Files
Jyotisha/docs/tasks/TASK-home-sidebar-rerender-20261013.md
T
Jesse_ChenandClaude Opus 5.5 0be4cb299c docs(tasks): code-health + performance audit briefs, 16 tasks (BUG-1323 onward)
Batch 1 (dispatch now): rect-scoring-perf, node-runtime-resources,
home-sidebar-rerender, api-disk-cache-retention, report-reference-perf,
consult-prep-parallel, frontend-dead-code.
Batch 2 (queued on predecessors): api-chart-subprocess-gate, answer-effort-ab,
consult-route-split, pl9-render-split, python-hygiene, dasha-unify,
api-server-service-extract, data-layer-types, contracts-helpers-dedupe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-14 00:34:35 +08:00

18 KiB
Raw Blame History

TASK · 流式回答时首页侧栏每帧整栏重渲染(issue #待定)

  • 日期:2026-10-13
  • 来源:「2026-10-13 全仓屎山与性能排查」——P1「首页侧栏重渲染」
  • 执行方:Grok(Windows + Docker),分支 codex/home-sidebar-rerender-20261013,只推这个分支
  • 验收:Claude(Linux)
  • BUG 编号:BUG-1329(开工时核对 docs/BUG_HISTORY.md 最大号;写作时最大号 BUG-1317;号段由主会话分配)(另:TASK-upstream-sync7-20261014 占 BUG-1318~1322,本批号段整体从 BUG-1323 起)
  • 开工前置:无(第一批立即派发)

1. 基线

  • 基线 commit:origin/staging 77057a819
  • 链路:frontend/src/app/(app)/page.tsx::Home → frontend/src/hooks/use-home-shell-registration.ts::useHomeShellRegistration → frontend/src/lib/session-list-context.tsx::SessionListProvider.registerShellControls(setRegistration)→ ShellRegistrationContext → frontend/src/app/(app)/layout.tsx::AppShell → frontend/src/components/app-sidebar.tsx::AppSidebar 与 frontend/src/components/ui/sidebar.tsx::SidebarProvider。
  • 未开 React Compiler:frontend/next.config.ts 无 reactCompiler,无 babel.config.* / .babelrc,package.json 无 babel-plugin-react-compiler(eslint 的 react-hooks 编译器规则只做静态检查,不做自动记忆化)。BUG-725 也明确「不启用 React Compiler」。

2. 事故实证(77057a819,源码逐条核实)

  1. use-home-shell-registration.ts 第二个 useEffect(约 87–146 行)每次运行都 registerShellControls({...}),依赖数组含:
    • renameSession / selectSession / startNewChat / togglePinnedSession / shareSession / loadMoreSessions——它们在 use-session-management.ts::useSessionManagement(约 271–420 行)里是普通 function 声明,每次渲染都是新函数;
    • visibleSessions——同文件约 184 行 sortSessions(sessions.filter(...)),每次渲染都是新数组(即使只修回调,这一项也会让 effect 每次重跑);
    • openAccountDialog(use-profile-onboarding.ts 约 139 行,同样每次新建)。
  2. 每次注册都是新对象 → setRegistration 必然触发 → AppShell 重渲染 → AppSidebar(无 memo)重渲染,favoriteSessions / historySessions / groupSessionsByRecency 重算;注册 effect 里 visibleSessions.map(toSidebarSessionRow) 每次全量重建行;SidebarProvider 的 context 对象(ui/sidebar.tsx 约 171 行)未记忆化,SidebarMenuButton / SidebarMenuLink 等全部 useSidebar() 消费者跟着重渲染——侧栏每条会话行都是其中之一。
  3. 流式回答期间 use-consultation-run.ts 的 createStreamFrameBuffer 每个动画帧 setStreamingReply(next) 一次(BUG-473 设计如此),Home 每帧重渲染 → 上述整条链每帧一次。一条 900 字回答约几十到上百帧,会话列表越长越重。
  4. 没有死循环:BUG-971 已把 registration 拆进只给外壳订阅的 ShellRegistrationContext,Home 不订阅自己生产的注册,children 原样透传。所以是「Home 每渲染一次,外壳陪一次」,不是无限循环。

渲染次数「改前」由 T4 的计数测试在基线代码上实测给出(本机无 node_modules,起草时未跑;按源码推断:N 个流式帧 → 注册 N 次、AppSidebar 提交 N 次)。

3. 根因

useSessionManagement 返回的回调与 visibleSessions 没有稳定身份,而注册 effect 把它们全部列为依赖、且每次无条件发布新注册对象。BUG-971 修的是「Home 订阅自己的注册 → 死循环」,按其决策保留了完整依赖与最新回调,只切断了反馈;「外壳陪 Home 每渲染一次」这一半当时未处理。不是回归,是 BUG-971 遗留的已知开销;咨询面(BUG-473)与校正面(BUG-725)各自的消息列表已经做了记忆化边界,侧栏这一侧没人做。

关联:BUG-971(侧栏注册反馈循环)、BUG-927(共享外壳)、BUG-744~746(侧栏统一、单一 SidebarProvider)、BUG-725(actionsRef 做法)、BUG-473(流式按帧提交)。

4. 决策记录

  • 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行;本单 P1,需 A/B。
  • AGENTS.md §6 的「参数式 hook 内部保持 0 个 React hook」已于 2026-09-15 被推翻(TASK-freeze-metric-change-20260915.md,3b17c1b2;现行 §6:「抽出去的 hook 与子组件应当持有自己的状态,page.tsx 只做装配」)。所以本单可以在 useHomeShellRegistration / useSessionManagement 里用 useRef / useMemo / useCallback。执行方若手里的 AGENTS.md 副本还写着旧句,以 origin/staging 的 AGENTS.md 为准。
  • 不做运行时开关(偏离「P1 改动做成开关」的通用要求,理由):这是纯前端渲染次数优化,输出行为(注册内容、点击后调用的回调)要求与基线逐项一致;做开关就得同时保留两套注册路径,违反「不得写第二套」的精神,且 NEXT_PUBLIC_* 开关会进前端包。A/B 用「同一测试在基线与新代码上各跑一遍」完成。若主会话 / 产品要求开关,改为本单不做、回报「需拍板」。
  • 不推翻 BUG-971 的任何防复发条款(见红线 2~4)。

5. 硬红线

  1. 不得冻结首次闭包:外壳拿到的回调,被调用时必须执行 Home 最新一次渲染的版本(BUG-971)。
  2. Home 不得订阅 ShellRegistrationContext;AppShell 不得重建 children;注册仍在 Home 卸载时注销(registerShellControls(null))(BUG-971)。
  3. frontend/tests/session-list-lifecycle.test.tsx 里 HomeProbe 故意每次渲染传新数组、新回调(注释「Like useSessionManagement, intentionally new array/callbacks each render」)——不得改成固定常量来让测试通过(BUG-971 防复发原文)。新代码必须在这种不稳定输入下也做到不重复注册。
  4. 不得在渲染期写 ref(react-hooks 编译器 lint 规则会拦);不得用 eslint-disable 绕过。最新回调的保存放在 effect 里做(BUG-725 的 actionsRef 思路:组件拿稳定包装函数,包装函数读 ref.current,ref.current 在 effect 中更新)。
  5. 不改侧栏外观、文案、行顺序、分组、分页触发、菜单行为;单一 SidebarProvider、layout 只挂一次(BUG-744~746 合同测试)不变。
  6. frontend/src/app/(app)/page.tsx:Home() 的 useState / useRef 数不得增长(frontend/tests/home-shell-growth-contract.test.ts),行数在其护栏内;本单预期 page.tsx 不需要改,若要改只能是删减或改调用参数。
  7. 不改流式提交节奏(stream-frame-buffer,BUG-473);不启用 React Compiler;不升级依赖;不改 .gitea/workflows/**;不动数据库与迁移;scripts/jyotish_api_server.py 不碰。
  8. 改既有断言写「原值 / 新值 / 原因」三栏。已知必改一条:session-list-lifecycle.test.tsx 中 assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback)(回调稳定后两者相等)——新断言必须改为证明「调用到的是最新版本」(该用例已有 callbackRevisions 断言 [1],保留并加强),不得只删不补。

6. 任务分解

T1 · 注册只在「数据真的变了」时发布(use-home-shell-registration.ts)

  • 新增一个 latestRef(useRef),在第一个 effect里每次渲染后写入本次全部回调与 sessions(无依赖数组;不 setState,开销可忽略)。
  • 对外的回调全部改成稳定包装函数(useMemo(() => ({...}), []) 或 useCallback(..., [])),内部转调 latestRef.current.xxx(...):onLoadMore、onMenuSessionChange、onTogglePinned、onRename、onShare、onDelete、onAccountMenuOpenChange、onNewChat、onSelectSession、onOpenProfile/General/Billing/Logout。onRename / onShare / onDelete 里按 id 查 session 也改读 latestRef.current.sessions。
  • 注册 effect 改为「比较后发布」:在 effect 内(不是渲染期)与上次已发布的输入做比较,相同则不调用 registerShellControls:
    • 标量 / 引用字段:hydrated、account(引用)、profile.name、accountMenuOpen、modalOpen、activeSessionId、rectificationOpeningSessionId、rectificationSurfaceOpen、creatingSession、pendingSessionId、cancellationPending、modelCatalog(引用)、sessionMenuId、sessionsCursor、accountTrigger(引用)、chartLibrary(引用);
    • visibleSessions:逐元素引用相等(长度相同且每个 session 对象 ===)即视为未变——setSessions 只替换改动的那一行对象,未改的行引用不变;
    • 未变时连 toSidebarSessionRow 都不跑;变了才重建 sidebarSessions 并发布。
    • 上次已发布的输入存在一个 effect 内读写的 ref 里;registerShellControls(null)(未 hydrated / 无账户 / 卸载)后清空它,保证重新进入 Home 时一定重新发布。
  • 依赖数组可以保留完整(effect 每次跑但大多提前返回),或收窄到数据字段——二选一,以 lint 0 error、无 eslint-disable 为准。
  • 不改 ShellRegistration 类型与 session-list-context.tsx 的 provider 结构。
  • 验收:见 T4;session-list-lifecycle.test.tsx 全部用例(含 StrictMode 两遍)通过。

T2 · useSessionManagement 的 visibleSessions 记忆化(use-session-management.ts)

  • visibleSessions 用 useMemo,依赖 sessions 与当前人物 id。当前代码在渲染期调用 readCurrentSubjectId();改为用 lib/current-subject.ts 已有的 useCurrentSubject() 返回值进依赖(确认该 hook 在人物切换时触发重渲染;SessionListProvider 已在用它)。
  • 这一步让真实页面里 T1 的比较几乎总是一次命中;T1 不依赖它(测试里输入故意不稳定)。
  • 不把其余函数改成 useCallback(T1 的稳定包装已覆盖外壳用到的那些;全量改造面太大,见「后续」)。
  • 验收:人物切换后侧栏列表正确换人(tests/chat-profile-picker.test.ts 全绿,并在 T4 测试里加一条「切换人物后 sidebarSessions 换成新人物的会话」);tsc、lint 0 error。

T3 · SidebarProvider 的 context 值记忆化(components/ui/sidebar.tsx,可砍)

  • context 用 useMemo;setInsetTrigger / setSidebarSurface 改 useCallback(只写 ref,安全)。依赖为 open / openMobile / viewport / ready / isMobile / setOpen / setOpenMobile / toggleSidebar。
  • 作用:注册真变化时(如切会话),SidebarMenuButton 等消费者不再因为 context 对象换新而额外重渲染。
  • 验收:sidebar-state.test.ts、sidebar-contract.test.ts 全绿、无断言改动。

T4 · 回归测试 + A/B(渲染计数)

新测试 frontend/tests/home-sidebar-rerender.test.tsx(用现有 tests/react-client-lifecycle-test-support.ts::createClientLifecycleHarness,真实 React reconciler 与 effect):

  • 结构:SessionListProvider → 与 layout.tsx::AppShell 同构的外壳(SidebarProvider + 真实 AppSidebar 包在 <Profiler id="sidebar" onRender={…}> 里计提交次数 + SidebarInset)→ HomeProbe。
  • HomeProbe 调用 useHomeShellRegistration,每次渲染传新回调、新 visibleSessions 数组(元素对象引用不变),与 session-list-lifecycle.test.tsx 同法;另有一个 frame state 模拟流式帧。会话列表放 30 条虚构会话(Synthetic session N)。
  • 驱动:hydrated 并 idle 后,清零计数;harness.update 连续推进 60 帧(每帧 setFrame(n));再推进 1 次「真变化」(改 activeSessionId);再 1 次「替换其中一行 session 对象(改标题)」。
  • 计数:registerShellControls 调用次数(包一层 spy)、AppSidebar Profiler 提交次数、toSidebarSessionRow 调用次数(可选,若能不改生产代码地计到)。
  • 断言(B):60 帧期间注册 0 次、AppSidebar 提交 0 次;activeSessionId 变化后注册 1 次且外壳拿到新值;改标题后侧栏行标题更新;随后调用外壳的 onSelectSession / onRename,执行的是最新一次渲染传入的回调(记录 revision 断言)。
  • 反向验证:同一测试在基线代码(origin/staging 77057a819)上运行,预期 60 帧对应注册 ≈ 60、提交 ≈ 60 而失败;把失败输出原样贴进 PROGRESS。

A/B 记录 docs/testing/home-sidebar-rerender-ab-20261013.md:

  • A = 77057a819(在基线工作树里临时放入同一测试文件,只读运行,不提交到基线),B = 本分支。同一测试、同一输入,各跑 3 次取中位数。
  • 度量:60 帧内注册次数、AppSidebar 提交次数、Profiler actualDuration 合计(ms);另报 30 条与 100 条会话两档。
  • 正确性:B 的「真变化」后外壳收到的 registration 与 A 同一时刻收到的 registration,在剔除函数字段后 JSON.stringify 逐字节一致;函数字段用「调用后记录的 revision」比对一致。
  • 判定门槛:B 在 60 帧内注册 = 0 且 AppSidebar 提交 = 0(A 预期 ≈ 60),且逐字节一致、session-list-lifecycle 全绿 → 交付。达不到 0 时写明剩余提交的来源,由 Claude 判断。
  • 浏览器侧(Grok 可选、Windows 本机):npm run dev,Chrome 装 React DevTools,Profiler 勾选「Record why each component rendered」,在登录态首页发一条问题,录完整个流式回答,记录 AppSidebar 的提交次数,A/B 各一次截图存 PROGRESS(不含任何真实会话内容,截图前把侧栏折叠或用虚构账户)。没有可用账户或模型 key 就写「浏览器 A/B 未跑」,以测试计数为准;同样的步骤写进 docs/testing/home-sidebar-rerender-20261013.md 真机清单留给产品负责人。

T5 · 记录

  • docs/BUG_HISTORY.md 新增 BUG-1329(流式回答期间首页侧栏每帧整栏重渲染):根因写「BUG-971 只切断反馈、未做发布去重」,关联 BUG-971 / 725 / 473 / 744~746;防复发写「外壳注册须按数据变化发布;任何流式会话面要有侧栏提交次数的按帧断言」。
  • docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md:基线失败输出、三栏断言改动表、A/B 结论。
  • frontend/DESIGN.md:在侧栏 / 共享外壳条目下补一句「流式期间侧栏不随回答帧重渲染(BUG-1329)」——属于行为约束,不是视觉改动;CHANGELOG.md 一句「回答生成时侧栏不再跟着闪动 / 卡顿」。Skill 不 bump。

7. 让步顺序

T1 + T4(必须一起交)→ T2 → T3 → 浏览器侧 A/B。时间不够先砍 T3,再砍 T2;T1 不能脱离 T4 的回归测试单独交付。

8. 验收命令分工

谁 命令
Grok 自跑 ./node_modules/.bin/tsc --noEmit;npm run lint(0 error,不加 eslint-disable);npx tsx --test tests/home-sidebar-rerender.test.tsx tests/session-list-lifecycle.test.tsx tests/session-list-provider.test.ts tests/sidebar-contract.test.ts tests/sidebar-state.test.ts tests/secondary-page-entry.test.ts tests/account-dialog-inert-20260918.test.ts tests/home-shell-growth-contract.test.ts tests/home-streaming-render-split.test.ts(fail 与 cancelled 都为 0);A/B 三次。前端全量 npm test、next build 不跑
Claude 验收 上述全部复验 + 在基线上复跑新测试确认会红 + 前端全量 npm test 与基线逐名比对 + next build(/ 仍 Static,首屏 gzip ±2%)
不需要 test:db、Python 测试(不碰数据库访问层与引擎)

9. 开工前置命令

git fetch origin --prune
git worktree add -b codex/home-sidebar-rerender-20261013 .worktrees/home-sidebar-rerender-20261013 origin/staging
cd .worktrees/home-sidebar-rerender-20261013
git log -1 --format=%h                          # 应为 77057a819 或其后代
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3      # 核对最大号
grep -n "参数式\|持有自己的状态" AGENTS.md            # 应只看到「抽出去的 hook 与子组件应当持有自己的状态」
grep -n "reactCompiler\|babel-plugin-react-compiler" next.config.ts package.json frontend/next.config.ts frontend/package.json 2>/dev/null   # 应无输出
grep -rn "notEqual(app.shell?.controls\|intentionally new array" frontend/tests
# A/B 用的基线工作树(只读)
git worktree add --detach .worktrees/base-home-sidebar-rerender origin/staging

10. 交付物清单

  • 代码:frontend/src/hooks/use-home-shell-registration.ts、frontend/src/hooks/use-session-management.ts、(可砍)frontend/src/components/ui/sidebar.tsx
  • 测试:frontend/tests/home-sidebar-rerender.test.tsx(新);frontend/tests/session-list-lifecycle.test.tsx(一条断言按三栏改)
  • 文档:docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md、docs/testing/home-sidebar-rerender-ab-20261013.md、docs/testing/home-sidebar-rerender-20261013.md(真机清单)、docs/BUG_HISTORY.md(BUG-1329)、frontend/DESIGN.md(一句)、CHANGELOG.md

11. 后续阶段(本单不做)

  • useSessionManagement 其余函数全面 useCallback 化 / 拆分持有状态(属于 Home 状态下沉系列)。
  • AppSidebar 用 memo 包裹并拆出会话分组子组件——T1 之后外壳只在数据变化时重渲染,收益小,等 A/B 显示仍有必要再立单。
  • 流式期间 Home 自身每帧重渲染(只让直播行订阅流式状态)——属于咨询面架构,另立单。