diff --git a/CHANGELOG.md b/CHANGELOG.md index d2056bfc..b33c7a69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-14 — 回答生成时侧栏不再跟着闪动 / 卡顿(待合入 staging) + +- 回答生成时侧栏不再跟着闪动 / 卡顿。点会话、改名、换人仍是当前这一下的操作。Skill 版本不变(BUG-1329)。 + ## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging) - 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index add22027..6079785c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17795,3 +17795,18 @@ - 复发自:无。 - 修复版本:未修复。 +## BUG-1329 | 流式回答期间首页侧栏每帧整栏重渲染 + +- 状态:fixed-pending-verify +- 首次发现 / 最近更新:2026-10-13 / 2026-10-14 +- 来源:issue #59。任务书 `docs/tasks/TASK-home-sidebar-rerender-20261013.md`。 +- 现象:回答逐字出来时,左侧会话列表跟着每一帧重画。会话越多越重。外观、顺序和点击结果没有变。 +- 触发条件:首页已登录,回答按帧写入。每一帧都会让首页再渲染一次。 +- 根因:BUG-971 切断了「首页订阅自己的注册」这条反馈,但注册仍然每次渲染都发布一份新对象。会话回调和可见列表每次渲染都是新的,外壳就跟着提交一次。不是回归,是当时留下的开销。 +- 修复:外壳拿到的是稳定包装,点下去仍执行首页最新一次渲染的回调。注册只在会话、当前项、账户或开关这些数据变了才发布;列表逐条对象没变就不重建行。可见列表按当前人物记忆。侧栏上下文对象也不再每次新建。没有运行时开关。 +- 验证:同一套计数测试。改前 60 帧注册 60 次、侧栏提交 60 次;改后注册 0 次、提交 0 次。换当前项、改标题、换人各发布 1 次。包装函数身份不变,调用记到的是最新一次渲染。详见 `docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md`。 +- 防复发:外壳注册须按数据变化发布。任何流式会话面要有侧栏提交次数的按帧断言。`frontend/tests/home-sidebar-rerender.test.tsx` 在 60 帧内要求注册 0 次、侧栏提交 0 次。 +- 相关记录:BUG-971、BUG-725、BUG-473、BUG-744、BUG-745、BUG-746。 +- 复发自:无。 +- 修复版本:分支 `codex/home-sidebar-rerender-20261013`,尚未合入 staging。 + diff --git a/docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md b/docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md new file mode 100644 index 00000000..2b74e92c --- /dev/null +++ b/docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md @@ -0,0 +1,54 @@ +# PROGRESS · 流式回答时侧栏不再跟着每帧重画(issue #59) + +- 分支:`codex/home-sidebar-rerender-20261013` +- 实际基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f` +- 任务书基线写成 `77057a819`,计划评论里的 base 是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。开工时远端 staging 已是上面的 `66cd6008`。A/B 就跑在这个提交上,没有另开只读工作树(本轮不能离开当前工作树)。方向没有改,不需要拍板。 +- `docs/BUG_HISTORY.md` 在这个基线上停在 BUG-1322。BUG-1323~1328、1345、1346 属于还没合入的别的分支,本单只用 BUG-1329。 +- 未改 `page.tsx`。没有运行时开关。没有启用 React Compiler。Skill 版本不变。浏览器 A/B 未跑。 + +## 做了什么 + +外壳拿到的点击函数不再每次渲染换新,点下去仍执行首页刚渲染的那一版。注册只在数据变了才发给侧栏:会话行要逐条还是同一个对象才算没变,没变就不重建行。可见会话按当前人物记忆。侧栏自己的上下文对象也不再每次新建。卸载首页时仍会清掉注册。 + +## 断言三栏 + +| 位置 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `frontend/tests/session-list-lifecycle.test.tsx` 外壳「打开资料」函数 | `assert.notEqual(onOpenProfile, initialCallback)` | `assert.equal(...)`;再改一版后仍是同一个函数,调用记录从 `[1]` 加强到 `[1, 2]` | 包装函数身份稳定。调用必须落到最新一次渲染,不能只删掉「不相等」 | + +`HomeProbe` 仍是每次渲染新回调、新数组,没有改成常量。 + +## 基线失败(改代码之前,同一测试,3 次,退出码都是 1) + +失败停在 `assert.equal(duringFrames.registers, 0)`,实际是 60。60 帧里侧栏提交也是 60。换当前项、改标题、换人各注册 1 次;只改回调版本也会再注册 1 次。剔除函数后的内容摘要: + +| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时三次(毫秒) | 中位 | 内容摘要 | +| --- | --- | --- | --- | --- | --- | +| 30 条虚构会话 | 60 | 60 | 2774.964 / 2778.961 / 4202.085 | 2778.961 | `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` | +| 100 条虚构会话 | 60 | 60 | 10530.828 / 11799.253 / 12284.103 | 11799.253 | `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` | + +更早一次探针把注册函数也做成每次渲染新建,60 帧数到 120 次注册、60 次提交。那是清理函数跟着新函数又清了一次,不是产品路径。交付用的测试和生产一样,用提供者给出的稳定注册函数,所以改前是 60 而不是 120。 + +## 改后(同一测试,3 次,退出码都是 0) + +| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时中位 | 换当前项 | 改标题 | 只改回调版本 | 换人 | 内容摘要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 30 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 | +| 100 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 | + +三次摘要都与上表相同。门槛达到:60 帧内注册 0、侧栏提交 0,内容摘要逐字节一致。 + +## 自测 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 退出码 0 | +| 规范 | `npm run lint --prefix frontend` | 0 error,126 条原有 warning。本单文件没有新增 warning,没有 `eslint-disable` | +| 定向测试 | `tsx --test` 下表 10 个文件 | tests 94,pass 94,fail 0,cancelled 0 | +| 数据库 | 未跑 | 本单不碰数据库 | +| 浏览器 | 未跑 | 浏览器 A/B 未跑。步骤在 `docs/testing/home-sidebar-rerender-20261013.md` | +| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 通过 | + +定向文件:`home-sidebar-rerender`、`session-list-lifecycle`、`session-list-provider`、`sidebar-contract`、`sidebar-state`、`secondary-page-entry`、`account-dialog-inert-20260918`、`home-shell-growth-contract`、`home-streaming-render-split`、`chat-profile-picker`。 + +全量 `npm test`、`next build`、Python 快速门未跑,留给验收。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 58b326a6..a91088de 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -469,7 +469,7 @@ | --- | --- | --- | --- | --- | | `TASK-rect-scoring-perf-20261013.md` | — | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待领取 | `codex/rect-scoring-perf-20261013` | | `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` | -| `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` | +| `TASK-home-sidebar-rerender-20261013.md` | `PROGRESS-home-sidebar-rerender-20261013.md` | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待验收 | `codex/home-sidebar-rerender-20261013` | | `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` | | `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` | | `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` | diff --git a/docs/testing/home-sidebar-rerender-20261013.md b/docs/testing/home-sidebar-rerender-20261013.md new file mode 100644 index 00000000..0f24a94f --- /dev/null +++ b/docs/testing/home-sidebar-rerender-20261013.md @@ -0,0 +1,12 @@ +# 真机走查 · 回答生成时侧栏不再跟着闪(BUG-1329,issue #59) + +浏览器 A/B 未跑。部署到 staging 后,在 `https://staging.jyotisha.chat` 登录一个没有真实会话内容需要露出的账号,按顺序看: + +1. 电脑浏览器打开首页。左侧会话列表的样子、顺序、分组和以前一样。 +2. 发一个普通问题,等回答一个字一个字出来。这段时间左侧列表不跟着闪、不跟着整栏重画。回答本身仍是逐字出现。 +3. 回答过程中点另一条会话,能打开那一条。点当前这条的菜单,改名、置顶、分享、删除仍可用,改的是刚刚点的那一条。 +4. 右上角账户菜单仍能打开资料、通用、账单和退出。 +5. 换一个人物后,左侧列表换成那个人的会话,不串到别人的。 +6. 手机宽度再看一遍第 2 步。侧栏抽屉的开关和以前一样。 + +任何一步不对,在 issue #59 里写是哪一步、哪台设备,并把标签改回 `agent/待规划`。不要贴出真实会话内容。 diff --git a/docs/testing/home-sidebar-rerender-ab-20261013.md b/docs/testing/home-sidebar-rerender-ab-20261013.md new file mode 100644 index 00000000..0fc3e5f1 --- /dev/null +++ b/docs/testing/home-sidebar-rerender-ab-20261013.md @@ -0,0 +1,18 @@ +# A/B · 回答生成时侧栏重画(BUG-1329,issue #59) + +同一测试 `frontend/tests/home-sidebar-rerender.test.tsx`,同一输入。A 是改代码前的 `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 已被后续提交盖过)。B 是分支 `codex/home-sidebar-rerender-20261013`。各跑 3 次。会话都是虚构的「Synthetic session N」。没有真实会话内容。 + +度量是 60 帧里的注册次数、侧栏提交次数、这 60 帧的 Profiler 耗时合计。内容用剔除函数字段后的 SHA-256 比较。 + +| 档 | A 注册 / 提交 / 耗时中位 | B 注册 / 提交 / 耗时中位 | 内容 | +| --- | --- | --- | --- | +| 30 条 | 60 / 60 / 2778.961 毫秒 | 0 / 0 / 0 | 两边都是 `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` | +| 100 条 | 60 / 60 / 11799.253 毫秒 | 0 / 0 / 0 | 两边都是 `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` | + +A 的三次 30 条耗时:2774.964、2778.961、4202.085。100 条:10530.828、11799.253、12284.103。B 的三次都是 0。 + +正确性:换当前项后 B 注册 1 次,外壳看到新的当前项。改一行标题后标题更新。换成另一人的会话后列表换掉。只改回调版本不再注册,点「打开会话」和「改名」记到的都是第 4 版。A 在只改回调版本时还会再注册 1 次;B 不注册,但调用结果相同。 + +判定:B 在 60 帧内注册 0、侧栏提交 0,内容摘要一致,`session-list-lifecycle` 全绿。可以交付。 + +浏览器 A/B 未跑。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index bf73e26d..f64355ab 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -551,6 +551,7 @@ one skeleton, none of them carrying the sidebar. `children` through unchanged. Registering fresh controls/rows must not render their producer again: that feedback loop can starve client navigation. Business changes still publish current callbacks; Home unmount clears them. + 流式期间侧栏不随回答帧重渲染(BUG-1329)。 - **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the product. `/` passes `controls`; the secondary layout does not, and without it the same markup renders read-only. There used to be a second component for diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index 35a905ab..541d14fb 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -8,6 +8,7 @@ import { useCallback, useContext, useEffect, + useMemo, useRef, useState, type ComponentProps, @@ -169,7 +170,13 @@ export function SidebarProvider({ else setOpen(!open); }, [isMobile, open, openMobile, setOpen, setOpenMobile]); - const context: SidebarProviderContextValue = { + const setInsetTrigger = useCallback((element: HTMLButtonElement | null) => { + insetTriggerRef.current = element; + }, []); + const setSidebarSurface = useCallback((element: HTMLElement | null) => { + sidebarSurfaceRef.current = element; + }, []); + const context = useMemo(() => ({ state: open ? "expanded" : "collapsed", open, setOpen, @@ -179,9 +186,12 @@ export function SidebarProvider({ ready, isMobile, toggleSidebar, - setInsetTrigger: (element) => { insetTriggerRef.current = element; }, - setSidebarSurface: (element) => { sidebarSurfaceRef.current = element; }, - }; + setInsetTrigger, + setSidebarSurface, + }), [ + open, openMobile, viewport, ready, isMobile, setOpen, setOpenMobile, toggleSidebar, + setInsetTrigger, setSidebarSurface, + ]); return ( diff --git a/frontend/src/hooks/use-home-shell-registration.ts b/frontend/src/hooks/use-home-shell-registration.ts index 9b84fdf8..1522cc64 100644 --- a/frontend/src/hooks/use-home-shell-registration.ts +++ b/frontend/src/hooks/use-home-shell-registration.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react"; +import { useEffect, useMemo, useRef, type Dispatch, type RefObject, type SetStateAction } from "react"; import type { ChatSession } from "@/lib/home-types"; import type { Account } from "@/lib/home-types"; @@ -43,6 +43,68 @@ type HomeShellRegistrationParams = { visibleSessions: readonly ChatSession[]; }; +type LatestShellActions = { + sessions: readonly ChatSession[]; + loadMoreSessions: () => void; + renameSession: (session: ChatSession) => void | Promise; + selectSession: (sessionId: string) => void; + shareSession: (session: ChatSession) => void | Promise; + startNewChat: () => void | Promise; + togglePinnedSession: (sessionId: string) => void; + openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void; + setSessionMenuId: Dispatch>; + setAccountMenuOpen: Dispatch>; + setPendingSessionDeletion: Dispatch>; +}; + +type PublishedShellInputs = { + hydrated: boolean; + account: Account; + profileName: string; + accountMenuOpen: boolean; + modalOpen: boolean; + activeSessionId: string | undefined; + rectificationOpeningSessionId: string | null; + rectificationSurfaceOpen: boolean; + creatingSession: boolean; + pendingSessionId: string | null; + cancellationPending: boolean; + modelCatalog: PublicLanguageModelCatalog | null; + sessionMenuId: string | null; + sessionsCursor: string | null; + accountTrigger: RefObject; + chartLibrary: readonly ChartLibraryRecord[]; + visibleSessions: readonly ChatSession[]; +}; + +function sameVisibleSessions(left: readonly ChatSession[], right: readonly ChatSession[]) { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function samePublishedInputs(previous: PublishedShellInputs, next: PublishedShellInputs) { + return previous.hydrated === next.hydrated + && previous.account === next.account + && previous.profileName === next.profileName + && previous.accountMenuOpen === next.accountMenuOpen + && previous.modalOpen === next.modalOpen + && previous.activeSessionId === next.activeSessionId + && previous.rectificationOpeningSessionId === next.rectificationOpeningSessionId + && previous.rectificationSurfaceOpen === next.rectificationSurfaceOpen + && previous.creatingSession === next.creatingSession + && previous.pendingSessionId === next.pendingSessionId + && previous.cancellationPending === next.cancellationPending + && previous.modelCatalog === next.modelCatalog + && previous.sessionMenuId === next.sessionMenuId + && previous.sessionsCursor === next.sessionsCursor + && previous.accountTrigger === next.accountTrigger + && previous.chartLibrary === next.chartLibrary + && sameVisibleSessions(previous.visibleSessions, next.visibleSessions); +} + export function useHomeShellRegistration(params: HomeShellRegistrationParams) { const { account, @@ -75,6 +137,27 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { togglePinnedSession, visibleSessions, } = params; + const profileName = profile.name; + const latestRef = useRef(null); + const publishedRef = useRef(null); + + useEffect(() => { + // No dependency list: this has to land after every render and before the + // publish effect, so a later click runs this render's callbacks (BUG-725). + latestRef.current = { + sessions, + loadMoreSessions, + renameSession, + selectSession, + shareSession, + startNewChat, + togglePinnedSession, + openAccountDialog, + setSessionMenuId, + setAccountMenuOpen, + setPendingSessionDeletion, + }; + }); useEffect(() => { // The inline dead-screen script stands down only after the app has revealed. @@ -84,12 +167,59 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { document.documentElement.dataset.hydrated = "1"; }, [hydrated]); + const stableControls = useMemo(() => ({ + onLoadMore: () => { latestRef.current?.loadMoreSessions(); }, + onMenuSessionChange: (sessionId: string | null) => { latestRef.current?.setSessionMenuId(sessionId); }, + onTogglePinned: (sessionId: string) => { latestRef.current?.togglePinnedSession(sessionId); }, + onRename: (sessionId: string) => { + const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId); + if (session) void latestRef.current?.renameSession(session); + }, + onShare: (sessionId: string) => { + const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId); + if (session) void latestRef.current?.shareSession(session); + }, + onDelete: (sessionId: string) => { + const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId); + if (session) latestRef.current?.setPendingSessionDeletion(session); + }, + onAccountMenuOpenChange: (open: boolean) => { latestRef.current?.setAccountMenuOpen(open); }, + onNewChat: () => { void latestRef.current?.startNewChat(); }, + onSelectSession: (sessionId: string) => { latestRef.current?.selectSession(sessionId); }, + onOpenProfile: () => { latestRef.current?.openAccountDialog("profile"); }, + onOpenGeneral: () => { latestRef.current?.openAccountDialog("general"); }, + onOpenBilling: () => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); }, + onOpenLogout: () => { latestRef.current?.openAccountDialog("logout"); }, + }), []); + useEffect(() => { if (!hydrated || !account) { + publishedRef.current = null; registerShellControls(null); return; } - const name = profile.name.trim(); + const next: PublishedShellInputs = { + hydrated, + account, + profileName, + accountMenuOpen, + modalOpen, + activeSessionId, + rectificationOpeningSessionId, + rectificationSurfaceOpen, + creatingSession, + pendingSessionId, + cancellationPending, + modelCatalog, + sessionMenuId, + sessionsCursor, + accountTrigger, + chartLibrary, + visibleSessions, + }; + if (publishedRef.current && samePublishedInputs(publishedRef.current, next)) return; + publishedRef.current = next; + const name = profileName.trim(); const email = account.user.email || "尚未读取邮箱"; registerShellControls({ escapeBlocked: accountMenuOpen || modalOpen, @@ -112,42 +242,34 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) { accountTriggerRef: accountTrigger, sessionControls: { hasMore: Boolean(sessionsCursor), - onLoadMore: loadMoreSessions, + onLoadMore: stableControls.onLoadMore, menuSessionId: sessionMenuId, disabled: Boolean(pendingSessionId) || cancellationPending, - onMenuSessionChange: setSessionMenuId, - onTogglePinned: togglePinnedSession, - onRename: (sessionId) => { - const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) void renameSession(session); - }, - onShare: (sessionId) => { - const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) void shareSession(session); - }, - onDelete: (sessionId) => { - const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) setPendingSessionDeletion(session); - }, + onMenuSessionChange: stableControls.onMenuSessionChange, + onTogglePinned: stableControls.onTogglePinned, + onRename: stableControls.onRename, + onShare: stableControls.onShare, + onDelete: stableControls.onDelete, }, - onAccountMenuOpenChange: setAccountMenuOpen, - onNewChat: () => { void startNewChat(); }, - onSelectSession: selectSession, - onOpenProfile: () => openAccountDialog("profile"), - onOpenGeneral: () => openAccountDialog("general"), - onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }), - onOpenLogout: () => openAccountDialog("logout"), + onAccountMenuOpenChange: stableControls.onAccountMenuOpenChange, + onNewChat: stableControls.onNewChat, + onSelectSession: stableControls.onSelectSession, + onOpenProfile: stableControls.onOpenProfile, + onOpenGeneral: stableControls.onOpenGeneral, + onOpenBilling: stableControls.onOpenBilling, + onOpenLogout: stableControls.onOpenLogout, }, }); }, [ account, accountMenuOpen, accountTrigger, activeSessionId, cancellationPending, - chartLibrary, creatingSession, hydrated, loadMoreSessions, modalOpen, modelCatalog, - openAccountDialog, pendingSessionId, profile.name, + chartLibrary, creatingSession, hydrated, modalOpen, modelCatalog, + pendingSessionId, profileName, rectificationOpeningSessionId, rectificationSurfaceOpen, - registerShellControls, renameSession, selectSession, sessionMenuId, sessions, - sessionsCursor, setAccountMenuOpen, setPendingSessionDeletion, setSessionMenuId, - shareSession, startNewChat, togglePinnedSession, visibleSessions, + registerShellControls, sessionMenuId, sessionsCursor, stableControls, visibleSessions, ]); - useEffect(() => () => registerShellControls(null), [registerShellControls]); + useEffect(() => () => { + publishedRef.current = null; + registerShellControls(null); + }, [registerShellControls]); } diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index 2e9b63c9..bd3c0330 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -1,7 +1,7 @@ "use client"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeChatSession } from "@/lib/chat-session-write-contract"; @@ -12,7 +12,7 @@ import { writeSessionUrl, } from "@/lib/chat-session-url"; import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model"; -import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog } from "@/lib/current-subject"; +import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject"; import { mergeSubjectSessionRows } from "@/lib/subject-session-merge"; import { consultationReportMarkdown } from "@/lib/consultation-report-export"; import { fallbackSessionId } from "@/lib/rectification-session-composer-guard"; @@ -181,8 +181,9 @@ export function useSessionManagement(params: SessionManagementParams) { if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id); } - const visibleSessions = sortSessions(sessions.filter((session) => isListedSidebarSession(session) - && sessionMatchesSubject(session, readCurrentSubjectId()))); + const subjectId = useCurrentSubject(); + const visibleSessions = useMemo(() => sortSessions(sessions.filter((session) => isListedSidebarSession(session) + && sessionMatchesSubject(session, subjectId))), [sessions, subjectId]); function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) { setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session))); diff --git a/frontend/tests/home-sidebar-rerender.test.tsx b/frontend/tests/home-sidebar-rerender.test.tsx new file mode 100644 index 00000000..2b8c6284 --- /dev/null +++ b/frontend/tests/home-sidebar-rerender.test.tsx @@ -0,0 +1,304 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { Profiler, useCallback, useEffect, useState, type ProfilerOnRenderCallback, type ReactNode } from "react"; + +import { AppSidebar } from "../src/components/app-sidebar.tsx"; +import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx"; +import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts"; +import { resetSubjectCatalogForTests } from "../src/lib/current-subject.ts"; +import { emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts"; +import { + SessionListProvider, + sessionListSidebarModel, + useSessionList, + useShellRegistration, + type ShellRegistration, +} from "../src/lib/session-list-context.tsx"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +const account: Account = { + user: { id: "11111111-1111-4111-8111-111111111111", email: null }, + profile: { ...emptyProfile, name: "Synthetic" }, + avatar: null, credits: 10, isAdmin: false, adminUrl: null, + rectificationPriceCredits: 0, activeSubscription: null, + hasConfirmedBirthTime: false, hasUsableBirthTime: false, +}; +const trigger = { current: null }; +const charts: never[] = []; + +function syntheticSession(index: number, title = `Synthetic session ${index}`): ChatSession { + return { + id: `session-${index}`, + title, + theme: "general", + modelId: "test", + messages: [], + createdAt: 1, + updatedAt: 1_700_000_000_000 + index, + sessionType: "consultation", + rectificationCaseId: null, + chartProfileId: null, + chartProfileName: null, + chartProfileRole: null, + pinned: false, + archivedAt: null, + messagesHydrated: false, + }; +} + +function registrationData(registration: ShellRegistration | null) { + if (!registration) return null; + return { + activeSessionId: registration.activeSessionId, + openingSessionId: registration.openingSessionId, + escapeBlocked: registration.escapeBlocked, + insetClassName: registration.insetClassName, + insetInert: registration.insetInert, + account: registration.sidebarAccount ?? null, + sessions: registration.sidebarSessions ?? [], + newChatDisabled: registration.controls.newChatDisabled, + creatingSession: registration.controls.creatingSession, + accountMenuOpen: registration.controls.accountMenuOpen, + hasMore: registration.controls.sessionControls.hasMore ?? false, + menuSessionId: registration.controls.sessionControls.menuSessionId, + disabled: registration.controls.sessionControls.disabled, + }; +} + +function payloadSha(data: ReturnType) { + return createHash("sha256").update(JSON.stringify(data)).digest("hex"); +} + +async function runProbe(sessionCount: number) { + const harness = createClientLifecycleHarness(); + resetSubjectCatalogForTests(); + const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + const storageValues = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => storageValues.get(key) ?? null, + setItem: (key: string, value: string) => { storageValues.set(key, String(value)); }, + removeItem: (key: string) => { storageValues.delete(key); }, + clear: () => { storageValues.clear(); }, + key: (index: number) => Array.from(storageValues.keys())[index] ?? null, + get length() { return storageValues.size; }, + } satisfies Storage, + }); + const originalFetch = globalThis.fetch; + const originalIntersection = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver"); + class FakeIntersectionObserver { + observe() {} + disconnect() {} + unobserve() {} + } + Object.defineProperty(globalThis, "IntersectionObserver", { + configurable: true, + writable: true, + value: FakeIntersectionObserver, + }); + globalThis.fetch = (async (input: unknown) => { + const url = String(input); + const payload = url === "/api/account" ? account + : url === "/api/chart-profiles" ? { profiles: [] } + : { sessions: [], nextCursor: null }; + return new Response(JSON.stringify(payload), { status: 200 }); + }) as typeof fetch; + + const metrics = { registers: 0, commits: 0, actualDuration: 0, recording: false }; + const selectRevisions: number[] = []; + const renameRevisions: number[] = []; + const driver: { + setFrame: (frame: number) => void; + setActive: (id: string) => void; + retitle: () => void; + switchPerson: () => void; + setRevision: (revision: number) => void; + } = { + setFrame: () => {}, + setActive: () => {}, + retitle: () => {}, + switchPerson: () => {}, + setRevision: () => {}, + }; + let latestShell: ShellRegistration | null = null; + const currentShell = (): ShellRegistration | null => latestShell; + const initialSessions = Array.from({ length: sessionCount }, (_, index) => syntheticSession(index)); + const otherPerson = [syntheticSession(sessionCount + 1, "Other person session")]; + + function HomeProbe() { + const list = useSessionList(); + const [frame, setFrame] = useState(0); + const [activeSessionId, setActive] = useState(initialSessions[0]?.id ?? null); + const [sessions, setSessions] = useState(initialSessions); + const [revision, setRevision] = useState(0); + // Production passes SessionListProvider's stable registrar. A fresh wrapper + // every render would rerun the unmount cleanup and publish twice per frame. + const registerShellControls = useCallback((registration: ShellRegistration | null) => { + if (metrics.recording) metrics.registers += 1; + list.registerShellControls(registration); + }, [list]); + useHomeShellRegistration({ + account: list.account, + accountMenuOpen: false, + accountTrigger: trigger, + activeSessionId: activeSessionId ?? undefined, + cancellationPending: false, + chartLibrary: charts, + creatingSession: false, + hydrated: list.settled, + loadMoreSessions: () => {}, + modalOpen: false, + modelCatalog: null, + openAccountDialog: () => {}, + pendingSessionId: null, + profile: { ...emptyProfile, name: "Synthetic" }, + rectificationOpeningSessionId: null, + rectificationSurfaceOpen: false, + registerShellControls, + renameSession: () => { renameRevisions.push(revision); }, + selectSession: () => { selectRevisions.push(revision); }, + sessionMenuId: null, + sessions, + sessionsCursor: null, + setAccountMenuOpen: () => {}, + setPendingSessionDeletion: () => {}, + setSessionMenuId: () => {}, + shareSession: () => {}, + startNewChat: () => {}, + togglePinnedSession: () => {}, + // New array every render. Element identity stays until a real edit. + visibleSessions: sessions.filter(() => true), + }); + void frame; + useEffect(() => { + driver.setFrame = setFrame; + driver.setActive = setActive; + driver.retitle = () => setSessions((current) => current.map((session, index) => ( + index === 0 ? { ...session, title: "Renamed synthetic" } : session + ))); + driver.switchPerson = () => setSessions(otherPerson); + driver.setRevision = setRevision; + }, []); + return null; + } + + const onSidebarRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => { + if (!metrics.recording) return; + if (phase !== "mount" && phase !== "update") return; + metrics.commits += 1; + metrics.actualDuration += actualDuration; + }; + + function Shell({ children }: { children: ReactNode }) { + const list = useSessionList(); + const registration = useShellRegistration(); + const model = sessionListSidebarModel(list, registration); + useEffect(() => { latestShell = registration; }); + return ( + + + + + {children} + + ); + } + + const restore = () => { + globalThis.fetch = originalFetch; + resetSubjectCatalogForTests(); + if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage); + else Reflect.deleteProperty(globalThis, "localStorage"); + if (originalIntersection) Object.defineProperty(globalThis, "IntersectionObserver", originalIntersection); + else Reflect.deleteProperty(globalThis, "IntersectionObserver"); + }; + + try { + await harness.render( + + + , + ); + await harness.idle(); + assert.equal(harness.errors.length, 0); + assert.ok(latestShell, "hydrated home registers once before the frame run"); + metrics.recording = true; + metrics.registers = 0; + metrics.commits = 0; + metrics.actualDuration = 0; + for (let frame = 1; frame <= 60; frame += 1) { + await harness.update(() => driver.setFrame(frame)); + } + const duringFrames = { + registers: metrics.registers, + commits: metrics.commits, + actualDuration: Number(metrics.actualDuration.toFixed(3)), + }; + metrics.registers = 0; + metrics.commits = 0; + await harness.update(() => driver.setActive("session-active")); + const afterActiveRegisters = metrics.registers; + const afterActiveCommits = metrics.commits; + const afterActive = registrationData(currentShell()); + metrics.registers = 0; + await harness.update(() => driver.retitle()); + const afterTitleRegisters = metrics.registers; + const renamedTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null; + metrics.registers = 0; + await harness.update(() => driver.setRevision(4)); + const afterRevisionRegisters = metrics.registers; + currentShell()?.controls.onSelectSession("session-0"); + currentShell()?.controls.sessionControls.onRename("session-0"); + metrics.registers = 0; + await harness.update(() => driver.switchPerson()); + const afterSwitchRegisters = metrics.registers; + const otherTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null; + const report = { + sessions: sessionCount, + duringFrames, + afterActiveRegisters, + afterActiveCommits, + afterActiveSha: payloadSha(afterActive), + afterActive, + afterTitleRegisters, + renamedTitle, + afterRevisionRegisters, + selectRevisions, + renameRevisions, + afterSwitchRegisters, + otherTitle, + }; + console.log(`SIDEBAR_AB ${JSON.stringify(report)}`); + assert.equal(duringFrames.registers, 0); + assert.equal(duringFrames.commits, 0); + assert.equal(afterActiveRegisters, 1); + assert.equal(afterActive?.activeSessionId, "session-active"); + assert.equal(afterTitleRegisters, 1); + assert.equal(renamedTitle, "Renamed synthetic"); + assert.equal(afterRevisionRegisters, 0); + assert.deepEqual(selectRevisions, [4]); + assert.deepEqual(renameRevisions, [4]); + assert.equal(afterSwitchRegisters, 1); + assert.equal(otherTitle, "Other person session"); + return report; + } finally { + await harness.close(); + restore(); + } +} + +for (const sessionCount of [30, 100]) { + test(`streaming frames do not republish the sidebar (${sessionCount} sessions)`, async () => { + await runProbe(sessionCount); + }); +} diff --git a/frontend/tests/session-list-lifecycle.test.tsx b/frontend/tests/session-list-lifecycle.test.tsx index 1b797c37..41f14f45 100644 --- a/frontend/tests/session-list-lifecycle.test.tsx +++ b/frontend/tests/session-list-lifecycle.test.tsx @@ -182,9 +182,16 @@ test("shell receives current callbacks, account and session state without freezi assert.equal(app.shell?.activeSessionId, "revision-1"); assert.equal(app.shell?.insetInert, true); assert.equal(app.shell?.escapeBlocked, true); - assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback); + // 原值:assert.notEqual(onOpenProfile, initialCallback),每次注册都是新函数。 + // 新值:包装函数身份不变;再改一版后仍是同一个函数,调用记到的是当前 revision。 + // 原因:外壳拿稳定包装,点下去执行 Home 最新一次渲染的回调(BUG-971 / BUG-1329)。 + assert.equal(app.shell?.controls.onOpenProfile, initialCallback); app.shell?.controls.onOpenProfile(); assert.deepEqual(app.callbackRevisions, [1]); + await app.revise(2); + assert.equal(app.shell?.controls.onOpenProfile, initialCallback); + app.shell?.controls.onOpenProfile(); + assert.deepEqual(app.callbackRevisions, [1, 2]); await app.harness.update(() => { app.list.setAccount({ ...account, credits: 27 }); app.list.setSessions([row]);