diff --git a/BLOCKED.md b/BLOCKED.md index d522605b..18c72d84 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,12 @@ # BLOCKED +## 侧栏导航循环:本机构建与登录态验收缺口(2026-09-19) + +- 同 SHA 基线 webpack 构建完成编译/TypeScript,但 collect page data 因 Skill runtime `symlink EPERM` 失败;修复版同环境结果见进度记录。未得到完整生产构建,因此 `/` Static 和首屏 gzip ±2% 尚未验收,不能用不完整产物代替。 +- 基线全量前端测试 3481 tests / 3403 pass / 78 fail;包含 Windows 路径、符号链接、数据库/Docker 与既有合同失败。修复版逐项对照见 [本轮记录](docs/tasks/PROGRESS-sidebar-navigation-loop-20260919.md),不顺带放宽断言。 +- 没有受控登录态浏览器,未执行完整 Next Link 三入口点击、移动/折叠及可见分页 observer 走查。替代证据为真实 React provider/effect/transition 回归,**不等价于浏览器验收**。按 [真人清单](docs/testing/sidebar-navigation-loop-20260919.md) 补齐。 +- 发布前预检远端 verified,但碎片扫描两条既有断言失败;不以同步成功声称预检全绿。修复交付与部署状态在进度记录分别标注。 + ## ~~TypeSafe Jev 意图分类:等 API key(2026-09-19)~~ - ~~T2/T3 阻塞,替代证据 = T0/T1 产出。~~ diff --git a/CHANGELOG.md b/CHANGELOG.md index d6ceb86e..9d4f79ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-19 — 修复侧栏导航被首页重复更新阻塞 + +拆开首页与共享侧栏的状态订阅,消除反复更新导致「星盘 / 星历 / 我的报告」点了不切页的反馈循环;仍使用客户端导航,不以整页刷新绕过。新增真实 React 生命周期与切页回归,保留最新会话操作和离开首页后的只读侧栏。测试站部署及登录态走查状态见本轮进度记录。Skill 版本不变。 + ## 2026-09-19 — 设置弹窗布局与资料入口整理 设置弹窗的四个分区继续共用固定外框,桌面导航加宽并与内容区分隔,右侧内容增加稳定内边距,表单阅读宽度更舒适;导航去掉会误导成页面跳转的右箭头。个人资料的账户头像预览放大到 56px,星盘资料的“添加其他人”移到分组标题旁,列表较长时也容易找到。Skill 版本不变(纯界面改动)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index fdc2d27f..a57ad855 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -12797,3 +12797,20 @@ - 相关记录:BUG-554、BUG-698 - 复发自:无 - 修复版本:待发布 + +## BUG-971 | 首页侧栏注册反馈循环阻塞客户端导航 + +- 状态:investigating(代码与定向回归完成;部署和登录态真人闭环待核对) +- 首次发现:2026-09-19 +- 最近更新:2026-09-19 +- 影响面:首页侧栏「星盘 / 星历 / 我的报告」客户端导航与共享外壳更新。 +- 用户现象:测试站刷新后仍点了不切页,Network 出现 `/api/ephemeris`;该请求来自 pointerenter/pointerdown 数据预取,不代表 Next 导航成功。 +- 触发条件:登录态 Home hydrated 后注册侧栏操作;useSessionManagement 每次 render 产生新的 visibleSessions/操作函数,注册 effect 随之再次发布。 +- 根因:SessionListContext 同时承载业务数据和 registration。Home 写 registration 又订阅同一 value,setRegistration 广播反向使 Home 重渲染,形成默认优先级更新循环,阻塞 Next Link 使用的 startTransition。旧共享外壳测试只有源码正则,不能执行 effect,未拦住运行时反馈。 +- 修复:会话数据 context 保留稳定 registrar,registration 独立只读 context 仅供 AppShell 订阅;映射显式接收 registration,children 原样透传。保留注册 effect 完整依赖、最新回调及卸载注销,不冻结首次闭包、不改成整页刷新。 +- 验证:真实 React 最小原版实验 122 render / 118 注册 / 2 maximum-depth 告警(保护阈值主动终止);稳定数组和回调的因果对照为 3/1/0。另一次 transition 实验直到主动停止反馈后目的地才 commit。正式新生命周期测试旧实现 3 fail / 1 pass、修复 4/4 pass;含普通/StrictMode 空闲收敛、transition 注销重入、最新业务数据与回调、401。实际 SidebarProvider/Inset 包裹,但不模拟浏览器点击/布局;完整主会话复验、基线失败对照与发布结果见进度记录。 +- 防复发:Home 不订阅自己生产的 shell registration,外壳不重建 children;保留真实客户端 React 回归而非仅 SSR/源码正则。未稳定数组/函数的测试输入不得改成固定常量来掩盖回归。 +- 相关记录:BUG-927(共享外壳)、BUG-965 / BUG-967(同名导航现象但不同跨部署机制);本轮不扩修 health 快照时序漏洞。 +- 复发自:BUG-927 共享外壳订阅边界的新增运行时回归,非已证实的旧构建不匹配复发。 +- 修复版本:本轮 `fix(frontend): isolate sidebar registration to unblock navigation` 提交;发布状态见 `docs/tasks/PROGRESS-sidebar-navigation-loop-20260919.md`。 +- 验收缺口:Windows Skill runtime symlink EPERM 阻止完整基线构建;Static/gzip 与真实登录态浏览器检查不得标通过。见 `BLOCKED.md` 和 `docs/testing/sidebar-navigation-loop-20260919.md`。 diff --git a/docs/tasks/PROGRESS-sidebar-navigation-loop-20260919.md b/docs/tasks/PROGRESS-sidebar-navigation-loop-20260919.md new file mode 100644 index 00000000..196254e0 --- /dev/null +++ b/docs/tasks/PROGRESS-sidebar-navigation-loop-20260919.md @@ -0,0 +1,57 @@ +# 执行与验收 · 侧栏导航注册循环(2026-09-19) + +## 授权、基线与范围 + +- 产品明确授权修复并 push staging,实施计划已批准;主代理负责诊断/验收/交付,子代理实施限定源码与测试。 +- 基线 `origin/staging = 507ef959f519218d190574fa588fc7b92c60229b`;独立工作树 `.worktrees/sidebar-navigation-loop-20260919`,分支 `codex/sidebar-navigation-loop-20260919`。同 SHA 的 detached 基线工作树用于测试和构建比较,不改主检出、不 stash。 +- 开工核对 Bug 最大号 970,本轮暂用 BUG-971,提交前再次检查编号冲突。 +- 只拆分会话数据与侧栏注册订阅;保持单侧栏、单列表数据源、最新操作回调、卸载注销、inert 和客户端导航。无样式/文案、数据库、依赖、workflow、DNS、main 改动。不扩修 BUG-967 的 health 时序问题。 + +## 事故实证与根因 + +产品确认测试站三个入口刷新后仍不切页;看到的 `/api/ephemeris` 是 `AppSidebar` 的 pointerenter/pointerdown 数据预取,不代表导航成功。匿名三个页面和 RSC 均 200;线上 `d6fc4fb8` 与本轮基线的关键导航源码相同。 + +`Home -> useSessionList -> useSessionManagement(新 visibleSessions/回调) -> useHomeShellRegistration effect -> setRegistration -> 同一 context 通知 Home` 形成反馈。真实 React 最小运行原版 122 render / 118 注册 / 2 个 maximum-depth 告警(保护阈值主动终止);只稳定回调无效,数组与回调稳定对照 3/1/0。另在第 3 render 发 startTransition,循环直到第 301 render 主动停止才允许目标 commit;普通优先级与稳定数组对照均可正常提交。Next Link 实际通过 startTransition 导航。 + +这是新的共享外壳运行时缺陷,关联 BUG-927,不把本轮刷新无效强归为旧 BUG-965/967。旧 provider 测试只有源码正则,未执行 effects。 + +## 实施进度 + +- [x] 隔离基线与实施工作树、复用现有依赖(junction,不安装或升级)。 +- [x] 注册订阅读写隔离与外壳消费调整(3 个源码文件,Home 状态与原注册 effect 未改)。 +- [x] 真实 React 生命周期/transition 回归及反向验证。 +- [x] 独立审查及可运行验收、完整测试/构建基线比较;Static/gzip/真人浏览器仍为明确缺口。 +- [ ] 提交与快进 push staging、远端 SHA/部署核对。 + +独立审查确认 data value 不依赖 registration、registrar 保持稳定、shell 单独读取、children 原样透传;账户/session 真正变动仍能通知 Home 并发布最新闭包。新 host helper 只运行 React reconciler/effect,不模拟点击、几何或浏览器可访问性。可见分页 observer、SidebarTrigger 操作与完整 Next 浏览器仍列真人缺口,没有声称全覆盖。 + +## 验证记录 + +| 项目 | 基线 | 修复后 | 结论 | +| --- | --- | --- | --- | +| 全量前端测试 | 3481 tests / 3403 pass / 78 fail | 3486 tests / 3408 pass / 78 fail | 新增 5 个通过;78 个失败测试名/次数完全一致,非全绿 | +| TypeScript | webpack 类型检查完成 | 独立 `tsc --noEmit --incremental false` 0 错 | 通过 | +| 完整 lint | 未重跑(未触碰原 warnings) | 0 error / 120 warnings,exit 0 | 零 error 通过,未顺手修 warning | +| 生产构建 / 首页 Static / 首屏 gzip | 编译/类型完成,collect page data: Skill symlink EPERM | 同阶段同根因 EPERM(含共同 /api/birth-time-guide) | 构建失败;Static/gzip 未验收 | +| 生命周期反向 | 新 4 条在旧源码 3 fail / 1 pass(50 render 保护) | 4/4 pass | 证明能抓回归 | +| 主会话独立五文件定向 | 原四文件 60/60 | 65/65,fail 0 | 通过 | +| staging 部署 | 诊断时 d6fc4fb8 | 未推送 | 未验收 | + +- 全量逐项证据:[78 项失败对照](../testing/sidebar-navigation-baseline-failures-20260919.md)。全量与构建使用同一 node_modules、相同命令;不读取用户凭据,不改生产源码绕过 Windows 权限。 +- 定向:`tsx --test tests/session-list-lifecycle.test.tsx tests/session-list-provider.test.ts tests/secondary-page-entry.test.ts tests/sidebar-contract.test.ts tests/account-dialog-inert-20260918.test.ts`。 +- 构建:`npm run build -- --webpack`。Next 16.3.1 编译与类型通过不等于构建通过。 +- 推送前 fetch 得到 `a2bcdee006397e2417f2e42d30ca030dd41ec480`,相对初始基线仅 2 个文档提交、2 处文档文件变化,Bug 最大号仍 970;整合后代码测试基线不变。 +- Gitea Actions runs 匿名只读 API 返回 401;未借用/搜寻其他账号凭据,门禁详情查询为环境缺口。 + +## 既有断言改动 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| 既有断言全部保留 | 不修改任何旧断言;追加 1 条结构合同和 4 条生命周期测试 | 锁定订阅边界并执行真实 effect/transition,测试总数 +5;没有弱化或减少覆盖 | + +## 环境边界 + +- 不使用真实用户 Cookie/凭据,不接管故障标签页;登录态完整 Next 点击验收待 `docs/testing/sidebar-navigation-loop-20260919.md`。 +- Windows 基线与修复树均复用相同 node_modules;为避免 Turbopack 外部 junction 限制,用相同 `npm run build -- --webpack` 对照,具体结果如实记录。 +- 本会话此前 `pre_work_check.py` 远端 verified,但两条碎片扫描断言失败(镜像路径字面量与候选/残留统计边界),不属于本轮前端修复,不改断言掩盖失败。 +- Skill 版本不变。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f805017a..f56c3a33 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -7,6 +7,8 @@ - 被环境或依赖挡住的事项写进根目录 `BLOCKED.md`;Bug 事实写进 `docs/BUG_HISTORY.md`;两者不在这里重复。 - 纯文档改动不触发 staging 门禁(见 `deploy/gated-paths.txt`)。 +- [侧栏导航注册循环修复与验收](PROGRESS-sidebar-navigation-loop-20260919.md) — 2026-09-19,修复/回归已完成,完整验收与 staging 交付状态见记录。 + ## 状态板 这一节是所有任务的总板。任务书推上来时加一行,状态变化时改这一行,不另开文件。 diff --git a/docs/testing/sidebar-navigation-baseline-failures-20260919.md b/docs/testing/sidebar-navigation-baseline-failures-20260919.md new file mode 100644 index 00000000..6efa958f --- /dev/null +++ b/docs/testing/sidebar-navigation-baseline-failures-20260919.md @@ -0,0 +1,84 @@ +# 侧栏修复 · 同环境全量失败逐项对照 + +基线 `507ef959`:3481 tests / 3403 pass / 78 fail。修复:3486 tests / 3408 pass / 78 fail。按测试名及出现次数比较,新增/消失失败均为 0;下列 78 项在两边均失败,不作为通过。未收录原始输出或任何凭据。 + +| 测试名 | 基线 | 修复 | +| --- | --- | --- | +| `admin code functions reject immutable codes, revoked redemption, and roll back on audit failure` | fail | fail | +| `operation-level admin email reauthentication is removed from routes and UI` | fail | fail | +| `rectification agent maps setup failures without calling the rest of the handler` | fail | fail | +| `renders the persisted Beam avatar in the sidebar, account menu, and profile editor` | fail | fail | +| `public code never imports the internal dynamic choice contract` | fail | fail | +| `tests\\birth-time-guide-agent.test.ts` | fail | fail | +| `candidate scores stay out of the specified client ownership boundary` | fail | fail | +| `personal Agent exposes the Jyotish Skill and named server tool` | fail | fail | +| `general agent runtime has no Jyotish skill package and no personal chart tool` | fail | fail | +| `the route's own strict checklist reaches the answer, not just the package listing` | fail | fail | +| `a route the skill declares no checklist for is reported, not filled in with another route's` | fail | fail | +| `health uses the skill's health-timing-strict checklist` | fail | fail | +| `wealth uses finance-timing-strict as the live checklist, with the wealth alias in the heading` | fail | fail | +| `a multi-domain plan carries every executed route's checklist once` | fail | fail | +| `no plan can spend the answer's context on method` | fail | fail | +| `the widest legal plan still fits the budget` | fail | fail | +| `further reading offers the references the skill names, and only ones that exist` | fail | fail | +| `the delivered method is quoted from the live skill tree` | fail | fail | +| `tests\\consultation-workflow-request.test.ts` | fail | fail | +| `admin customer reset clears only rebuildable application state` | fail | fail | +| `service and restricted admin database identities stay separated` | fail | fail | +| `Owner recovery grants only one currently loginable synced identity admin` | fail | fail | +| `staging backups are encrypted, atomic, private, and retain the newest three` | fail | fail | +| `rejects destructive backup directory aliases and symlink components before mutation` | fail | fail | +| `rejects unsafe writable backup parents before creating the target` | fail | fail | +| `rejects a direct canonical sticky shared backup directory before chmod` | fail | fail | +| `creates every absent backup path component privately despite a permissive caller umask` | fail | fail | +| `same-second backups publish once without overwriting the completed archive` | fail | fail | +| `find enumeration failures preserve existing backups and do not report completion` | fail | fail | +| `refuses full disks and removes a failed-pipeline partial file` | fail | fail | +| `billing order adjustments and redemption reasons are atomic and audited` | fail | fail | +| `billing, subscriptions, usage authorization, RBAC, and model publication remain transactional` | fail | fail | +| `saving a published product forks a draft and delete retires or removes it` | fail | fail | +| `append_consultation_question ignores thinking fields and enforces the physical JSON cap` | fail | fail | +| `database env validator accepts punctuated literal required secrets` | fail | fail | +| `database env validator rejects symlinks and unsafe modes` | fail | fail | +| `database env validator enforces an explicit staging owner uid without printing values` | fail | fail | +| `database env validator accepts a private valid file without printing values` | fail | fail | +| `local PostgreSQL applies the reviewed business schema and serves authenticated business calls` | fail | fail | +| `personal_reports.card_summary is nullable, owner-read, service-written, and length-capped` | fail | fail | +| `personal report job migration is atomic, lease-bound, recoverable, and owner read-only` | fail | fail | +| `longform appendices are owner-read, service-written, and never change report status` | fail | fail | +| `personal report sections enforce owner-read RLS and service-owned durable transitions` | fail | fail | +| `block_scan RPCs are service_role-only and advance a declared period` | fail | fail | +| `redeem security: case-sensitive hashing, rate limiting, idempotency and order ownership` | fail | fail | +| `read_report_candidate_range is service_role-only, returns only the window, and stays empty without rows` | fail | fail | +| `self-hosted identity migration creates Better Auth tables with least privilege` | fail | fail | +| `every color token used as a Tailwind utility is exposed through @theme` | fail | fail | +| `staging model provider env preparation removes legacy settings and keeps one stable key` | fail | fail | +| `staging env validator rejects selector drift, duplicates, and unsafe permissions` | fail | fail | +| `production env validators accept only self-hosted production selectors and role URLs` | fail | fail | +| `Better Auth supports shared user OTP/password sessions for admins` | fail | fail | +| `job migration mirror is exact and preserves personal_reports as the document projection` | fail | fail | +| `document v2 and durable job migrations are exact db/supabase mirrors` | fail | fail | +| `document v2 migration preserves v1 reads and adds explicit product depth` | fail | fail | +| `durable job migration defines the complete state, lease, retry, and identity contract` | fail | fail | +| `durable job migration atomically enqueues reports and exposes lease RPCs only to service_role` | fail | fail | +| `ingest P0: education kinds, batch confirm, opening focus reuse, precision lock` | fail | fail | +| `PR-4 candidate decisions use server UUIDs, receipt-derived gates and separate acceptance/confirmation` | fail | fail | +| `tests\\rectification-v9-agent.test.ts` | fail | fail | +| `v9 migration applies on a fresh database and re-applies idempotently` | fail | fail | +| `v9 open is atomic, idempotent and allows separate homepage cases` | fail | fail | +| `v9 enforces profile gating, ownership and terminal read-only` | fail | fail | +| `v9 evidence lifecycle: quote grounding, idempotency, confirm and revision lineage` | fail | fail | +| `v9 legacy backfill maps statuses, keeps one resumable per user and is idempotent` | fail | fail | +| `v9 agent api migration applies, seeds the runtime flag and guards consent` | fail | fail | +| `v9 ignores a historical active minute and allows reselection within the live result` | fail | fail | +| `tests\\skill-binding.test.ts` | fail | fail | +| `immutable Skill migration enforces RPC-only identity, legacy adoption, and cascade-safe receipts` | fail | fail | +| `checked-in registry verifies hashed product packages and leaves consult on the live skill` | fail | fail | +| `path traversal and symlink escape fail closed` | fail | fail | +| `symbolic links are rejected even when their target stays inside the project root` | fail | fail | +| `live consult skill reads a hand-updated tree without a registry hash` | fail | fail | +| `changed staging workflows are syntactically valid YAML` | fail | fail | +| `live staging sync preserves env, state, incoming files, and encrypted backups` | fail | fail | +| `live staging sync repairs nested deploy-tree drift without preserving foreign ownership` | fail | fail | +| `first immutable deployment rolls back to validated local image IDs` | fail | fail | +| `is-docs-only-range.sh decides from local history and refuses non-ancestor ranges` | fail | fail | diff --git a/docs/testing/sidebar-navigation-loop-20260919.md b/docs/testing/sidebar-navigation-loop-20260919.md new file mode 100644 index 00000000..99727b6b --- /dev/null +++ b/docs/testing/sidebar-navigation-loop-20260919.md @@ -0,0 +1,25 @@ +# 侧栏导航循环 · 测试站真人验收(2026-09-19) + +关联 BUG-971;自动化/交付结果见 [进度记录](../tasks/PROGRESS-sidebar-navigation-loop-20260919.md)。以下项目尚未真人验收,不以匿名 HTTP 200 或最小 React host 测试替代。 + +## 前置 + +- 仅测试站 `https://staging.jyotisha.chat`;先核对 `/api/health` 的 `deployment.gitCommit` 为本轮代码提交。 +- 使用本人受控测试账号,不转交 Cookie、JWT、出生资料、会话内容或完整 HAR/curl;曾外发的会话凭据先退出登录并重新登录。 +- 开 Console 与 Network,清空已有记录。截图只保留脱敏错误、请求路径/状态码、部署 SHA。 + +## 核心复现与回归 + +1. 刷新首页,静置后点击「星盘」;地址应变 `/chart`,正文与高亮同时切换。回首页后分别点「星历」「我的报告」,验证 `/ephemeris`、`/reports`。 +2. 三个次级页互跳、浏览器前进/后退、回首页再点三入口,不能停在旧页或必须二次点击。 +3. Console 不应持续刷 `Maximum update depth exceeded`;静置时 Network 不应持续发送同类请求。 +4. `/api/ephemeris` 可能在指针进入/按下时预取,**不代表导航完成**。以 URL、目的页正文和路由响应共同判断;按需记录 `_rsc` 请求状态,不记录敏感查询参数。 +5. 切换受控测试会话、创建空会话、改名/置顶/归档/删除测试会话,确认高亮、列表和操作结果仍同步,不出现旧回调操作错误对象。不要操作真实重要内容。 +6. 有下一页会话时将列表滚至底部,验证分页可见、加载一次后稳定,没有 observer 重挂导致请求风暴;来回切页不重新拉全列表。 +7. 打开并关闭账户菜单及设置弹窗,验证正文 inert/恢复、Escape 和导航恢复正常;次级页侧栏保持只读且返回首页后恢复操作。 +8. 桌面展开/折叠侧栏、移动端抽屉各试三入口;点击后抽屉关闭、正文切换。若登录失效,应正常到登录页/只读回退,不持续刷新。 + +## 回报格式 + +- 部署 SHA、浏览器/视口、通过/失败的步骤编号。 +- 失败时仅提供脱敏 Console 错误和请求路径/状态码、URL 是否变化;不贴凭据或原始用户内容。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ad81b0df..3a0caaf2 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -410,6 +410,12 @@ one skeleton, none of them carrying the sidebar. `NEXT_PUBLIC_GIT_COMMIT` with `/api/health` `.deployment.gitCommit` on visibility and before navigation; a mismatch uses `window.location.assign`, a match keeps the client router. +- **Separate data and shell subscriptions.** `SessionListProvider` owns both, + but Home reads only the session/account context and its stable registrar. + Only AppShell subscribes to shell registration; it passes the original + `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. - **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/app/(app)/layout.tsx b/frontend/src/app/(app)/layout.tsx index c9997360..3a7b9159 100644 --- a/frontend/src/app/(app)/layout.tsx +++ b/frontend/src/app/(app)/layout.tsx @@ -4,13 +4,13 @@ import type { ReactNode } from "react"; import { AppSidebar } from "@/components/app-sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { SessionListProvider, sessionListSidebarModel, useSessionList } from "@/hooks/use-session-list"; +import { SessionListProvider, sessionListSidebarModel, useSessionList, useShellRegistration } from "@/hooks/use-session-list"; import "../site-styles"; function AppShell({ children }: { children: ReactNode }) { const list = useSessionList(); - const { sessions, account } = sessionListSidebarModel(list); - const registration = list.registration; + const registration = useShellRegistration(); + const { sessions, account } = sessionListSidebarModel(list, registration); return ( diff --git a/frontend/src/hooks/use-session-list.ts b/frontend/src/hooks/use-session-list.ts index 9ac76a31..810802f7 100644 --- a/frontend/src/hooks/use-session-list.ts +++ b/frontend/src/hooks/use-session-list.ts @@ -1 +1 @@ -export { SessionListProvider, useSessionList, sessionListSidebarModel } from "@/lib/session-list-context"; +export { SessionListProvider, useSessionList, useShellRegistration, sessionListSidebarModel } from "@/lib/session-list-context"; diff --git a/frontend/src/lib/session-list-context.tsx b/frontend/src/lib/session-list-context.tsx index cab218d0..5af0e32b 100644 --- a/frontend/src/lib/session-list-context.tsx +++ b/frontend/src/lib/session-list-context.tsx @@ -55,10 +55,13 @@ export type SessionListContextValue = { ready: Promise; boot: () => SessionListBoot | null; registerShellControls: (registration: ShellRegistration | null) => void; - registration: ShellRegistration | null; }; const SessionListContext = createContext(null); +// Home produces these controls; only the shell subscribes to their updates. +// Publishing them through the data context re-renders Home, whose registration +// effect has fresh callbacks/rows, feeding another update back to the provider. +const ShellRegistrationContext = createContext(null); function createReadyGate(): { promise: Promise; resolve: () => void } { let settle = () => {}; @@ -167,20 +170,24 @@ export function SessionListProvider({ children }: { children: ReactNode }) { ready: readyPack.promise, boot, registerShellControls, - registration, }), [ account, boot, readyPack.promise, registerShellControls, - registration, sessions, sessionsCursor, settled, signedOut, ]); - return {children}; + return ( + + + {children} + + + ); } export function useSessionList(): SessionListContextValue { @@ -191,11 +198,17 @@ export function useSessionList(): SessionListContextValue { return value; } -export function sessionListSidebarModel(list: SessionListContextValue): { +export function useShellRegistration(): ShellRegistration | null { + return useContext(ShellRegistrationContext); +} + +export function sessionListSidebarModel( + list: SessionListContextValue, + registered: ShellRegistration | null, +): { sessions: readonly SidebarSession[]; account: SidebarAccount | null; } { - const registered = list.registration; return { sessions: registered?.sidebarSessions ?? sessionsToSidebarRows(list.sessions), account: registered?.sidebarAccount ?? toSidebarAccount(list.account), diff --git a/frontend/tests/react-client-lifecycle-test-support.ts b/frontend/tests/react-client-lifecycle-test-support.ts new file mode 100644 index 00000000..05c73a91 --- /dev/null +++ b/frontend/tests/react-client-lifecycle-test-support.ts @@ -0,0 +1,96 @@ +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; + +/** Minimal host DOM for React lifecycle tests, not a browser/layout simulator. + * Components may render div/section wrappers; no event dispatch, geometry or + * accessibility behavior is emulated. React's reconciler/effects are real. + */ +class HostElement { + readonly nodeType = 1; + readonly namespaceURI = "http://www.w3.org/1999/xhtml"; + readonly childNodes: HostElement[] = []; + readonly style = {}; + readonly attributes = new Map(); + parentNode: HostElement | null = null; + textContent = ""; + constructor(readonly tagName: string, readonly ownerDocument: HostDocument) {} + get nodeName() { return this.tagName; } + get firstChild() { return this.childNodes[0] ?? null; } + addEventListener() {} + removeEventListener() {} + setAttribute(name: string, value: string) { this.attributes.set(name, String(value)); } + removeAttribute(name: string) { this.attributes.delete(name); } + appendChild(node: HostElement) { + this.childNodes.push(node); + node.parentNode = this; + return node; + } + insertBefore(node: HostElement, before: HostElement) { + this.childNodes.splice(this.childNodes.indexOf(before), 0, node); + node.parentNode = this; + return node; + } + removeChild(node: HostElement) { + this.childNodes.splice(this.childNodes.indexOf(node), 1); + node.parentNode = null; + return node; + } +} + +class HostDocument { + readonly nodeType = 9; + readonly activeElement = null; + defaultView: unknown; + addEventListener() {} + removeEventListener() {} + createElement(tag: string) { return new HostElement(tag.toUpperCase(), this); } + createElementNS(_namespace: string, tag: string) { return this.createElement(tag); } +} + +export function createClientLifecycleHarness() { + const document = new HostDocument(); + const frames = new Map>(); + let frameId = 0; + const window = { + document, + innerWidth: 1440, + HTMLElement: HostElement, + HTMLIFrameElement: class {}, + addEventListener() {}, + removeEventListener() {}, + requestAnimationFrame(callback: () => void) { + const id = ++frameId; + frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0)); + return id; + }, + cancelAnimationFrame(id: number) { clearTimeout(frames.get(id)); frames.delete(id); }, + }; + document.defaultView = window; + const globals = { window, document, IS_REACT_ACT_ENVIRONMENT: true }; + const originals = Object.fromEntries(Object.keys(globals).map((key) => [ + key, Object.getOwnPropertyDescriptor(globalThis, key), + ])); + for (const [key, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, key, { value, writable: true, configurable: true }); + } + const errors: unknown[] = []; + const root = createRoot(document.createElement("div") as unknown as HTMLElement, { + onUncaughtError: (error) => { errors.push(error); }, + }); + return { + errors, + async render(node: ReactNode) { await act(async () => { root.render(node); }); }, + async update(action: () => void) { await act(async () => { action(); }); }, + async idle() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); }, + async close() { + try { await act(async () => { root.unmount(); }); } + finally { + for (const timer of frames.values()) clearTimeout(timer); + for (const [key, descriptor] of Object.entries(originals)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } + }, + }; +} diff --git a/frontend/tests/session-list-lifecycle.test.tsx b/frontend/tests/session-list-lifecycle.test.tsx new file mode 100644 index 00000000..28d91f71 --- /dev/null +++ b/frontend/tests/session-list-lifecycle.test.tsx @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { StrictMode, startTransition, useEffect, useState, type ReactNode } from "react"; + +import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx"; +import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts"; +import * as sessionList from "../src/lib/session-list-context.tsx"; +import { emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts"; +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 row: ChatSession = { + id: "22222222-2222-4222-8222-222222222222", title: "Synthetic session", theme: "general", + modelId: "test", messages: [], createdAt: 1, updatedAt: 1, + sessionType: "consultation", rectificationCaseId: null, chartProfileId: null, + chartProfileName: null, chartProfileRole: null, pinned: false, archivedAt: null, + messagesHydrated: false, +}; +const noop = () => {}; +const trigger = { current: null }; +const charts: never[] = []; + +async function fixture(options: { strict?: boolean; unauthorized?: boolean } = {}) { + const harness = createClientLifecycleHarness(); + const originalFetch = globalThis.fetch; + const requests: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + requests.push(url); + assert.ok(url === "/api/account" || url.startsWith("/api/sessions?"), `unexpected fetch ${url}`); + return new Response(JSON.stringify(url === "/api/account" ? account : { sessions: [], nextCursor: null }), { + status: options.unauthorized ? 401 : 200, + }); + }) as typeof fetch; + let homeRenders = 0; + let targetCommits = 0; + let latestList: sessionList.SessionListContextValue | undefined; + let latestShell: sessionList.ShellRegistration | null = null; + let latestModel: ReturnType | undefined; + let setPage: (home: boolean) => void = noop; + let revise: (revision: number) => void = noop; + const callbackRevisions: number[] = []; + + function HomeProbe() { + const list = sessionList.useSessionList(); + const [revision, setRevision] = useState(0); + // Bound failure even if the old effect/context feedback is reintroduced. + homeRenders += 1; + if (homeRenders > 50) throw new Error("Home registration failed to settle within 50 renders"); + const changed = () => { callbackRevisions.push(revision); }; + useHomeShellRegistration({ + account: list.account, accountMenuOpen: revision > 0, accountTrigger: trigger, + activeSessionId: `revision-${revision}`, cancellationPending: false, + chartLibrary: charts, creatingSession: false, hydrated: list.settled, + loadMoreSessions: changed, modalOpen: revision > 0, modelCatalog: null, + openAccountDialog: changed, pendingSessionId: null, + profile: { ...emptyProfile, name: `Synthetic ${revision}` }, + rectificationErrorMessage: "", rectificationErrorSessionId: null, + rectificationOpeningSessionId: null, rectificationSurfaceOpen: false, + registerShellControls: list.registerShellControls, + renameSession: changed, selectSession: changed, sessionMenuId: null, + sessions: list.sessions, sessionsCursor: null, + setAccountMenuOpen: noop, setPendingSessionDeletion: noop, setSessionMenuId: noop, + shareSession: changed, showArchivedSessions: false, startNewChat: changed, + toggleArchivedSession: changed, toggleArchivedView: changed, togglePinnedSession: changed, + // Like useSessionManagement, intentionally new array/callbacks each render. + visibleSessions: list.sessions.filter(() => true), + }); + useEffect(() => { revise = setRevision; }, []); + return null; + } + function Target() { + useEffect(() => { targetCommits += 1; }, []); + return null; + } + function Shell({ children }: { children: ReactNode }) { + const list = sessionList.useSessionList(); + const registration = sessionList.useShellRegistration(); + useEffect(() => { + latestList = list; + latestShell = registration; + latestModel = sessionList.sessionListSidebarModel(list, registration); + }); + // Exercise the actual second provider and unchanged children identity too: + // shell context updates must not re-render the Home registration producer. + return + {children} + ; + } + function Router() { + const [home, showHome] = useState(true); + useEffect(() => { setPage = showHome; }, []); + return {home ? : }; + } + try { + await harness.render(options.strict ? : ); + } catch (error) { + await harness.close(); + globalThis.fetch = originalFetch; + throw error; + } + return { + harness, requests, callbackRevisions, + get renders() { return homeRenders; }, + get commits() { return targetCommits; }, + get list() { assert.ok(latestList); return latestList; }, + get shell() { return latestShell; }, + get model() { assert.ok(latestModel); return latestModel; }, + async navigate(home: boolean) { await harness.update(() => startTransition(() => setPage(home))); }, + async revise(value: number) { await harness.update(() => revise(value)); }, + async close() { try { await harness.close(); } finally { globalThis.fetch = originalFetch; } }, + }; +} + +for (const strict of [false, true]) { + test(`home registration settles and transitions unregister/reenter (StrictMode=${strict})`, async () => { + const app = await fixture({ strict }); + try { + assert.deepEqual(app.harness.errors, []); + assert.ok(app.shell, "hydrated Home registers its controls"); + const idleRenders = app.renders; + await app.harness.idle(); + assert.equal(app.renders, idleRenders, "idle shell does not feed updates back into Home"); + assert.ok(idleRenders < 15, `unexpected render growth: ${idleRenders}`); + const reads = app.requests.length; + await app.navigate(false); + assert.deepEqual(app.harness.errors, []); + assert.ok(app.commits > 0, "startTransition commits the destination"); + assert.equal(app.shell, null, "Home cleanup unregisters controls"); + assert.equal(app.model.account?.name, "Synthetic", "secondary page uses account fallback"); + await app.navigate(true); + assert.ok(app.shell, "returning Home registers new controls"); + assert.equal(app.requests.length, reads, "shared provider does not refetch on route changes"); + await app.harness.idle(); + assert.deepEqual(app.harness.errors, []); + } finally { await app.close(); } + }); +} + +test("shell receives current callbacks, account and session state without freezing closures", async () => { + const app = await fixture(); + try { + assert.deepEqual(app.harness.errors, []); + const initialCallback = app.shell?.controls.onOpenProfile; + await app.revise(1); + 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); + app.shell?.controls.onOpenProfile(); + assert.deepEqual(app.callbackRevisions, [1]); + await app.harness.update(() => { + app.list.setAccount({ ...account, credits: 27 }); + app.list.setSessions([row]); + }); + assert.equal(app.model.account?.credits, 27); + assert.equal(app.model.sessions[0]?.id, row.id); + await app.navigate(false); + assert.equal(app.shell, null); + assert.equal(app.model.account?.credits, 27); + assert.equal(app.model.sessions[0]?.id, row.id); + assert.deepEqual(app.harness.errors, []); + } finally { await app.close(); } +}); + +test("401 settles signed-out fallback with no shell registration", async () => { + const app = await fixture({ unauthorized: true }); + try { + assert.deepEqual(app.harness.errors, []); + assert.equal(app.list.signedOut, true); + assert.equal(app.list.settled, true); + await app.list.ready; + assert.equal(app.shell, null); + assert.deepEqual(app.model.sessions, []); + assert.equal(app.model.account, null); + const renders = app.renders; + await app.harness.idle(); + assert.equal(app.renders, renders); + } finally { await app.close(); } +}); diff --git a/frontend/tests/session-list-provider.test.ts b/frontend/tests/session-list-provider.test.ts index 00c516ef..15db00b1 100644 --- a/frontend/tests/session-list-provider.test.ts +++ b/frontend/tests/session-list-provider.test.ts @@ -35,3 +35,15 @@ test("a 401 clears the list and marks signedOut", () => { test("session writes no longer invalidate a second cache", () => { assert.doesNotMatch(sessionHook, /invalidateSidebarCache/); }); + +test("only the shell consumes registration and it passes Home children through unchanged", () => { + assert.match(layout, /const registration = useShellRegistration\(\)/); + assert.match(layout, /sessionListSidebarModel\(list, registration\)/); + assert.doesNotMatch(layout, /list\.registration|cloneElement/); + assert.match(layout, /\s*\{children\}\s*<\/SidebarInset>/); + assert.doesNotMatch(page, /useShellRegistration/); + const dataValue = provider.slice(provider.indexOf("const value = useMemo"), provider.indexOf("return (", provider.indexOf("const value = useMemo"))); + assert.doesNotMatch(dataValue, /\bregistration\b/); + assert.match(dataValue, /registerShellControls/); + assert.match(provider, /\s*\{children\}/); +});