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
@@ -605,6 +605,38 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
低成本热点:Composer 高度改为只在需要时测量、`t()` 结果或 i18next 语言码规范化做缓存、
滚动跟随按帧合并且只在贴底时执行。
#### 2026-10-02 第二步:三处低成本热点
- **Composer 高度(`App.tsx` `resizeComposerTextarea`):** 记住上次的值与高度。值未变不测量;
在原文本末尾追加时不再先设 `height: auto`(只会变高),已到最大高度直接跳过测量;删除、
替换、清空仍按原方式重置并测量。
- **i18next 语言码(`i18n/index.ts`):** i18next 25 每次 `t()` 都经
`toResolveHierarchy`→`formatLanguageCode` 调用 `Intl.getCanonicalLocales`。初始化后对
`languageUtils.formatLanguageCode` 加按输入缓存(结果只取决于代码与静态初始化选项,上限
64 项),翻译结果不变。
- **滚动跟随(`ChatHistoryPane.tsx`):** 激活面板时仍同步滚到底;之后内容更新时改为每帧最多
一次 `requestAnimationFrame` 中读取 `scrollHeight` 并 `scrollTo`,回调里再次确认仍贴底;
卸载时取消。读者离开底部时不滚动,恢复阅读位置逻辑不变。
- **测试:** 新增“流式三次更新只滚动一帧”(旧实现 3 次,失败)、Composer 追加与删除的测量
次数、`t()` 不再调用 `Intl.getCanonicalLocales`。全量测试 5,621 通过,2 失败与本改动
无关(真实 OpenCode 集成测试因免费额度返回 403;Supervisor 超时设置用例此前已失败)。
- **`npm run perf:app` A/B(同机,改动前为临时 worktree 中的 `983cdd4` 构建,交替运行):**
| 场景(4× 降速,3 轮) | 改动前 按键/点击 p50 / p95 | 改动后 |
| --- | --- | --- |
| 输入 120 字(新 profile) | 28–80 / 33–361 ms | 19–36 / 24–57 ms |
| 输入 120 字(有 300 个会话) | 265–2,372 / 1,486–4,266 ms | 27–161 / 32–346 ms |
| 首次切换会话 | 442–761 / 1,063–1,384 ms | 360–398 / 820–1,334 ms |
| 再次切换(缓存命中) | 472–749 / 501–1,143 ms | 390–420 / 456–951 ms |
| 流式 40 KB:Long Tasks 总时长 | 15.9–20.6 s | 15.1–17.4 s |
| 流式期间输入 | 432–6,778 / 1,146–9,634 ms | 96–466 / 297–1,567 ms |
不降速时(2 轮)两版各场景持平,输入 p95 均约 9 ms,流式 1 个 Long Task。4× 降速下
方差很大(同版本轮间可差数倍),应看量级:有历史时输入从秒级回到百毫秒内,切换略有改善,
流式本身仍是每帧卡顿——剩余成本是 App 级重渲染与 Markdown/React 提交,需 `PERF-13`。
- **剩余:** 流式 delta 按帧合并后以 transition 提交、大面板 memo 仍待实施;之后进入
`PERF-13`。
### PERF-13 Renderer 领域 Store 迁移
- **优先级 / 状态:** P1 / 待开始
+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