进度记录含三簇 20 个状态的分类表(每个写明谁在读)、改前改后实测、 既有断言改动的三栏说明、反向验证、lint warning 逐条比对、以及 session 簇留 4 个在外壳的让步说明。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
20 KiB
PROGRESS · 状态下沉第二批:synastry / profile / session 三簇 + 稳住外壳 setter(2026-09-16)
工作树:.worktrees/home-state-lowering-batch2-20260916
分支:codex/home-state-lowering-batch2-20260916
任务书基线写 4f643aa0;开工时 origin/staging = 37e6c519(docs(tasks): 校正状态板 + 状态下沉第二批 + 后门单 + 对账清单)。
本机 Linux,有 Node 与 npm,无 Docker、无登录态、无 Chrome。本单不占 BUG 号,不改 CHANGELOG.md / docs/BUG_HISTORY.md / docs/tasks/README.md。未 git push origin HEAD:staging。未改数据库、API 路由、frontend/DESIGN.md(无可见样式变化)、frontend/docs/VOICE.md(无文案变化)。
开工实测(不要抄任务书 52/39/1931)
数法与合同测试相同:Home() 切片上 \buseState[<(] / \buseRef[<(]。
| 指标 | 实测 |
|---|---|
Home() useState |
52 |
Home() useRef |
39 |
page.tsx 行数 |
1931 |
npm run lint |
0 error / 120 warning |
useSessionManagement 参数(type 字段) |
41(任务书约 40) |
useProfileOnboarding 参数(type 字段) |
38 |
全量 npx tsx --test tests/*.test.ts tests/*.test.tsx |
3329 tests / 3283 pass / 31 fail / 15 skipped |
31 条失败全是环境缺口(无 Docker、无本地 PostgreSQL、无外网),清单见文末,与收尾实测逐条相同。
分类表(20 个,一个不漏)
「谁在读」按改前的 page.tsx 实测,不是记忆。
synastry 簇(4 个,全部搬下去)
| 状态 | 类别 | 谁在读 | 处置 |
|---|---|---|---|
synastryRelationshipType |
只服务子树 | 只有 ChartLibraryPanel(关系类型下拉) |
搬进新建 useSynastry |
synastryPendingId |
只服务子树 | ChartLibraryPanel(按钮 disabled / 「正在计算合盘...」)、draftSynastryQuestionFromChart 自己的重入闸 |
搬进 useSynastry |
synastryReportCard |
只服务子树 | ChartLibraryPanel(本次合盘卡) |
搬进 useSynastry |
synastryHistory |
只服务子树,但外壳的星盘库 effect 要写 | 值只给 ChartLibraryPanel;写在 Home 的星盘库 effect 里(切账号清空、云端历史落库)与合盘成功后 |
状态搬进 useSynastry;外壳只拿到两个 useCallback 稳定句柄 clearSynastryHistory / applyCloudSynastryHistory,并列进该 effect 的 deps(身份恒定,不多跑一次) |
draftSynastryQuestionFromChart 整个搬进 hook;Home 里 const [synastry 出现次数 = 0。六个子树字段打成一个 synastryPanel,JSX 里 {...synastryPanel} 展开。
profile 簇(6 个,全部搬下去)
| 状态 | 类别 | 谁在读 | 处置 |
|---|---|---|---|
profile |
外壳也要读 | Home:profileComplete、birthTimeRoute、dailyStarlanguage*、rectificationDeclaredTime、侧栏头像首字母、ProfilePanel / ChartLibraryPanel;useSessionManagement、useRectificationSurface、useSynastry、useConsultationRun 的入参 |
状态搬进 useProfileOnboarding,Home 从它的 return 读回;hook 调用上移到 useBirthTimeGuidedJourney 之前,保证第一个读者之前已定义 |
profileDraft |
外壳也要读 | Home:引导三步的题面与提交按钮、BirthTimeIntakeFields / BirthLocationFields、ChartLibraryPanel |
同上 |
profileNotice |
外壳也要读(只透传) | ChartLibraryPanel;setProfileNotice 还要交给 useRectificationSurface 与 useConsultationRun |
状态在 hook;Home 只读回值与 setter |
avatarNotice |
只服务子树 | 只有 ProfilePanel |
搬进 hook,只读回值 |
avatarSaving |
只服务子树 | 只有 ProfilePanel;写只在 hook 的 persistAvatar |
搬进 hook,只读回值 |
profileSaving |
外壳也要读 | Home:引导三步按钮 disabled / 「保存中」、重新评估按钮、ChatComposer 的 inputDisabled / submitBlocked、ChartLibraryPanel |
搬进 hook,只读回值 |
Home 里散装 profile* state = 0(低于任务书 ≤1)。
session 簇(10 个,搬走 6 个,留 4 个)
| 状态 | 类别 | 谁在读 | 处置 |
|---|---|---|---|
sessionsCursor |
外壳也要读 | Home 侧栏 hasMore;写在 hook 的 loadMoreSessions 和外壳的 bootstrap effect |
搬进 useSessionManagement,值与 setter 都读回 |
showArchivedSessions |
外壳也要读 | Home:visibleSessions 过滤、侧栏 showingArchived;写在 hook 的 toggleArchivedView |
搬进 hook;visibleSessions 一并搬进 hook 计算后返回 |
sessionMenuId |
只服务子树 | 只有侧栏(menuSessionId / onMenuSessionChange / 切归档视图时清菜单) |
搬进 hook,值与 setter 读回 |
pendingSessionDeletion |
只服务子树 | 只有删除确认弹窗 | 搬进 hook,值与 setter 读回 |
sessionDetailLoadingId |
只服务 hook 自己 | Home 里没有任何读者(改前 lint 就报 assigned a value but never used);只有 hook 的 ensureSessionMessages 读写 |
整个搬进 hook,不返回 |
sessionFullPrompt |
只服务 hook 自己 | Home 里没有读者;写在 useSessionManagement 与 useConsultationRun |
状态搬进 hook,只把 setter 返回给 useConsultationRun 用 |
sessions |
外壳也要读 | Home:activeSession、bootstrap effect 首屏落库、侧栏统计与三处 sessions.find;sessionsRef 初值;useSessionManagement / useRectificationSurface / useConsultationRun 的入参与 setSessions |
留在外壳:它是三个 hook 的共同输入,且 bootstrap effect 在任何 hook 之外写它 |
activeSessionId |
外壳也要读 | Home:activeSession、visibleSessions 的「当前会话不过滤」分支、activeSessionIdRef 同步 effect、ensureSessionMessages effect、birthTimeRoute、useConsultationRun 入参、侧栏 |
留在外壳:两个 effect 的依赖项,且 useRectificationSurface 也写它 |
creatingSession |
外壳也要读 | Home:productEntrypointsDisabled、自动打开校正面的守卫、新对话按钮 disabled、StarterHome、底部按钮;useSessionManagement 与 useRectificationSurface 双方入参 |
留在外壳:两个 hook 都要读,谁持有都得回传给另一个 |
pendingSessionId |
外壳也要读 | Home:isLoading、productEntrypointsDisabled、ensureSessionMessages effect deps、两条恢复 effect、chooseSuggestedQuestion 闸、ChatComposer submitBlocked、侧栏 |
留在外壳:它其实属于「咨询运行」簇(useConsultationRun),不是会话管理簇;本单不动咨询簇 |
Home 里散装 session* state = 4(任务书要 ≤2,见下「让步说明」)。
收尾实测
| 指标 | 改前 | 改后 |
|---|---|---|
Home() useState |
52 | 36(= 任务书 ≤36 的上限;搬走 16) |
Home() useRef |
39 | 37(未上升,反而少 2) |
page.tsx 行数 |
1931 | 1846 |
npm run lint |
0 error / 120 warning | 0 error / 118 warning |
useSessionManagement 参数 |
41 | 34 |
useProfileOnboarding 参数 |
38 | 26 |
散装 synastry* / profile* / session* state |
4 / 6 / 10 | 0 / 0 / 4 |
新建 frontend/src/hooks/use-synastry.ts(141 行)。use-profile-onboarding.ts 与 use-session-management.ts 现在各自 useState。
5.1:为什么 warning 只能靠 useMemo,不能靠塞 deps
createRectificationShellSetters(setRectification) 改前在 render 体里裸调,每帧返回四个新函数身份,入口摘要 effect 因此多一条 exhaustive-deps(119 → 120)。
改法:const {...} = useMemo(() => createRectificationShellSetters(setRectification), [setRectification]);,再把 setRectificationEntrySummary / setRectificationEntrySummarySettled 补进该 effect 的 deps。setRectification 是 React 的 useState setter,身份恒定,所以四个包装函数跨帧稳定。
不是靠肉眼的证明:入口摘要 effect 的 deps 现在是 [accountId, bootstrapPhase, setRectificationEntrySummary, setRectificationEntrySummarySettled]。后两项由 useMemo(..., [setRectification]) 产出,而 setRectification 来自 useState,React 保证同一 mount 内引用不变 → useMemo 不重算 → 两个 setter 引用不变 → effect 仍然只在 accountId / bootstrapPhase 变化时触发。若有人删掉 useMemo,eslint 会立刻把这条 warning 报回来(这正是改前的状态),所以这条不变量由 lint 数字守着:warning 必须是 118,回到 119/120 就是有人把记忆化拆了。
既有断言改动
| 文件 | 原值 | 新值 | 原因 |
|---|---|---|---|
tests/home-shell-growth-contract.test.ts 三门 |
HOME_USE_STATE_CAP = 52 / HOME_USE_REF_CAP = 39 / PAGE_LINE_COUNT_BASELINE = 1931 |
36 / 37 / 1846 | 本单下沉后的新基线,三项全部往紧里收 |
tests/chat-navigation-a11y-contract.test.ts 的 signOut 切片终点 |
sourceBetween(pageSource, "async function signOut()", "return {\n refreshAccount,") |
终点改成 "return {\n profile," |
六个 profile 状态搬进 use-profile-onboarding.ts 后,该 hook 的 return 以 profile 开头,refreshAccount 不再是第一个字段。切的仍是 signOut 函数体,断言主语(退出后 window.location.assign("/login") 硬跳)一字未改 |
没有其它既有断言被改动或弱化;没有删除任何断言。新增 1 条断言(见下)。
新增断言
tests/home-shell-growth-contract.test.ts 加一条 useSessionManagement still runs before useRectificationSurface:钉住 rectificationSessionOpenerRef 先声明、useSessionManagement 调用在 useRectificationSurface 之前、并且 opener 是通过 openRectificationSession: (exactSessionId) => rectificationSessionOpenerRef.current(exactSessionId) 递过去的。这是第一批留下的调用顺序约束,本单挪动了 hook 调用位置,所以补钉。
反向验证(增长冻结真的会变红)
在 Home() 里插入 const [_growthContractProbe, _setGrowthContractProbe] = useState(false); 后:
not ok 1 - Home() useState count must not grow
error: 'Home() has 37 useState calls; cap is 36. Extracted hooks and child components should own their own state.'
# tests 4 / pass 3 / fail 1
useRef、行数粗护栏、调用顺序三条仍绿。随后删掉探针,文件回到 36/37/1846,四条全绿。
lint warning 逐条比对(不只是比数字)
按「规则 + 归一化信息」排序后 diff,120 → 118,没有任何新增 warning,三处差异:
- 少一条(本单目标):
React Hook useEffect has missing dependencies: 'setRectificationEntrySummary' and 'setRectificationEntrySummarySettled'—— 5.1 消掉的就是它。 - 少一条(本单副产物):
'_result' is defined but never used—— 来自被删掉的guidedBirthTimeReadyRef = useRef((_result: JourneyClientResponse) => {}),见下节。 - 有一条既有 warning 的信息变长(规则、文件、effect 位置都没变):
- 原文:
React Hook useEffect has a missing dependency: 'restoreConsultationRecovery'. - 现文:
React Hook useEffect has missing dependencies: 'restoreConsultationRecovery', 'setProfile', 'setProfileDraft', and 'setSessionsCursor'. - 原因:那是 mount-once 的 bootstrap effect,它写
setProfile/setProfileDraft/setSessionsCursor,改前这三个是Home自己的useStatesetter(eslint 认得,不要求进 deps),改后来自 hook 的 return(eslint 认不出是 setter)。 - 为什么不把它们补进 deps:试过,
}, [setProfile, setProfileDraft]);会打红两条既有断言——tests/consultation-recovery.test.ts用source.indexOf("}, []);")当切片路标,tests/chat-navigation-a11y-contract.test.ts直接断言\n \}, \[\]\);。这两条断言的主语是「bootstrap 是 mount-once」,为了一条 warning 的措辞去改它们不划算,所以选择不动业务代码、不动既有断言,只如实记录这处措辞漂移。三个都是useStatesetter 原样透传,身份恒定,effect 仍然只跑一次。
- 原文:
为什么删掉了两个 ref(useRef 39 → 37)
把 useProfileOnboarding 改成持有状态后,React 编译器的 lint 规则才开始检查这个文件(改前它一个 hook 都不调),一次性冒出 5 条 error(§7.1 要求 0 error):
| error | 位置 | 处置 |
|---|---|---|
react-hooks/immutability ×3 |
dialogReturnTarget.current = ...、birthTimeRevisionPending.current = ... ×2 |
按规则自带的 hint 把两个 ref 入参改名为 dialogReturnTargetRef / birthTimeRevisionPendingRef(纯改名) |
react-hooks/refs ×2 |
render 期写 guidedBirthTimeReadyRef.current = completeGuidedBirthTime / editDeclaredBirthTimeDetailsRef.current = editDeclaredBirthTimeDetails |
删掉这两个 ref |
这两个 ref 当初存在,只是因为 useProfileOnboarding 排在 useBirthTimeGuidedJourney 之后、函数还没定义就要交给它。本单把 profile hook 上移之后,completeGuidedBirthTime / editDeclaredBirthTimeDetails 可以直接传:
onReady: (result) => completeGuidedBirthTime(result),
onEditBirthTimeDetails: () => editDeclaredBirthTimeDetails(),
零行为变化的依据:useBirthTimeGuidedJourney 只在 render 作用域的闭包里用这两个回调(acknowledgeReady 里 onReady(journey),editBirthTimeDetails: onEditBirthTimeDetails 直接原样返回),没有把它们塞进 useEffect/useRef 里缓存。改前每帧读 .current、改后每帧拿当帧的函数,调用到的都是同一个「最新一帧的实现」。(已核对 src/hooks/use-birth-time-guided-journey.ts 全文,onReady / onEditBirthTimeDetails 只出现在第 183、255 行。)
useRef 数从 39 降到 37,没有把任何 state 改写成 ref。
让步说明(任务书 §6)
- 5.1、5.2、5.3 全做,无让步。
- 5.4 部分让步:任务书要求
Home()里散装session*state ≤ 2,实际留了 4(sessions/activeSessionId/creatingSession/pendingSessionId),理由在分类表里逐个写明——前三个是三个 hook 的共同输入或 bootstrap effect 在 hook 之外的写入点,第四个属于咨询运行簇而不是会话管理簇。把它们合成一个外壳对象是可行的,但要同时改useConsultationRun、useRectificationSurface、bootstrap effect 三处写入口,属于「咨询簇」的活,不是本单的零行为变化预算,留给下一批。 - 5.5 的收口指标没有放松:
useState36 正好等于任务书上限 ≤36,useRef37 低于上限 39,行数基线 1846 低于 1931,三条冻结门全部往紧里收。 - 5.5、5.6 未砍。
验收命令与结果
| 命令 | 结果 |
|---|---|
./node_modules/.bin/tsc --noEmit |
0 错 |
npm run lint |
0 error / 118 warning(基线 120;逐条比对见上节) |
npx tsx --test tests/home-shell-growth-contract.test.ts tests/rectification-*.test.ts tests/chat-session-*.test.ts tests/session-*.test.ts |
见下 |
npx tsx --test tests/*.test.ts tests/*.test.tsx |
见下 |
npm run build |
见下 |
(定向、全量、build 的实测数字见文件末尾「收尾复核」一节。)
真人走查
docs/testing/home-state-lowering-batch2-20260916.md:合盘三条路径、资料引导与头像、会话列表七件事。本仓无登录态、无 Chrome,未测。
发现的交互缺陷(本单不修)
无新增。sessionDetailLoadingId 与 sessionFullPrompt 这两条 state 在 Home() 里从来没有读者(改前 lint 已经在报 assigned a value but never used,改后同样两条 warning 原封不动出现在 use-session-management.ts)。sessionFullPrompt 由 useConsultationRun 写入却无人渲染,像是没接完的功能;本单零行为变化,只记录不动。
未做(范围外)
- 其余分散状态(
account*、birthTime*、consultation*、streaming*等) - 咨询运行簇(
pendingSessionId/pendingRequestId/streamingReply/replyOutcome…) - Context Provider 或外部 store
- 推
origin HEAD:staging - 改
docs/tasks/README.md状态列
收尾复核(最终工作树,四条验收命令的实测)
| 命令 | 实测 |
|---|---|
./node_modules/.bin/tsc --noEmit |
0 错 |
npm run lint |
0 error / 118 warning(基线 120) |
npx tsx --test tests/home-shell-growth-contract.test.ts tests/rectification-*.test.ts tests/chat-session-*.test.ts tests/session-*.test.ts |
1369 tests / 1360 pass / 0 fail / 9 skipped |
npx tsx --test tests/*.test.ts tests/*.test.tsx |
3330 tests / 3284 pass / 31 fail / 15 skipped |
npm run build |
退出码 0,/ 仍是 ○ Static |
测试总数 3329 → 3330(只增不减,多的那条是新增的调用顺序断言)。全量失败清单与开工基线 31 条逐条相同(diff 空)。
全量失败清单(与开工基线逐条相同,全是环境缺口)
无 Docker、无本地 PostgreSQL、无外网,因此 database / staging-sync / backup / 远端 oracle 这几类整组红。
Better Auth supports shared user OTP/password sessions for admins;Owner recovery grants only one currently loginable synced identity admin;admin code functions reject immutable codes, revoked redemption, and roll back on audit failure;admin customer reset clears only rebuildable application state;billing order adjustments and redemption reasons are atomic and audited;billing, subscriptions, usage authorization, RBAC, and model publication remain transactional;cached appendix returns markdown without model or engine calls;database drops secret refs, generates immutable codes, and invalidates evidence after key rotation;database env validator rejects Compose-compatible duplicate selectors without printing values;database roles have no cluster privileges;ephemeris natal and transit chart requests skip VedAstro overview;ephemeris_events failure degrades instead of failing the page;foundation grants no direct runtime table DML and exposes only reviewed functions;live staging sync preserves env, state, incoming files, and encrypted backups;live staging sync repairs nested deploy-tree drift without preserving foreign ownership;local PostgreSQL applies the reviewed business schema and serves authenticated business calls;migration runner is serialized, atomic, drift-safe, and read-only in check mode;missing appendix is a retired report, not an on-demand generation;panchanga still returns without a birth profile, and natal-relative stays empty;personal report job migration is atomic, lease-bound, recoverable, and owner read-only;personal report sections enforce owner-read RLS and service-owned durable transitions;rectification agent maps setup failures without calling the rest of the handler;rectification case refresh maps setup failures without calling the rest of the handler;redeem security: case-sensitive hashing, rate limiting, idempotency and order ownership;saving a published product forks a draft and delete retires or removes it;self-hosted identity migration creates Better Auth tables with least privilege;server compose defaults to local images without removing either build;service and restricted admin database identities stay separated;staging backups are encrypted, atomic, private, and retain the newest three;staging env validator rejects selector drift, duplicates, and unsafe permissions;unauthenticated ephemeris requests are 401。
不是本单回归。
next build 与首屏 gzip
Linux 上 Turbopack 构建正常,两次都退出 0:
- 改后:路由表里
┌ ○ /,即/仍是 Static。 - 基线对照:把
frontend/src与frontend/tests临时git checkout origin/staging --回去重建了一次,路由表同样是┌ ○ /,随后git checkout HEAD --还原。
首屏 gzip 量法(与第一批同一种):读 .next/server/app/index.html,取它引用的全部 /_next/static/**.js,逐个 gzip -9 后求和。
| 文件数 | gzip 合计 | |
|---|---|---|
基线 origin/staging @ 37e6c519 |
20 | 585,817 B |
| 本单 | 20 | 585,868 B |
变化 +0.009 %,远在 ±2 % 内(+51 B)。