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
18 KiB
18 KiB
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/staging77057a819 - 链路:
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,源码逐条核实)
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 行,同样每次新建)。
- 每次注册都是新对象 →
setRegistration必然触发 →AppShell重渲染 →AppSidebar(无memo)重渲染,favoriteSessions / historySessions / groupSessionsByRecency重算;注册 effect 里visibleSessions.map(toSidebarSessionRow)每次全量重建行;SidebarProvider的context对象(ui/sidebar.tsx约 171 行)未记忆化,SidebarMenuButton/SidebarMenuLink等全部useSidebar()消费者跟着重渲染——侧栏每条会话行都是其中之一。 - 流式回答期间
use-consultation-run.ts的createStreamFrameBuffer每个动画帧setStreamingReply(next)一次(BUG-473 设计如此),Home每帧重渲染 → 上述整条链每帧一次。一条 900 字回答约几十到上百帧,会话列表越长越重。 - 没有死循环: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. 硬红线
- 不得冻结首次闭包:外壳拿到的回调,被调用时必须执行
Home最新一次渲染的版本(BUG-971)。 - Home 不得订阅
ShellRegistrationContext;AppShell不得重建children;注册仍在Home卸载时注销(registerShellControls(null))(BUG-971)。 frontend/tests/session-list-lifecycle.test.tsx里HomeProbe故意每次渲染传新数组、新回调(注释「Like useSessionManagement, intentionally new array/callbacks each render」)——不得改成固定常量来让测试通过(BUG-971 防复发原文)。新代码必须在这种不稳定输入下也做到不重复注册。- 不得在渲染期写 ref(react-hooks 编译器 lint 规则会拦);不得用
eslint-disable绕过。最新回调的保存放在 effect 里做(BUG-725 的actionsRef思路:组件拿稳定包装函数,包装函数读ref.current,ref.current在 effect 中更新)。 - 不改侧栏外观、文案、行顺序、分组、分页触发、菜单行为;单一
SidebarProvider、layout 只挂一次(BUG-744~746 合同测试)不变。 frontend/src/app/(app)/page.tsx:Home()的useState/useRef数不得增长(frontend/tests/home-shell-growth-contract.test.ts),行数在其护栏内;本单预期page.tsx不需要改,若要改只能是删减或改调用参数。- 不改流式提交节奏(
stream-frame-buffer,BUG-473);不启用 React Compiler;不升级依赖;不改.gitea/workflows/**;不动数据库与迁移;scripts/jyotish_api_server.py不碰。 - 改既有断言写「原值 / 新值 / 原因」三栏。已知必改一条:
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同法;另有一个framestate 模拟流式帧。会话列表放 30 条虚构会话(Synthetic session N)。- 驱动:hydrated 并 idle 后,清零计数;
harness.update连续推进 60 帧(每帧setFrame(n));再推进 1 次「真变化」(改activeSessionId);再 1 次「替换其中一行 session 对象(改标题)」。 - 计数:
registerShellControls调用次数(包一层 spy)、AppSidebarProfiler 提交次数、toSidebarSessionRow调用次数(可选,若能不改生产代码地计到)。 - 断言(B):60 帧期间注册 0 次、
AppSidebar提交 0 次;activeSessionId变化后注册 1 次且外壳拿到新值;改标题后侧栏行标题更新;随后调用外壳的onSelectSession/onRename,执行的是最新一次渲染传入的回调(记录 revision 断言)。 - 反向验证:同一测试在基线代码(
origin/staging77057a819)上运行,预期 60 帧对应注册 ≈ 60、提交 ≈ 60 而失败;把失败输出原样贴进 PROGRESS。
A/B 记录 docs/testing/home-sidebar-rerender-ab-20261013.md:
- A =
77057a819(在基线工作树里临时放入同一测试文件,只读运行,不提交到基线),B = 本分支。同一测试、同一输入,各跑 3 次取中位数。 - 度量:60 帧内注册次数、
AppSidebar提交次数、ProfileractualDuration合计(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自身每帧重渲染(只让直播行订阅流式状态)——属于咨询面架构,另立单。