perf: skip redundant composer measurement, cache i18next language codes and follow streaming once per frame

This commit is contained in:
mesalogo
2026-10-02 16:57:29 +08:00
parent 983cdd4e05
commit 90ea33ef5c
6 changed files with 212 additions and 8 deletions
+61 -1
View File
@@ -3660,7 +3660,7 @@ describe("App", () => {
it("measures composer height once per input and resizes after sending", async () => {
render(<App />);
const input = await screen.findByLabelText("向 GoodBuddy 提问");
const measureHeight = vi.fn(() =>
const measureHeight = vi.fn((): number =>
(input as HTMLTextAreaElement).value ? 300 : 0,
);
Object.defineProperty(input, "scrollHeight", {
@@ -3672,6 +3672,21 @@ describe("App", () => {
expect(measureHeight).toHaveBeenCalledTimes(1);
expect(input).toHaveStyle({ height: "220px" });
// Appending at the maximum height cannot change the size.
measureHeight.mockClear();
fireEvent.input(input, { target: { value: "First line\nSecond line!" } });
expect(measureHeight).not.toHaveBeenCalled();
expect(input).toHaveStyle({ height: "220px" });
// Deleting text may shrink the box, so it is measured again.
measureHeight.mockImplementation(() => 100);
fireEvent.input(input, { target: { value: "First" } });
expect(measureHeight).toHaveBeenCalledTimes(1);
expect(input).toHaveStyle({ height: "100px" });
measureHeight.mockImplementation(() =>
(input as HTMLTextAreaElement).value ? 300 : 0,
);
measureHeight.mockClear();
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(input).toHaveValue(""));
@@ -4477,6 +4492,51 @@ describe("App", () => {
expect(screen.getByRole("button", { name: "到底部" })).toBeInTheDocument();
});
it("follows streaming output at the bottom once per frame", async () => {
render(<App />);
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
target: { value: "生成流式回复" },
});
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]?.[0];
if (!request) {
throw new Error("Missing request");
}
await act(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
const chat = document.querySelector<HTMLElement>(".chat");
if (!chat) {
throw new Error("Missing chat scroll container");
}
let scrollHeight = 1_200;
Object.defineProperties(chat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: { configurable: true, get: () => scrollHeight },
scrollTop: { configurable: true, writable: true, value: 800 },
});
const scrollTo = vi.fn();
chat.scrollTo = scrollTo;
for (const delta of ["第一段", "第二段", "第三段"]) {
scrollHeight += 100;
act(() => {
agentListener?.({ requestId: request.requestId, type: "text", delta });
});
}
expect(await screen.findByText(/第三段/u)).toBeInTheDocument();
await act(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
);
expect(scrollTo).toHaveBeenCalled();
expect(scrollTo.mock.calls.length).toBeLessThanOrEqual(2);
expect(scrollTo).toHaveBeenLastCalledWith({ top: 1_500, behavior: "auto" });
});
it("restores the reader position after activity continues on another page", async () => {
const { container } = render(<App />);
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
+41 -5
View File
@@ -1354,15 +1354,51 @@ function formatAttachmentSize(size: number): string {
const composerTextareaMinHeight = 72;
const composerTextareaMaxHeight = 220;
const composerTextareaSizes = new WeakMap<
HTMLTextAreaElement,
{ height: number; value: string }
>();
function clampComposerTextareaHeight(height: number): number {
return Math.max(
composerTextareaMinHeight,
Math.min(height, composerTextareaMaxHeight),
);
}
// Sizing reads scrollHeight, which forces a synchronous layout of the whole
// window. Skip the read when the result is known, and avoid the extra
// "auto" reset layout when text was only appended (it can only grow).
function resizeComposerTextarea(textarea: HTMLTextAreaElement | null): void {
if (!textarea) {
return;
}
textarea.style.height = "auto";
textarea.style.height = `${Math.max(
composerTextareaMinHeight,
Math.min(textarea.scrollHeight, composerTextareaMaxHeight),
)}px`;
const value = textarea.value;
const previous = composerTextareaSizes.get(textarea);
const previousHeightApplied =
previous !== undefined &&
textarea.style.height === `${previous.height}px`;
let height: number;
if (previousHeightApplied && value === previous.value) {
height = previous.height;
} else if (
previousHeightApplied &&
previous.value !== "" &&
value.startsWith(previous.value)
) {
height =
previous.height >= composerTextareaMaxHeight
? composerTextareaMaxHeight
: clampComposerTextareaHeight(textarea.scrollHeight);
} else {
textarea.style.height = "auto";
height = clampComposerTextareaHeight(textarea.scrollHeight);
}
const nextHeight = `${height}px`;
if (textarea.style.height !== nextHeight) {
textarea.style.height = nextHeight;
}
composerTextareaSizes.set(textarea, { height, value });
}
const imageDataUrlPattern = /^data:image\/(png|jpeg|webp);base64,/u;
+30 -1
View File
@@ -109,6 +109,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const latestScrollSnapshotRef = useRef(scrollSnapshot);
const restorePendingRef = useRef(true);
const wasActiveRef = useRef(false);
const followFrameRef = useRef<number | undefined>(undefined);
const prependScrollPositionRef = useRef<
| {
scrollHeight: number;
@@ -260,14 +261,42 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
return;
}
}
if (pinnedToBottomRef.current) {
if (!pinnedToBottomRef.current) {
return;
}
if (activated) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "auto",
});
return;
}
// Streaming updates commit many times per frame. Reading scrollHeight here
// would force a synchronous layout per commit, so follow the bottom once
// per frame instead; rAF still runs before the frame is painted.
if (followFrameRef.current !== undefined) {
return;
}
followFrameRef.current = requestAnimationFrame(() => {
followFrameRef.current = undefined;
const container = scrollRef.current;
if (!container || !pinnedToBottomRef.current) {
return;
}
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
});
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
useEffect(
() => () => {
if (followFrameRef.current !== undefined) {
cancelAnimationFrame(followFrameRef.current);
followFrameRef.current = undefined;
}
},
[],
);
useLayoutEffect(() => {
const previous = prependScrollPositionRef.current;
if (!previous) {
+17 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import i18n, {
changeUiLocale,
i18nResources
@@ -41,4 +41,20 @@ describe('renderer i18n resources', () => {
await changeUiLocale('zh-CN')
expect(i18n.t('navigation.chat')).toBe('对话')
})
it('canonicalizes language codes once instead of on every translation', async () => {
const getCanonicalLocales = vi.spyOn(Intl, 'getCanonicalLocales')
try {
await changeUiLocale('en-US')
expect(i18n.t('navigation.chat')).toBe('Chat')
getCanonicalLocales.mockClear()
for (let index = 0; index < 50; index += 1) {
i18n.t('navigation.chat')
}
expect(getCanonicalLocales).not.toHaveBeenCalled()
expect(i18n.services.languageUtils.formatLanguageCode('zh-cn')).toBe('zh-CN')
} finally {
getCanonicalLocales.mockRestore()
}
})
})
+31
View File
@@ -68,6 +68,37 @@ void i18n
returnNull: false
})
memoizeLanguageCodeFormatting(i18n)
/**
* i18next canonicalizes the current language with `Intl.getCanonicalLocales`
* on every `t()` call. The result depends only on the code and the static init
* options, so cache it; large lists call `t()` hundreds of times per render.
*/
export function memoizeLanguageCodeFormatting(instance: typeof i18n): void {
const languageUtils = instance.services?.languageUtils as
| { formatLanguageCode?: (code: string) => string }
| undefined
const format = languageUtils?.formatLanguageCode
if (!languageUtils || typeof format !== 'function') {
return
}
const cache = new Map<string, string>()
languageUtils.formatLanguageCode = (code: string): string => {
if (typeof code !== 'string') {
return format.call(languageUtils, code)
}
let formatted = cache.get(code)
if (formatted === undefined) {
formatted = format.call(languageUtils, code)
if (cache.size < 64) {
cache.set(code, formatted)
}
}
return formatted
}
}
export async function changeUiLocale(locale: UiLocale): Promise<void> {
await i18n.changeLanguage(locale)
document.documentElement.lang = locale