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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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`):
|
||||
|
||||
@@ -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 验证
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+49
-45
@@ -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<OverlayMode>('manual');
|
||||
const [docRevOverlay, setDocRevOverlay] = useState<DiffResult | null>(null);
|
||||
const [docRevConflict, setDocRevConflict] = useState<string | null>(null);
|
||||
@@ -1082,6 +1090,18 @@ export default function App() {
|
||||
});
|
||||
|
||||
const [controlledContentReady, setControlledContentReady] = useState<Record<string, boolean>>({});
|
||||
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<Record<string, boolean>>({});
|
||||
const [docLoadErrorInfo, setDocLoadErrorInfo] = useState<
|
||||
Record<string, { code?: string; message: string; correlationId?: string }>
|
||||
@@ -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,
|
||||
setDeliverables((prev) =>
|
||||
mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser),
|
||||
);
|
||||
applyControlledInvalidationPlan(
|
||||
planControlledContentInvalidation({
|
||||
reason: 'list_sync',
|
||||
candidateIds: controlledHydrateCandidateIds(
|
||||
deliverablesRef.current,
|
||||
currentProjectId,
|
||||
currentUser,
|
||||
),
|
||||
activeDocId: activeDocIdRef.current,
|
||||
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
|
||||
}),
|
||||
);
|
||||
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;
|
||||
});
|
||||
} 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 (
|
||||
|
||||
@@ -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<string, string>, ready: Record<string, boolean>) {
|
||||
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<string, string> = { [DOC]: 'UI06-mu0o5hbq' };
|
||||
let ready: Record<string, boolean> = { [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<string, string> = { [DOC]: 'stale-before-agent' };
|
||||
let ready: Record<string, boolean> = { [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<string, string> = { [DOC]: 'draft-in-editor' };
|
||||
let ready: Record<string, boolean> = { [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);
|
||||
});
|
||||
});
|
||||
@@ -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<T>(
|
||||
map: Record<string, T>,
|
||||
dropIds: readonly string[],
|
||||
): Record<string, T> {
|
||||
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<TCache>(
|
||||
state: { cache: Record<string, TCache>; ready: Record<string, boolean> },
|
||||
plan: ControlledInvalidationPlan,
|
||||
): { cache: Record<string, TCache>; ready: Record<string, boolean> } {
|
||||
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';
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user