From 08bf615f95f02f98f36d86e5acbdb0d726556f08 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 03:33:37 +0000 Subject: [PATCH] fix(web): keep hydrated content across late list sync Hard refresh wiped cache/ready after a successful hydrate, then the unchanged fingerprint never re-ran the effect. Invalidate only via a planner that preserves list-sync hits and re-hydrates after post-task drops. Co-authored-by: Cursor --- ...09-14-controlled-content-canonical-hash.md | 3 + ...09-14-controlled-content-canonical-hash.md | 3 +- .doc/status/FEATURE-MATRIX.md | 2 +- web/src/App.tsx | 98 +++++----- .../lib/controlledContentInvalidation.test.ts | 180 ++++++++++++++++++ web/src/lib/controlledContentInvalidation.ts | 88 +++++++++ web/src/lib/editorHydrationGate.test.ts | 10 + web/src/lib/editorHydrationGate.ts | 5 + 8 files changed, 340 insertions(+), 49 deletions(-) create mode 100644 web/src/lib/controlledContentInvalidation.test.ts create mode 100644 web/src/lib/controlledContentInvalidation.ts diff --git a/.doc/ops/handoffs/2026-09-14-controlled-content-canonical-hash.md b/.doc/ops/handoffs/2026-09-14-controlled-content-canonical-hash.md index a4b9862b..9fe72cfb 100644 --- a/.doc/ops/handoffs/2026-09-14-controlled-content-canonical-hash.md +++ b/.doc/ops/handoffs/2026-09-14-controlled-content-canonical-hash.md @@ -25,6 +25,7 @@ status: done - 终审:`--live --environment dev` 必须同时识别 DSN `:5433` 与库名 `agent_system_dev`,且 `--host-data-root` 为 `data/dev`;空 dsnEnv/pathEnv 不放行;stored work_dir 必须在 `--stored-data-root` 下并映射,禁止沿用其他绝对路径 - 工程门禁(worktree):`go vet`、`go test -short`、`web npm test`、`web npm run build` PASS - UI follow-up:`shouldLoadControlledContent` / `hydrateControlledDocumentContent` 与 `revisionToolbarMountMode` 的 server-backed 资格对齐;`doc-001` 等本地 legacy/mock 不再请求 content 与 revision-state。`doc_proj*` 仍 hydrate;已注册 `doc-free`/freeform 与 template preview 不走受控 content 请求 +- UI 竞态 follow-up:`planControlledContentInvalidation` 统一失效。`list_sync` 不再清空 cache/ready(硬刷新后晚到列表不能把刚成功的 hydrate 打回 loading);`post_task` 清空活动文档时必须 bump `hydrateResumeKey`;编辑中保护草稿并 `requestHydrationResume`。`setDeliverables` updater 不再嵌套 setCache/setReady ## 未完成 @@ -53,6 +54,7 @@ status: done | `internal/docrevision/reconcile.go` | 唯一备份目录;回报 output/head/meta 三条路径 | | `cmd/document-content-reconcile/safety.go` | live 正向确认 dev DSN 与 `data/dev` | | `web/src/lib/controlledDocHydrate.ts` | 本地 legacy/mock 不 hydrate;与 toolbar 资格一致 | +| `web/src/lib/controlledContentInvalidation.ts` | 受控正文失效/重水合规划;list_sync 保 hydrate,post_task 必重拉 | | `internal/docgov/engine.go` | Mark 预加载/幂等补偿 | | `cmd/document-content-reconcile/` | 默认拒绝共享;live 硬门闩 + 路径映射 | | `internal/server/project_doc_handlers.go` | drift 返回 `DOCUMENT_CONTENT_PROJECTION_DRIFT` | @@ -66,6 +68,7 @@ go test ./internal/docrevision/ ./internal/docgov/ ./internal/server/ ./internal -run 'TestCanonicalize|TestClassify|TestReconcile|TestCommitMigrationRepair|TestMapStored|TestInspectProjection|TestMigration032|TestReadDocumentContent_Typed|TestWriteControlledContentError|TestServeLedgerHead|TestValidateCLI' cd web && npm test && npm run build # 重点:controlledDocHydrate 拒绝 doc-001 / template / registered freeform 发 content+revision +# 重点:controlledContentInvalidation 覆盖 hydrate→晚到sync、post_task 重水合、编辑/PATCH 防旧 GET ``` 隔离 PG(禁止 5433/5434/5435 与 `agent_system_dev|test|main`): diff --git a/.doc/ops/short-term/2026-09-14-controlled-content-canonical-hash.md b/.doc/ops/short-term/2026-09-14-controlled-content-canonical-hash.md index bcb7468b..2405a386 100644 --- a/.doc/ops/short-term/2026-09-14-controlled-content-canonical-hash.md +++ b/.doc/ops/short-term/2026-09-14-controlled-content-canonical-hash.md @@ -41,6 +41,7 @@ mutex: docgov 7. [x] follow-up:旧 head 正文备份、备份不可覆盖、原子写目录 fsync;单提交后 done 8. [x] 终审 3 项:live 正向确认 :5433+agent_system_dev+data/dev;备份三件套+唯一目录/O_EXCL+全路径;rename 后目录 Sync 兼容处理 9. [x] UI clean run:doc-001 等本地 legacy 不发 content/revision;hydrate 资格对齐 toolbar server-backed +10. [x] UI 竞态:晚到 list sync 不得擦掉成功 hydrate;清空 ready/cache 必须重水合;编辑/PATCH 基线受保护 ## 验收标准 @@ -73,7 +74,7 @@ mutex: docgov - 审查修补后再次 `go vet ./...` PASS、`go test -short ./...` PASS - focused:canonical/classify/reconcile/CLI live 门闩/path map/typed content/drift 冲突 PASS - PG: 本机 `TEST_DATABASE_URL` 未设置;`TestReconcile_LegacyRepair_PG` skip。已去掉 `-short` 无条件跳过,隔离库就绪后即使 `-short` 也会跑。禁止 5433/5434/5435 -- `web npm test` PASS(tsx 337 + vitest 30) +- `web npm test` PASS(tsx 349 + vitest 30;含 controlledContentInvalidation 时序) - `web npm run build` PASS - follow-up:`*.head.md` 保存旧 head 正文;已有备份拒绝覆盖;`WriteFileAtomic` rename/link 后 `SyncDir` - 未执行:`env-service.sh`、共享 Docker、`data/dev` apply、真实 UI、隔离 PG apply 验证 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index b323cc7b..e444660d 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -21,7 +21,7 @@ | 工作流治理 | 三正式工作流 + 阶段门禁 | done | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md) | Phase2 设计器 partial | `workflow` | | 纯软件项目自举 | 软件项目创建、Bootstrap 基线、Delivery 迭代与受控工程交接 | planned | [详细设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[现状调研](../ops/research/2026-09-14-software-project-bootstrap-current-state.md);[验收计划](../ops/checklists/2026-09-14-software-project-bootstrap-acceptance.md) | 当前仅完成方案;目录/模板/执行器/创建向导/真实 PI UI 均未实施。受控代码执行单列后续 Epic | `workflow-design` | | IAM / 鉴权 | 项目级 API 鉴权 + RBAC | partial | [audit/authorization](../audit/authorization-matrix-v2.md);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | 历史读取端点补统一会话 owner + 阶段授权;旧 API 410 标注(BUG-11 partial) | `authz` | -| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | partial | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[项目 143 正文 404 审计](../audit/2026-09-14-project143-controlled-content-404.md);[整改](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md);[handoff](../ops/handoffs/2026-09-14-controlled-content-canonical-hash.md) | 生命周期/租约保留。W1–W6 + 审查修补 + UI hydrate 资格(本地 doc-001 不发 content/revision)工程门禁已在 `fix/controlled-content-canonical-hash` 通过。隔离 PG apply、合 dev、143 apply、真实 UI 未做,不得标 done / PASS | `docgov` | +| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | partial | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[项目 143 正文 404 审计](../audit/2026-09-14-project143-controlled-content-404.md);[整改](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md);[handoff](../ops/handoffs/2026-09-14-controlled-content-canonical-hash.md) | 生命周期/租约保留。W1–W6 + 审查修补 + UI hydrate 资格 + 晚到 list sync 不再擦掉成功 hydrate。工程门禁已在 `fix/controlled-content-canonical-hash` 通过。隔离 PG apply、合 dev、143 apply、真实 UI 未做,不得标 done / PASS | `docgov` | | 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [管理测试](../test-reports/2026-09-13-document-revision-management-test-report.md);[运行时 UI Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[运行时审计](../audit/2026-09-13-document-revision-runtime-404.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **运行时 UI PASS**(6159 / V3/r2)。仍 `partial`:change report=`fallback`(真实 diff+lineage,非完整 PI 叙事)。系统级 `CHAT-INTENT` 见 gap-matrix | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` | | PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` | diff --git a/web/src/App.tsx b/web/src/App.tsx index c7c6f84e..845fb1a5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -68,6 +68,12 @@ import { resolveCanonicalDocId, shouldLoadControlledContent, } from './lib/controlledDocHydrate'; +import { + controlledHydrateCandidateIds, + dropRecordKeys, + planControlledContentInvalidation, + type ControlledInvalidationPlan, +} from './lib/controlledContentInvalidation'; import { applyLeaseUpdates, sameLease } from './lib/deliverableLeaseSync'; import { ControlledContentRequestError } from './api/projectDocuments'; import { @@ -726,6 +732,8 @@ export default function App() { if (targetProjId === 3) return 'doc-cusa-feasibility-reg'; return deliverables[0]?.docId || 'doc-001'; }); + const activeDocIdRef = useRef(activeDocId); + activeDocIdRef.current = activeDocId; const [docRevOverlayMode, setDocRevOverlayMode] = useState('manual'); const [docRevOverlay, setDocRevOverlay] = useState(null); const [docRevConflict, setDocRevConflict] = useState(null); @@ -1082,6 +1090,18 @@ export default function App() { }); const [controlledContentReady, setControlledContentReady] = useState>({}); + const applyControlledInvalidationPlan = useCallback((plan: ControlledInvalidationPlan) => { + if (plan.dropIds.length > 0) { + setDocCache((cache) => dropRecordKeys(cache, plan.dropIds)); + setControlledContentReady((ready) => dropRecordKeys(ready, plan.dropIds)); + } + if (plan.requestResume) { + editorHydrationGateRef.current.requestHydrationResume(); + } + if (plan.rehydrateActive) { + setHydrateResumeKey((k) => k + 1); + } + }, []); const [docLoadErrors, setDocLoadErrors] = useState>({}); const [docLoadErrorInfo, setDocLoadErrorInfo] = useState< Record @@ -1736,34 +1756,20 @@ export default function App() { try { const workflowDocs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []); if (!active || workflowDocs.length === 0) return; - setDeliverables((prev) => { - const merged = mergeWorkflowDocumentsFromServer( - prev, - workflowDocs, - currentProjectId, - currentUser, - ); - const serverBackedIds = merged - .filter((d) => d.projectId === currentProjectId && shouldLoadControlledContent(d)) - .map((d) => d.docId); - if (serverBackedIds.length > 0) { - setDocCache((cache) => { - const next = { ...cache }; - serverBackedIds.forEach((docId) => { - delete next[docId]; - }); - return next; - }); - setControlledContentReady((ready) => { - const next = { ...ready }; - serverBackedIds.forEach((docId) => { - delete next[docId]; - }); - return next; - }); - } - return merged; - }); + setDeliverables((prev) => + mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser), + ); + applyControlledInvalidationPlan( + planControlledContentInvalidation({ + reason: 'list_sync', + candidateIds: controlledHydrateCandidateIds( + deliverablesRef.current, + currentProjectId, + ), + activeDocId: activeDocIdRef.current, + editorOpen: editorHydrationGateRef.current.isEditorOpen(), + }), + ); } catch (e) { console.warn('[deliverables] 工作流受控文档同步失败:', e); } @@ -1772,7 +1778,15 @@ export default function App() { return () => { active = false; }; - }, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name, workflowDocsEpoch]); + }, [ + session, + sessionValidated, + currentProjectId, + currentUser.id, + currentUser.name, + workflowDocsEpoch, + applyControlledInvalidationPlan, + ]); // 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。 useEffect(() => { @@ -2623,24 +2637,14 @@ export default function App() { onMerged: (next) => { deliverablesRef.current = next; setDeliverables(next); - const serverBackedIds = next - .filter((d) => d.projectId === task.projectId && shouldLoadControlledContent(d)) - .map((d) => d.docId); - if (serverBackedIds.length === 0) return; - setDocCache((cache) => { - const cleared = { ...cache }; - serverBackedIds.forEach((docId) => { - delete cleared[docId]; - }); - return cleared; - }); - setControlledContentReady((ready) => { - const cleared = { ...ready }; - serverBackedIds.forEach((docId) => { - delete cleared[docId]; - }); - return cleared; - }); + applyControlledInvalidationPlan( + planControlledContentInvalidation({ + reason: 'post_task', + candidateIds: controlledHydrateCandidateIds(next, task.projectId), + activeDocId: activeDocIdRef.current, + editorOpen: editorHydrationGateRef.current.isEditorOpen(), + }), + ); }, }); if ( diff --git a/web/src/lib/controlledContentInvalidation.test.ts b/web/src/lib/controlledContentInvalidation.test.ts new file mode 100644 index 00000000..ba5bd6bd --- /dev/null +++ b/web/src/lib/controlledContentInvalidation.test.ts @@ -0,0 +1,180 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + applyControlledContentInvalidation, + controlledHydrateCandidateIds, + dropRecordKeys, + planControlledContentInvalidation, + resolveControlledContentView, +} from './controlledContentInvalidation'; +import { shouldLoadControlledContent } from './controlledDocHydrate'; +import { createEditorHydrationGate } from './editorHydrationGate'; +import type { DeliverableItem } from '../types/workbench'; + +const DOC = 'doc_proj6172_intake'; + +const serverDoc: DeliverableItem = { + docId: DOC, + projectId: 6172, + taskKey: 'intake', + title: '用户初始需求收集表', + stage: 'V0.5', + category: 'workflow', + creatorId: 1, + creatorName: '张工', + isFixedWorkflow: true, + isTemplateOnly: false, + lifecycleState: 'drafting', + isClaimed: true, + author: '张工', + reviewer: '李主管', + approver: '王总监', + updatedAt: '2026-09-14 11:20', + governanceMode: 'controlled', +}; + +function viewOf(cache: Record, ready: Record) { + return resolveControlledContentView({ + needsServerContent: true, + hasCache: Boolean(cache[DOC]), + ready: Boolean(ready[DOC]), + }); +} + +describe('controlledContentInvalidation', () => { + it('hydrate success then late list_sync keeps cache/ready and does not bump resume', () => { + let cache: Record = { [DOC]: 'UI06-mu0o5hbq' }; + let ready: Record = { [DOC]: true }; + let resumeKey = 0; + + const plan = planControlledContentInvalidation({ + reason: 'list_sync', + candidateIds: [DOC, 'doc_proj6172_research'], + activeDocId: DOC, + editorOpen: false, + }); + const next = applyControlledContentInvalidation({ cache, ready }, plan); + cache = next.cache; + ready = next.ready; + if (plan.rehydrateActive) resumeKey += 1; + + assert.deepEqual(plan, { dropIds: [], rehydrateActive: false, requestResume: false }); + assert.equal(cache[DOC], 'UI06-mu0o5hbq'); + assert.equal(ready[DOC], true); + assert.equal(resumeKey, 0); + assert.equal(viewOf(cache, ready), 'content'); + }); + + it('post_task invalidation drops active cache and requires a new hydrate epoch', () => { + let cache: Record = { [DOC]: 'stale-before-agent' }; + let ready: Record = { [DOC]: true }; + let resumeKey = 0; + + const plan = planControlledContentInvalidation({ + reason: 'post_task', + candidateIds: [DOC], + activeDocId: DOC, + editorOpen: false, + }); + assert.deepEqual(plan.dropIds, [DOC]); + assert.equal(plan.rehydrateActive, true); + + const next = applyControlledContentInvalidation({ cache, ready }, plan); + cache = next.cache; + ready = next.ready; + if (plan.rehydrateActive) resumeKey += 1; + + assert.equal(cache[DOC], undefined); + assert.equal(ready[DOC], undefined); + assert.equal(resumeKey, 1); + assert.equal(viewOf(cache, ready), 'loading'); + + cache = { ...cache, [DOC]: 'agent-output' }; + ready = { ...ready, [DOC]: true }; + assert.equal(viewOf(cache, ready), 'content'); + }); + + it('editor open / save epoch keeps draft and rejects a stale GET after PATCH', () => { + const gate = createEditorHydrationGate(); + gate.setActiveDocId(DOC); + const stale = gate.beginHydration(DOC); + assert.ok(stale); + + let cache: Record = { [DOC]: 'draft-in-editor' }; + let ready: Record = { [DOC]: true }; + + gate.setEditorOpen(true); + const listPlan = planControlledContentInvalidation({ + reason: 'list_sync', + candidateIds: [DOC], + activeDocId: DOC, + editorOpen: true, + }); + const taskPlan = planControlledContentInvalidation({ + reason: 'post_task', + candidateIds: [DOC], + activeDocId: DOC, + editorOpen: true, + }); + + const afterList = applyControlledContentInvalidation({ cache, ready }, listPlan); + const afterTask = applyControlledContentInvalidation(afterList, taskPlan); + cache = afterTask.cache; + ready = afterTask.ready; + + assert.equal(listPlan.dropIds.length, 0); + assert.equal(taskPlan.dropIds.length, 0); + assert.equal(taskPlan.rehydrateActive, false); + assert.equal(taskPlan.requestResume, true); + assert.equal(cache[DOC], 'draft-in-editor'); + assert.equal(ready[DOC], true); + + gate.markSaved(); + cache = { ...cache, [DOC]: 'patched-r1' }; + assert.equal(gate.decide(stale).apply, false); + assert.equal(gate.decide(stale).reason, 'editor_open'); + + gate.requestHydrationResume(); + gate.setEditorOpen(false); + assert.equal(gate.decide(stale).reason, 'superseded_by_save'); + assert.equal(gate.takeHydrationResume(), true); + assert.equal(cache[DOC], 'patched-r1'); + }); + + it('never drops the active doc without signaling rehydrate', () => { + const plan = planControlledContentInvalidation({ + reason: 'post_task', + candidateIds: [DOC, 'doc_proj6172_research'], + activeDocId: DOC, + editorOpen: false, + }); + assert.ok(plan.dropIds.includes(DOC)); + assert.equal(plan.rehydrateActive, true); + }); + + it('candidate helper keeps template / doc-001 / freeform / upload out of wipe lists', () => { + const docs: DeliverableItem[] = [ + serverDoc, + { ...serverDoc, docId: 'doc-001', projectId: 1, taskKey: 'feasibility_reg' }, + { ...serverDoc, docId: 'doc-p143-intake', isTemplateOnly: true, taskKey: 'intake' }, + { + ...serverDoc, + docId: 'doc-free-1', + governanceMode: 'freeform', + taskKey: 'freeform_doc', + isFixedWorkflow: false, + }, + { ...serverDoc, docId: 'doc-upload-1', sourceType: 'uploaded_file' }, + ]; + assert.deepEqual(controlledHydrateCandidateIds(docs, 6172), [DOC]); + assert.equal(shouldLoadControlledContent(docs[1]), false); + assert.equal(shouldLoadControlledContent(docs[2]), false); + assert.equal(shouldLoadControlledContent(docs[3]), false); + assert.equal(shouldLoadControlledContent(docs[4]), false); + }); + + it('dropRecordKeys is a no-op when ids are absent', () => { + const ready = { other: true }; + assert.equal(dropRecordKeys(ready, [DOC]), ready); + }); +}); diff --git a/web/src/lib/controlledContentInvalidation.ts b/web/src/lib/controlledContentInvalidation.ts new file mode 100644 index 00000000..4aef26ad --- /dev/null +++ b/web/src/lib/controlledContentInvalidation.ts @@ -0,0 +1,88 @@ +import { shouldLoadControlledContent } from './controlledDocHydrate'; +import type { DeliverableItem } from '../types/workbench'; + +/** + * 受控正文 cache / ready 的统一失效规划。 + * + * 任何清空必须经过这里:晚到的列表同步不得擦掉刚成功的 hydrate; + * 若清空了当前活动文档,调用方必须 bump hydrate epoch 才能重新拉正文。 + */ +export type ControlledInvalidationReason = 'list_sync' | 'post_task'; + +export type ControlledInvalidationInput = { + reason: ControlledInvalidationReason; + candidateIds: readonly string[]; + activeDocId: string | undefined; + editorOpen: boolean; +}; + +export type ControlledInvalidationPlan = { + dropIds: string[]; + rehydrateActive: boolean; + requestResume: boolean; +}; + +export function controlledHydrateCandidateIds( + docs: DeliverableItem[], + projectId?: number, +): string[] { + return docs + .filter( + (d) => + (projectId == null || d.projectId === projectId) && shouldLoadControlledContent(d), + ) + .map((d) => d.docId); +} + +export function planControlledContentInvalidation( + input: ControlledInvalidationInput, +): ControlledInvalidationPlan { + const active = (input.activeDocId || '').trim(); + const candidates = [...new Set(input.candidateIds.filter((id) => !!id?.trim()))]; + + if (input.reason === 'list_sync') { + return { dropIds: [], rehydrateActive: false, requestResume: false }; + } + + const dropIds = candidates.filter((id) => !(input.editorOpen && id === active)); + const rehydrateActive = !!active && dropIds.includes(active); + const requestResume = input.editorOpen && !!active && candidates.includes(active); + return { dropIds, rehydrateActive, requestResume }; +} + +export function dropRecordKeys( + map: Record, + dropIds: readonly string[], +): Record { + if (dropIds.length === 0) return map; + let changed = false; + const next = { ...map }; + for (const id of dropIds) { + if (id in next) { + delete next[id]; + changed = true; + } + } + return changed ? next : map; +} + +export function applyControlledContentInvalidation( + state: { cache: Record; ready: Record }, + plan: ControlledInvalidationPlan, +): { cache: Record; ready: Record } { + return { + cache: dropRecordKeys(state.cache, plan.dropIds), + ready: dropRecordKeys(state.ready, plan.dropIds), + }; +} + +/** 与 App docAST 受控分支一致:ready 被清且不再 hydrate 就会永久停在 loading。 */ +export function resolveControlledContentView(input: { + needsServerContent: boolean; + hasCache: boolean; + ready: boolean; +}): 'content' | 'loading' | 'local' { + if (!input.needsServerContent) return 'local'; + if (input.hasCache && input.ready) return 'content'; + return 'loading'; +} diff --git a/web/src/lib/editorHydrationGate.test.ts b/web/src/lib/editorHydrationGate.test.ts index 6d12011a..e58c1d24 100644 --- a/web/src/lib/editorHydrationGate.test.ts +++ b/web/src/lib/editorHydrationGate.test.ts @@ -128,6 +128,16 @@ describe('createEditorHydrationGate', () => { assert.equal(gate.takeHydrationResume(), true); }); + it('requestHydrationResume is consumed once after a protected edit session', () => { + const gate = createEditorHydrationGate(); + gate.setActiveDocId('doc_a'); + gate.setEditorOpen(true); + gate.requestHydrationResume(); + gate.setEditorOpen(false); + assert.equal(gate.takeHydrationResume(), true); + assert.equal(gate.takeHydrationResume(), false); + }); + it('late hydrate after a successful PATCH does not roll back the new baseline', () => { const gate = createEditorHydrationGate(); gate.setActiveDocId('doc_a'); diff --git a/web/src/lib/editorHydrationGate.ts b/web/src/lib/editorHydrationGate.ts index 71ef57e3..37975b17 100644 --- a/web/src/lib/editorHydrationGate.ts +++ b/web/src/lib/editorHydrationGate.ts @@ -59,6 +59,8 @@ export type EditorHydrationGate = { * so a first-load skeleton is not left forever (effect deps may not change). */ takeHydrationResume: () => boolean; + /** 列表/任务同步在编辑中保护了活动文档时,关闭编辑器后补一次 hydrate。 */ + requestHydrationResume: () => void; }; export function createEditorHydrationGate(): EditorHydrationGate { @@ -107,5 +109,8 @@ export function createEditorHydrationGate(): EditorHydrationGate { blockedNeedsResume = false; return true; }, + requestHydrationResume() { + blockedNeedsResume = true; + }, }; }