From 90ea33ef5cba2e0669bdaba8ccf0a1821f9f2ba0 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Fri, 2 Oct 2026 16:57:29 +0800 Subject: [PATCH] perf: skip redundant composer measurement, cache i18next language codes and follow streaming once per frame --- ...performance-experience-improvement-plan.md | 32 ++++++++++ src/renderer/src/App.test.tsx | 62 ++++++++++++++++++- src/renderer/src/App.tsx | 46 ++++++++++++-- src/renderer/src/ChatHistoryPane.tsx | 31 +++++++++- src/renderer/src/i18n/i18n.test.ts | 18 +++++- src/renderer/src/i18n/index.ts | 31 ++++++++++ 6 files changed, 212 insertions(+), 8 deletions(-) diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index 8c008b40..759483d5 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -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 / 待开始 diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 3371cccd..6a19c4de 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -3660,7 +3660,7 @@ describe("App", () => { it("measures composer height once per input and resizes after sending", async () => { render(); 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(); + 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((resolve) => requestAnimationFrame(() => resolve())), + ); + const chat = document.querySelector(".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((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(); fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6bfd5f54..2277fac0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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; diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index 11bfe327..882c0a7e 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -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(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) { diff --git a/src/renderer/src/i18n/i18n.test.ts b/src/renderer/src/i18n/i18n.test.ts index d24daf59..3386228b 100644 --- a/src/renderer/src/i18n/i18n.test.ts +++ b/src/renderer/src/i18n/i18n.test.ts @@ -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() + } + }) }) diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 608ffa1f..f48488a4 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -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() + 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 { await i18n.changeLanguage(locale) document.documentElement.lang = locale