回答生成时侧栏不再跟着每一帧重画

外壳只在会话、当前项或账户这些数据变了才更新。60 帧计数从注册 60 次、提交 60 次降到 0 次,点下去仍是最新一次操作。BUG-1329。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-14 04:12:02 +08:00
co-authored by Grok (agent loop)
parent 66cd60081f
commit 6350943152
12 changed files with 588 additions and 40 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-10-14 — 回答生成时侧栏不再跟着闪动 / 卡顿(待合入 staging)
- 回答生成时侧栏不再跟着闪动 / 卡顿。点会话、改名、换人仍是当前这一下的操作。Skill 版本不变(BUG-1329)。
## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging)
- 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。
+15
View File
@@ -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。
@@ -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 快速门未跑,留给验收。
+1 -1
View File
@@ -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` |
@@ -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/待规划`。不要贴出真实会话内容。
@@ -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 未跑。
+1
View File
@@ -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
+14 -4
View File
@@ -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<SidebarProviderContextValue>(() => ({
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 (
<SidebarContext.Provider value={context}>
+152 -30
View File
@@ -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<void>;
selectSession: (sessionId: string) => void;
shareSession: (session: ChatSession) => void | Promise<void>;
startNewChat: () => void | Promise<unknown>;
togglePinnedSession: (sessionId: string) => void;
openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void;
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
};
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<HTMLButtonElement | null>;
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<LatestShellActions | null>(null);
const publishedRef = useRef<PublishedShellInputs | null>(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]);
}
+5 -4
View File
@@ -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)));
@@ -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<typeof registrationData>) {
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<string, string>();
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 (
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
<Profiler id="sidebar" onRender={onSidebarRender}>
<AppSidebar
sessions={model.sessions}
activeSessionId={registration?.activeSessionId ?? null}
openingSessionId={registration?.openingSessionId ?? null}
account={model.account}
settled={list.settled}
signedOut={list.signedOut}
controls={registration?.controls}
/>
</Profiler>
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
</SidebarProvider>
);
}
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(
<SessionListProvider>
<Shell><HomeProbe /></Shell>
</SessionListProvider>,
);
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);
});
}
@@ -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]);