Coming back to / from /chart, /ephemeris, /reports or /people by client navigation remounted Home from hydrated=false and replayed the whole bootstrap: model catalog, consult status, entry summary and daily card, 3-5 round trips plus up to 4 s of reveal budget, every time. The reveal now counts per document load, not per mount: - lib/home-warm-snapshot.ts: memory-only, per-account snapshot of the model catalog, rectification entry summary, session cursor and today's card (keyed by person + birth fingerprint + day). Account, profile and the session list already survive in SessionListProvider. Cleared on account change, sign-out, provider 401 and redirectToLogin. - lib/home-warm-start.ts: Home's useState initializers take one warm decision per mount. Complete snapshot + settled list + complete profile + a landing computable in memory -> start ready, landing resolved with the cold-path functions (?new=1, ?c=, login-return stash and its person scope from BUG-1038). Anything missing or needing a lookup -> the unchanged cold path (no half-reveal, BUG-1021). - runHomeWarmRefresh commits the landing, then refreshes catalog, account and background-consultation recovery in the background (recovery shares resolveReservedConsultation with the cold path). Summary and daily card refresh through their existing effects. - Next renders the new page before it writes the address bar, so AppLink / navigateAppPath note the target href (lib/client-navigation-target.ts); unknown target -> cold path. Home() useState 33 / useRef 37 unchanged; page.tsx 1329 -> 1373 lines. Tests: home-warm-return-lifecycle (13, real Home + sidebar + provider, navigating in Next's render-then-write-URL order; 11 fail with warm start disabled) and home-warm-snapshot (12). Full suite 3953 / 61 failing, failure names identical to the 3928 / 61 baseline. Build: / stays Static, rootMainFiles gzip 130933 B unchanged. Local Chrome with every /api/* held 1.5 s: /chart -> 新建对话 interactive in 18-23 ms with no loading ring (baseline 5530 ms with ring). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
16 KiB
PROGRESS · 从其他页面回首页不再放加载动画(2026-09-26)
任务书:docs/tasks/TASK-home-warm-return-20260926.md。分支 codex/home-warm-return-20260926,worktree .worktrees/home-warm-return-20260926。执行方:Claude 子代理(产品授权直接执行)。未推送、未部署。BUG-1040(开工时最大 BUG-1039,1040 空)。
结论
| 项 | 修复前(origin/staging 6a22626d) |
修复后 |
|---|---|---|
首页 → 星盘 → 新建对话,所有 /api/* 人为延迟 1.5 s(本地 Chrome) |
出加载环,5530 ms 才可交互;可交互前等完 5 个请求 | 无加载环,23 ms 可交互(复跑 18 ms);可交互前 0 个请求返回 |
| 首页 → 我的报告 → 点历史会话(同上) | 出加载环,5532 ms | 无加载环,19 ms(复跑 18 ms) |
整页刷新 / |
22 ms 出加载环,约 4.1 s 进入 | 22–24 ms 出加载环,约 4.1 s 进入(不变) |
首次打开 / |
64 ms 出加载环,约 4.4 s | 32–63 ms 出加载环,约 4.1–4.4 s(不变) |
冷启动约 4.1 s 是本地虚构数据下的 prepare 揭幕预算,基线与本分支一致,不属本单。
基线
origin/staging=6a22626d(BUG-10380a8350cc已合入,开工前git log核对)。实现期间 staging 前进到bd669aed(只改 BUG_HISTORY 与任务索引两行文档),本分支已 rebase 到bd669aed,docs/tasks/README.md冲突一处:保留 staging 的 BUG-1038 行、本单行改「待验收」。- 全量基线(同一台机器、Node v20.19.2、Linux):协调方给的
test9.log,3928 条,61 失败(环境缺口:Node 20 没有mock.module),27 跳过。 - 首屏 gzip(
.next/build-manifest.jsonrootMainFiles,gzip -9):130933 B。/的 index.html 引用的全部 js/css:654283 B(同代码的new-chat-from-people工作树构建)。 npm run lint:0 error / 126 warning(同代码工作树实测)。page.tsx1329 行;Home()useState 33、useRef 37(合同上限 36 / 37,行数上限 1567)。
设计
快照里放什么(T1,D2)
frontend/src/lib/home-warm-snapshot.ts:模块变量,一次一个账户,纯内存。
| 字段 | 来源 | 说明 |
|---|---|---|
accountId |
冷启动成功后的账户 | 键;读到别的账户立即清空 |
modelCatalog |
冷启动 / 后台刷新 | 缺或为空 → 不算齐全 |
entrySummary + entrySummarySettled |
校正入口摘要 effect | 未 settled → 不算齐全(揭幕预算先到时,等摘要落定才写) |
sessionsCursor + listBoot |
useSessionManagement 的分页游标,与写入时 provider 的 boot 对象身份 |
provider 在外重载过(例如换人)则用 provider 自己的游标 |
今日星语 {accountId, subjectId, fingerprint, day, card} |
DailyStarlanguageBinder 拿到 ready 时 |
人物、出生资料指纹、日期三者全等才用;否则 pending(卡片静态句),binder 按新键重取 |
与 D2 的偏离(有意):account、profile、startGreeting、onboardingStep 不复制进快照。账户本来就在布局层 SessionListProvider 的状态里跨客户端导航存活(那就是这份内存快照),而且比快照新(provider 换人重载会重读 /api/account,其它页面也不会改它);资料按冷启动同样的方式 readProfile(account.profile) 派生,问候语与引导步骤再由资料派生。复制一份只会多一个过期来源。齐全判定照样要求账户存在、同账户、资料完整。
写入:Home() 里一个只写快照的 effect,条件 hydrated && !accountError && accountId && modelCatalog && entrySummarySettled(不写半成品,BUG-1021)。只增 effect、不增 state / ref。
失效:SessionListProvider 的 /api/account 401、/api/sessions 401、换账户分支;redirectToLogin()(所有 401 跳登录的出口);signOut();readHomeWarmSnapshot(别的账户)。
暖返回(T2,D3)
frontend/src/lib/home-warm-start.ts,无 React hook:
Home()的useState(() => …)初始化函数(activeSessionId、modelCatalog、dailyStarlanguage、rectification、hydrated、bootstrapPhase、onboardingStep、startGreeting,以及useProfileOnboarding新增的initialProfile)都调takeHomeWarmStart()。同一次渲染(含 StrictMode 双调)共用一个结果,微任务后失效,下次挂载重新判断。- 条件全部满足才是暖启动,否则返回 null,走原封不动的冷启动:provider 已 settled、未登出、有账户、boot 无错;同账户快照齐全;资料完整(引导中的用户走冷启动);非开发
?preview=;落点能在内存里算出;知道要打开的/的 query。 - 落点
resolveWarmLanding()用冷启动同一组函数:resolveBootstrapSessionSelection→resolveStarterHomeLandingSessionId/starterHomeLandingNeedsConsultation。?new=1建本地空咨询(替换旧的未保存空对话,BUG-989 不落库);?c=在内存 →keep;存根在内存且属当前人物 →replace-selected,属别人 →other-subject(BUG-1038);需要 lookup(?c=或存根不在内存)→ null → 冷启动(BUG-705 的查找只有冷路径做)。 - 首帧:
hydrated=true、bootstrapPhase="prepare"、目录 / 资料 / 摘要 / 星语就位;新建的本地空咨询在进列表前由pendingWarmLandingSession()充当活动会话(ensureSessionMessages同样认它,不去服务端拉)。 - 启动 effect:挂载时
hydrated为真即暖启动,调runHomeWarmRefresh()(home-bootstrap-run.ts):同步提交落点(列表、游标、写 / 清 URL、清存根、缺失提示;存根校正会话在写回?c=后补一次自动打开,因为自动打开 effect 已在 URL 写回之前跑过),然后后台并行:/api/models(变了才替换,下线模型沿用原提示并 PATCH,未保存的本地空对话不 PATCH)、refreshAccount()、后台回答恢复(与冷启动共用抽出的resolveReservedConsultation;本标签页的 pending 记录在挂载时读取,因为首页自己的 pending effect 第一轮就会清掉它;新建落点不被恢复抢走)。入口摘要与今日星语走各自原有 effect(bootstrapPhase !== "account"即触发)。全部不 await 在可交互之前,失败保持快照值,不回加载态。
客户端导航读不到目标 URL(浏览器实测才发现)
第一版在真实浏览器里「新建对话」落到了旧会话、地址停在 /?new=1。原因:Next 16 的 app router 先渲染新页面,之后才在 HistoryUpdater 的 useInsertionEffect 里 pushState(node_modules/next/dist/client/components/app-router.js)。所以 Home 挂载渲染时 window.location 还是 /chart,初始化函数读不到 ?new=1。冷启动没这个问题,因为它在 await 之后才读 URL。
修法:新增 frontend/src/lib/client-navigation-target.ts(无依赖,随共享侧栏加载)。AppLink 在普通左键点击、未被拦截时记下目标 href,navigateAppPath 在客户端跳转前同样记下。首页挂载时:地址栏已是 /(刷新、浏览器前进后退)就读地址栏;否则读 10 秒内的那一次记录;都没有 → 冷启动(安全回落,不猜)。侧栏的新建对话、会话行、账户页脚都是 AppLink。
lifecycle 测试 harness 也改成 Next 的顺序(先渲染,URL 在 insertion effect 里写),原先「先 pushState 再渲染」的写法测不出这个问题。去掉记录时新测试里新建对话与历史会话两条失败。
改了什么
| 文件 | 内容 |
|---|---|
frontend/src/lib/home-warm-snapshot.ts(新) |
快照存取、按账户清空、齐全判定、今日星语按人物 + 指纹 + 日期存取 |
frontend/src/lib/home-warm-start.ts(新) |
暖启动判断、落点、挂载 query、模型对账、StrictMode 幂等提交 |
frontend/src/lib/client-navigation-target.ts(新) |
客户端导航目标记录 |
frontend/src/app/(app)/page.tsx |
8 个 useState 改初始化函数;activeSession 多一个首帧回退;启动 effect 分暖 / 冷;一个写快照 effect。1329 → 1373 行;useState 33 / useRef 37 不变 |
frontend/src/lib/home-bootstrap-run.ts |
抽出 resolveReservedConsultation(冷启动行为不变);新增 runHomeWarmRefresh |
frontend/src/components/daily-starlanguage-binder.tsx |
先读内存卡再读 localStorage;ready 时记进内存;原有 guard 与依赖数组不动 |
frontend/src/hooks/use-profile-onboarding.ts |
initialProfile 参数;signOut 先清快照 |
frontend/src/hooks/use-session-management.ts |
ensureSessionMessages 认首帧的活动会话 |
frontend/src/lib/session-list-context.tsx、home-cloud-sync.ts |
401 / 换账户 / redirectToLogin 清快照 |
frontend/src/components/app-link.tsx、frontend/src/lib/app-navigation.ts |
记录导航目标 |
frontend/tests/home-warm-return-lifecycle.test.tsx(新,13 条) |
见下 |
frontend/tests/home-warm-snapshot.test.ts(新,12 条) |
见下 |
frontend/tests/new-chat-from-people-lifecycle.test.tsx |
只在 openDocument 里加两行:文档加载时清快照(模块内存本来就随文档重置)。无断言改动 |
| 文档 | docs/BUG_HISTORY.md(BUG-1040)、frontend/DESIGN.md §9 与 Secondary page shell、CHANGELOG.md、BLOCKED.md、docs/tasks/README.md、docs/testing/home-warm-return-20260926.md 与两张截图 |
测试
新增
home-warm-return-lifecycle.test.tsx(真实 Home + AppSidebar + SessionListProvider,与 (app)/layout.tsx 同构;用兄弟组件的 layout effect 记录每次挂载的首个提交帧):
| # | 用例 | 关掉暖启动时 |
|---|---|---|
| 1–4 | /chart /ephemeris /reports /people → 新建对话:导航前把所有接口设为永不返回;首帧无加载环、标题「新对话」、首帧提交时请求数 = 导航前、starter 首页、地址 /、存根清掉、校正卡用快照摘要;之后 /api/models 与 /api/consult/status 在后台发出,挂起也不回加载态 |
失败 |
| 5 | /reports → 历史会话:首帧即该会话、?c= 保留、消息出现 |
失败 |
| 6 | 暖返回后整页刷新:首帧是加载环 | 失败(前半段暖返回断言) |
| 7 | 快照被清空再回首页:首帧加载环,目录挂起时一直等(BUG-1021) | 通过(本来就冷) |
| 8 | 后台目录换成别的模型:首帧不等;随后模型切到新默认、出现原提示「此前选择的模型已下线,已切换为默认模型。」、PATCH 该会话 | 失败 |
| 9 | 离开时回答还在生成(pending 记录 + reserved)→ 点该会话回来:出现停止按钮、pending 记录保留 | 失败 |
| 10 | 同上但点新建对话:停在新对话,旧回答继续恢复 | 失败 |
| 11 | 摘要变了:校正卡从「再次校正」静默换成「开始新的生时校正」 | 失败 |
| 12 | 存根校正会话经账户页脚回裸 /:写回 ?c=、校正界面打开(不是 BUG-1038 的锁死页) |
失败 |
| 13 | provider 读到另一个账户:快照清空 | 通过 |
home-warm-snapshot.test.ts(12 条):写入 / 读取;换账户清空(含今日星语);redirectToLogin 清空;退出与 provider 三个分支清空(源码合同);快照源码不含任何持久存储;不齐全判定;今日星语跨日 / 换人 / 换账户不命中、无出生分钟无键;齐全时暖启动(新建本地空对话、默认模型、游标取舍);每种缺项都拒绝暖启动;落点与冷规则一致(keep / 存根写回 / 他人存根丢弃 / 裸 / 校正头落咨询 / 新建清存根 / 无会话的人只建一个 / 存根不在内存要 lookup);落点替换旧空对话、模型对账不改身份;挂载 query 的三种来源与记录只取一次、过期不信、只收同源路径。
全量
| 项 | 基线 | 本分支 |
|---|---|---|
tsc --noEmit |
0 | 0 |
npm run lint |
0 error / 126 warning | 0 error / 126 warning |
npm test(Node 20,Linux) |
3928 / pass 3840 / fail 61 / skip 27 | 3953 / pass 3865 / fail 61 / skip 27 |
| 失败名单 diff | — | 逐条一致(61 = 61,0 新增) |
| 测试名 diff | — | 0 消失,+25(13 + 12) |
Python 源码合同(tests/test_birth_time_journey_contract.py 等 5 个文件) |
25 pass / 2 fail | 25 pass / 2 fail(同两条:test_jyotish_web_auth.py 仍查 Supabase 登录源码,基线即失败) |
过程中一次全量多出 1 条失败:chat-navigation-a11y-contract.test.ts「auth redirects stay hard document loads…」要求 await selfHostedOtpActions.signOut(); 与 window.location.assign("/login") 紧邻。改为把清快照挪到 signOut() 调用之前,断言未动。
改动的既有断言(三栏)
无。唯一触及的既有测试文件 new-chat-from-people-lifecycle.test.tsx 只在文档加载的 setup 里加了清快照两行,不改任何断言。
构建
npm run build -- --webpack(Linux):
| 项 | 基线 | 本分支 |
|---|---|---|
/ |
○ Static | ○ Static |
/chart /ephemeris /people |
○ | ○ |
/reports |
ƒ | ƒ |
| 路由表整体 | — | 与基线构建逐行一致 |
rootMainFiles gzip -9 |
130933 B | 130933 B(0.00%) |
/ index.html 引用的全部 js/css gzip -9 |
654283 B | 656646 B(+2363 B,+0.36%) |
浏览器与网络
本地 next start(端口 3431,本分支构建;基线用同代码工作树的构建,端口 3432),Chrome 151 --headless=new,CDP Fetch 拦截 /api/* 返回虚构账户(林遥)、一条生时校正、一条普通咨询。页面里用 Page.addScriptToEvaluateOnNewDocument 装 MutationObserver 记录「加载环出现」和「首页可交互(标题存在 + 输入框可用 + 路径 /)」的时间戳。返回阶段每个 /api/* 响应都人为延迟 1500 ms:如果首页等任何一个请求,可交互时间不可能早于 1500 ms。脚本在 scratchpad,未提交。
/chart → 新建对话(本分支):
interactiveMs 23, title 新对话, loadingRingShown 0, url /
POST /api/daily-starlanguage @+15ms → answered +1519ms
GET /api/rectification/cases/entry-summary @+16ms → +1519ms
GET /api/chart-profiles @+16ms → +1519ms
GET /api/synastry-reports @+16ms → +1520ms
GET /api/models @+21ms → +1523ms
GET /api/consult/status @+22ms → +1523ms
GET /api/account @+22ms → +1524ms
requestsAnsweredBeforeInteractive: []
同一路径(基线):interactiveMs 5530, loadingRingShown 1,可交互前已返回 chart-profiles synastry-reports models consult/status entry-summary;daily-starlanguage 在 +5526 ms 才发出。
/reports → 历史会话:本分支 interactiveMs 19, loadingRingShown 0, url /?c=…,可交互前 0 个请求返回;基线 5532 ms, loadingRingShown 1。
整页刷新:两边都 22–24 ms 出加载环。控制台 error:0。
截图:docs/testing/home-warm-return-20260926-chart-new-chat.png(新建对话暖返回,今日星语卡与校正入口已就位)、docs/testing/home-warm-return-20260926-reports-history.png(历史会话暖返回)。
环境缺口
- Node 22:本机只有 v20.19.2,任务书要求的 Node 22 全量待协调方复跑(见
BLOCKED.md)。 - 无受控登录账号:登录态真机、iPhone Safari、真实 staging 数据按
docs/testing/home-warm-return-20260926.md由产品走。 - 无 Docker:
npm run test:db未跑(不动表)。 - 未部署。