perf: skip redundant composer measurement, cache i18next language codes and follow streaming once per frame
This commit is contained in:
@@ -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 提问"), {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user