fix(web): keep block ids and drafts across editor hydration races
Background GET rebuilt markdown AST with Math.random ids and dropped head_block_map, so save closed the modal without PATCH. Align hydrate to the ledger map, gate in-flight responses, and fail-closed on stale ids. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "UI-06 编辑弹窗打开期间 hydration 换块 ID 导致保存静默丢稿"
|
||||
status: in_progress
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
severity: high
|
||||
---
|
||||
|
||||
# UI-06 编辑弹窗打开期间 hydration 换块 ID 导致保存静默丢稿
|
||||
|
||||
## 现象
|
||||
|
||||
第十二轮真实 UI(schema 30,dev@4c465d2 基线):
|
||||
|
||||
- 双击正文块、fill 标记、点击「保存修改」动作均成功,编辑器随后关闭
|
||||
- 30 秒内修订仍为 **r0**
|
||||
- 网络 trace 中 `PATCH /api/v1/documents/.../content` **数量为 0**
|
||||
- 动作 monotonic:dblclick 611755–611807;fill 611860–611910;save 611917–611979
|
||||
- 恰在 fill 开始时,后台 `GET /api/v1/docgov/documents/doc_proj6140_intake/content` 于 **611862.379** 发起并完成 hydration
|
||||
|
||||
本轮不是选择器点错按钮(第七轮 save-selector),而是保存路径在客户端被静默短路。
|
||||
|
||||
## 根因分析
|
||||
|
||||
| 层 | 问题 | 后果 |
|
||||
|---|---|---|
|
||||
| AST 身份 | `markdownToBlockAST` / `generateId()` 明确 `Math.random()`,每次 parse 新建 `blk_*` | 弹窗持有打开时的块 ID,当前 `docAST` 已是新 ID |
|
||||
| hydrate 丢账本 | `fetchRevisionState` 依赖被窄化为 `Promise<{etag:string}>`;`buildControlledDocumentAST` 之后**没有** `applyHeadBlockMap(rev.st.head_block_map)` | 服务端已有稳定块身份,前端每次 hydration 仍随机换 ID |
|
||||
| 保存 | `handleSaveBlockEdit` 按 `updated.id` 在 `docAST.rootBlock.children` 查找;`idx === -1` 时 `setEditingBlock(null)` 后 return | 关窗、不发 PATCH、无提示 |
|
||||
| hydration 触发 | `useEffect([session, sessionValidated, activeDocId, deliverables])` 在 lease/工作流同步改 `deliverables` 时重跑 | fill 窗口打出新 GET |
|
||||
| 迟到响应 | hydrate `.then` 无 generation / 编辑门闩 | 弹窗打开后或 PATCH 成功后仍可覆盖编辑基线(即使 ID 已对齐,也会回滚新正文) |
|
||||
|
||||
`deliverables` 被 5s lease 同步、任务完成后 `syncControlledDocsAfterTask`、工作流列表合并(清 cache)以及 governance 事件改写。任一变化都会在编辑期间再拉正文并换块身份。
|
||||
|
||||
## 严重度
|
||||
|
||||
`high`
|
||||
|
||||
用户可见「已保存」(弹窗关闭),账本仍 r0。属于静默丢稿,不是提示后的失败。
|
||||
|
||||
## 关联整改
|
||||
|
||||
- Remediation: [../remediation/2026-09-13-document-revision-editor-hydration-race.md](../remediation/2026-09-13-document-revision-editor-hydration-race.md)
|
||||
- Short-term: [../ops/short-term/2026-09-13-document-revision-editor-hydration-race.md](../ops/short-term/2026-09-13-document-revision-editor-hydration-race.md)
|
||||
- 分支: `fix/document-revision-editor-hydration-race`
|
||||
|
||||
## 证据
|
||||
|
||||
- 失败证据目录(根代理保留、本轮不得改/移/删/提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui06-r-bump-noop-failure/`
|
||||
- Playwright 断言:`[data-testid="doc-rev-revision"]` 30s 内仍为 `r0`
|
||||
|
||||
## 开放问题
|
||||
|
||||
- 本轮只做前端门闩与单测/构建验证;**未跑真实 UI-06**,不得标 E2E 通过或任务 `done`
|
||||
- 关窗后的下一次 deliverables 驱动 hydrate 仍会重建块 ID(编辑会话已结束,可接受)
|
||||
@@ -31,6 +31,14 @@
|
||||
|
||||
### 最新专项审计(2026-09-13)
|
||||
|
||||
**文件**: [2026-09-13-document-revision-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md)
|
||||
|
||||
**结论**: 第十二轮 UI-06 在 fill 窗口完成后台 hydration,块 ID 被重建;保存按旧 ID 找不到目标后静默关窗,未发 PATCH,修订停在 r0。
|
||||
|
||||
**整改**: [编辑弹窗 hydration 竞态整改](../remediation/2026-09-13-document-revision-editor-hydration-race.md)
|
||||
|
||||
### 对话意图运行时审计(2026-09-13)
|
||||
|
||||
**文件**: [2026-09-13-conversation-intent-runtime-state.md](./2026-09-13-conversation-intent-runtime-state.md)
|
||||
|
||||
**结论**: 真实会话被诊断探针污染;客户端名词匹配令只读总结请求误启动写任务;错误动作与文档租约状态刷新后无法恢复。
|
||||
|
||||
+6
-4
@@ -1,13 +1,13 @@
|
||||
# 当前焦点
|
||||
|
||||
**更新**: 2026-09-13
|
||||
**production_ready**: no(DOCREV:schema **30**;第11轮 UI-11 prelim output 契约;编码可改 Dockerfile / 测 build context,不执行 env-service 重建)
|
||||
**production_ready**: no(DOCREV:schema **30**;第十二轮 UI-06 编辑弹窗 hydration 竞态;不执行 env-service 重建 / 不声称 E2E 通过)
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **DOCREV-RT**: [prelim_design 缺 output.md 产物契约修复](short-term/2026-09-13-document-revision-prelim-output.md)(`fix/document-revision-pi-runner-module`:dev@55a4f39 重建后 runner 缺 `settle-policy.mjs` 热修;不执行 env-service 重建;仍 `in_progress`)
|
||||
2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录)
|
||||
3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term
|
||||
1. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(`fix/document-revision-editor-hydration-race`;第十二轮 UI-06 r0 静默丢稿;不声称 E2E 通过)
|
||||
2. **DOCREV-RT**: [prelim_design 缺 output.md 产物契约修复](short-term/2026-09-13-document-revision-prelim-output.md)(`fix/document-revision-pi-runner-module` 已合入 `dev@4c465d2`;仍 `in_progress`,待占槽重跑 UI-11)
|
||||
3. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录)
|
||||
|
||||
## 并行认领
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
| Cursor-Auto | document-revision-e2e-overlay-transition | fix/document-revision-e2e-overlay-transition | - | document-revision-overlay-transition | in_progress |
|
||||
| Cursor-Auto | document-revision-second-doc-routing-lease | fix/document-revision-second-doc-routing-lease | - | document-revision-second-doc-routing-lease | in_progress |
|
||||
| Cursor-Auto | document-revision-prelim-output | fix/document-revision-pi-runner-module | - | document-revision-prelim-output | in_progress |
|
||||
| Cursor-Auto | document-revision-editor-hydration-race | fix/document-revision-editor-hydration-race | - | document-revision-editor-hydration-race | in_progress |
|
||||
|
||||
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
|
||||
|
||||
@@ -73,3 +74,4 @@
|
||||
| UI-07 transition 中间帧取样审计 | [../audit/2026-09-13-document-revision-overlay-transition.md](../audit/2026-09-13-document-revision-overlay-transition.md) |
|
||||
| UI-11 第二份文档路由与 lease 审计 | [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md) |
|
||||
| UI-11 prelim_design 缺 output.md 审计 | [../audit/2026-09-13-document-revision-prelim-output.md](../audit/2026-09-13-document-revision-prelim-output.md) |
|
||||
| UI-06 编辑弹窗 hydration 竞态审计 | [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md) |
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "编辑弹窗 hydration 竞态修复"
|
||||
status: in_progress
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
owner: Cursor-Auto
|
||||
branch: fix/document-revision-editor-hydration-race
|
||||
worktree: "-"
|
||||
mutex: document-revision-editor-hydration-race
|
||||
---
|
||||
|
||||
# 编辑弹窗 hydration 竞态修复
|
||||
|
||||
## 目标
|
||||
|
||||
堵住第十二轮 UI-06:编辑弹窗打开期间后台 hydration 换块 ID,保存静默关窗且不发 PATCH。用户编辑期间与 PATCH 成功后,迟到 hydration 不得覆盖基线;找不到块 fail-closed。
|
||||
|
||||
## 范围
|
||||
|
||||
- `web/src/lib/controlledDocHydrate.ts`:`fetchRevisionState` 保留 `head_block_map`,hydrate 后 `applyHeadBlockMap`
|
||||
- `web/src/lib/editorHydrationGate.ts` 与单测(迟到响应不得覆盖 PATCH 成功正文)
|
||||
- `web/src/lib/blockSavePolicy.ts` 与单测(stale-id fail-closed)
|
||||
- `web/src/App.tsx`:初始/切换/重试 hydration、打开/关闭编辑器、保存
|
||||
- audit / remediation / CURRENT 认领
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不修改、移动、删除或提交 `.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/`
|
||||
- 不合 `dev`、不 push、不启动/重建 Docker
|
||||
- 不运行或伪造真实 UI/E2E;不得声称 UI-06 已通过
|
||||
- 不改 FEATURE-MATRIX 完成度;不削弱 ETag/409
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 从 `dev@4c465d2` 开 `fix/document-revision-editor-hydration-race`,mutex 不与现有 in_progress 冲突
|
||||
2. [x] 定位全部会在弹窗打开期间替换 AST 的路径(effect / 选文档 / 重试;lease 改 deliverables 触发 effect)
|
||||
3. [x] hydrate 对齐 `head_block_map` + generation token / 编辑门闩 + stale-id fail-closed
|
||||
4. [x] 单测:同 map ID 稳定、缺 map 仍随机、deferred hydrate、stale id、迟到 hydrate、PATCH ETag、409
|
||||
5. [x] `web` npm test / build(tsx 相关 25 项 + 全量 `npm test` + `npm run build` 通过)
|
||||
6. [ ] 真实 UI-06 重跑(本轮不做)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 编辑中不因后台 hydration 换 ID 而静默丢稿(逻辑门闩 + 单测)
|
||||
- [x] 找不到目标块保留编辑器并提示
|
||||
- [ ] 现场 r0→r1(未跑 E2E,保持未勾选)
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Audit: [../../audit/2026-09-13-document-revision-editor-hydration-race.md](../../audit/2026-09-13-document-revision-editor-hydration-race.md)
|
||||
- Remediation: [../../remediation/2026-09-13-document-revision-editor-hydration-race.md](../../remediation/2026-09-13-document-revision-editor-hydration-race.md)
|
||||
- 失败证据(勿提交): `.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui06-r-bump-noop-failure/`
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
- mutex:`document-revision-editor-hydration-race`(新 mutex,未占用 `document-revision-save-selector` 等 in_progress 行)
|
||||
- 基线:`dev@4c465d2`
|
||||
- 验证:`npx tsx --test` 覆盖 gate / save policy / hydrate / etag 共 25 pass;全量 `npm test` + `npm run build` 通过
|
||||
- **未跑真实 UI-06,不得标 done**
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "编辑弹窗 hydration 竞态整改"
|
||||
status: in_progress
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-13
|
||||
---
|
||||
|
||||
# 编辑弹窗 hydration 竞态整改
|
||||
|
||||
## 背景
|
||||
|
||||
链到 [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md)。
|
||||
|
||||
第十二轮 UI-06:fill 瞬间后台 GET 完成 hydration,块 ID 被重建;保存按旧 ID 找不到目标后静默关窗,PATCH 次数为 0,修订停在 r0。
|
||||
|
||||
## 目标
|
||||
|
||||
- 编辑期间后台 hydration(含 fill 与 save 之间完成的响应)不得替换块身份、不得覆盖编辑基线
|
||||
- PATCH 成功后迟到 hydration 不得回滚新正文 / 新 ETag
|
||||
- 找不到目标块时 fail-closed:保留弹窗与用户输入,明确提示,绝不关窗、不按正文改另一个块
|
||||
- 正常保存仍发 PATCH,采用打开时/hydrate 基线 ETag;409 语义不削弱
|
||||
- **不声称真实 UI/E2E 已通过**
|
||||
|
||||
## 分波次计划
|
||||
|
||||
| 波次 | 内容 | 验收 |
|
||||
|------|------|------|
|
||||
| Wave 0 | hydrate 对齐 `head_block_map`;抽出 `editorHydrationGate` + `blockSavePolicy`;App 三条 hydrate 路径与保存接线 | 单测:同 map 两次 hydrate ID 稳定;缺 map 仍随机;deferred hydrate / stale id / 迟到 hydrate / PATCH+409;`npm test` + `npm run build` |
|
||||
| Wave 1 | 占槽后真实 UI-06(本轮不做) | 现场 PATCH 次数 ≥ 1 且 r0→r1 |
|
||||
|
||||
## 设计
|
||||
|
||||
两层,缺一不可;不靠 Playwright sleep:
|
||||
|
||||
1. **身份对齐**:`hydrateControlledDocumentContent` 保留完整 `RevisionState`(含 `head_block_map`),`buildControlledDocumentAST` 后 `applyHeadBlockMap`。同一 head 的重复 hydrate 不再因 `Math.random` 换 ID。
|
||||
2. **generation / 编辑门闩**:双击立刻 `setEditorOpen(true)`;编辑中 `beginHydration` 返回 `null`;已在途 token 经 `decideHydrationApply` 丢弃。PATCH 成功先 `markSaved()` 再关窗,迟到 hydrate **即使 ID 已对齐**也不得回滚新正文。
|
||||
3. **stale-id fail-closed**:`resolveSaveTargetBlock` **只按 id**;找不到则提示并留窗,绝不按正文改另一块、绝不静默关窗。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 纯函数门闩覆盖 UI-06 时序(hydrate 在 fill 后 resolve 不覆盖)
|
||||
- [x] stale block id 保存不静默关闭、不假成功、不改同内容另一块
|
||||
- [x] PATCH 成功后迟到 hydration 不回滚;409 保留草稿
|
||||
- [ ] 真实 UI-06 E2E(本轮不做,不得勾选)
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Audit: [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md)
|
||||
- Short-term: [../ops/short-term/2026-09-13-document-revision-editor-hydration-race.md](../ops/short-term/2026-09-13-document-revision-editor-hydration-race.md)
|
||||
- 证据(只读): `.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui06-r-bump-noop-failure/`
|
||||
@@ -3,6 +3,7 @@
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT |
|
||||
| [2026-09-13-document-revision-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md) | 编辑弹窗打开期间 hydration 换块 ID,保存静默丢稿 |
|
||||
| [2026-09-13-document-revision-runtime-remediation.md](./2026-09-13-document-revision-runtime-remediation.md) | 账本 PG 持久化、bootstrap、保存/overlay/确定性 change report |
|
||||
| [2026-09-13-document-revision-management-implementation-plan.md](./2026-09-13-document-revision-management-implementation-plan.md) | 智能体版本、人工修订、差异认知与负责人通知 |
|
||||
| [2026-09-13-conversation-intent-runtime-state-remediation.md](./2026-09-13-conversation-intent-runtime-state-remediation.md) | 对话写意图、刷新可恢复任务状态、文档占用可见性与诊断隔离 |
|
||||
|
||||
+76
-29
@@ -69,6 +69,14 @@ import {
|
||||
resolveCanonicalDocId,
|
||||
shouldLoadControlledContent,
|
||||
} from './lib/controlledDocHydrate';
|
||||
import {
|
||||
createEditorHydrationGate,
|
||||
type HydrationToken,
|
||||
} from './lib/editorHydrationGate';
|
||||
import {
|
||||
interpretBlockSaveError,
|
||||
resolveSaveTargetBlock,
|
||||
} from './lib/blockSavePolicy';
|
||||
import { DocumentLineage } from './types/projectWorkflow';
|
||||
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||||
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
||||
@@ -721,6 +729,22 @@ export default function App() {
|
||||
/** 人工保存 / 受控正文 hydrate 后递增,驱动工具条重新拉取 revision-state 与 overlay */
|
||||
const [docRevRefreshKey, setDocRevRefreshKey] = useState(0);
|
||||
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
|
||||
const editorHydrationGateRef = useRef(createEditorHydrationGate());
|
||||
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
|
||||
|
||||
const openBlockEditor = useCallback((block: DocumentBlock) => {
|
||||
editorHydrationGateRef.current.setEditorOpen(true);
|
||||
setEditingBlock(block);
|
||||
}, []);
|
||||
|
||||
const closeBlockEditor = useCallback(() => {
|
||||
editorHydrationGateRef.current.setEditorOpen(false);
|
||||
setEditingBlock(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
editorHydrationGateRef.current.setActiveDocId(activeDocId);
|
||||
}, [activeDocId]);
|
||||
|
||||
// 认证与会话状态
|
||||
const [session, setSession] = useState<LoginResult | null>(() => authClient.loadSavedSession());
|
||||
@@ -1148,11 +1172,35 @@ export default function App() {
|
||||
});
|
||||
}, [deliverables, activeDocId]);
|
||||
|
||||
// 受控非模板文档:挂载/切换/同步后自动拉取服务端正文
|
||||
const applyHydratedDocument = useCallback(
|
||||
(
|
||||
token: HydrationToken,
|
||||
ast: DocumentAST | null | undefined,
|
||||
docId: string,
|
||||
opts?: { refreshToolbar?: boolean },
|
||||
) => {
|
||||
if (!ast) return false;
|
||||
if (!editorHydrationGateRef.current.decide(token).apply) return false;
|
||||
setDocCache((prev) => ({ ...prev, [docId]: ast }));
|
||||
setControlledContentReady((prev) => ({ ...prev, [docId]: true }));
|
||||
setDocLoadErrors((prev) => ({ ...prev, [docId]: false }));
|
||||
if (opts?.refreshToolbar !== false) {
|
||||
setDocRevRefreshKey((k) => k + 1);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 受控非模板文档:挂载/切换/同步后自动拉取服务端正文。
|
||||
// hydrate 会按 head_block_map 对齐块 ID;编辑弹窗打开期间仍不得发起新 GET,
|
||||
// 已在途响应也不得覆盖编辑基线或 PATCH 成功后的新正文。
|
||||
useEffect(() => {
|
||||
if (!session || sessionValidated !== true) return;
|
||||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||||
if (!target || !shouldLoadControlledContent(target)) return;
|
||||
const token = editorHydrationGateRef.current.beginHydration(activeDocId);
|
||||
if (!token) return;
|
||||
|
||||
let cancelled = false;
|
||||
setDocLoadErrors((prev) => {
|
||||
@@ -1162,15 +1210,13 @@ export default function App() {
|
||||
});
|
||||
void hydrateControlledDocumentContent(activeDocId, target)
|
||||
.then((ast) => {
|
||||
if (cancelled || !ast) return;
|
||||
setDocCache((prev) => ({ ...prev, [activeDocId]: ast }));
|
||||
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: true }));
|
||||
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: false }));
|
||||
setDocRevRefreshKey((k) => k + 1);
|
||||
if (cancelled) return;
|
||||
applyHydratedDocument(token, ast, activeDocId);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('自动拉取受控文档正文失败:', err);
|
||||
if (cancelled) return;
|
||||
if (!editorHydrationGateRef.current.decide(token).apply) return;
|
||||
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true }));
|
||||
setDocCache((prev) => ({
|
||||
...prev,
|
||||
@@ -1181,11 +1227,13 @@ export default function App() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session, sessionValidated, activeDocId, deliverables]);
|
||||
}, [session, sessionValidated, activeDocId, deliverables, applyHydratedDocument]);
|
||||
|
||||
const retryControlledDocLoad = useCallback(() => {
|
||||
const target = deliverables.find((d) => d.docId === activeDocId);
|
||||
if (!target || !shouldLoadControlledContent(target)) return;
|
||||
const token = editorHydrationGateRef.current.beginHydration(activeDocId);
|
||||
if (!token) return;
|
||||
|
||||
setDocLoadErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -1200,21 +1248,18 @@ export default function App() {
|
||||
|
||||
void hydrateControlledDocumentContent(activeDocId, target)
|
||||
.then((ast) => {
|
||||
if (!ast) return;
|
||||
setDocCache((prev) => ({ ...prev, [activeDocId]: ast }));
|
||||
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: true }));
|
||||
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: false }));
|
||||
setDocRevRefreshKey((k) => k + 1);
|
||||
applyHydratedDocument(token, ast, activeDocId);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('重试拉取受控文档正文失败:', err);
|
||||
if (!editorHydrationGateRef.current.decide(token).apply) return;
|
||||
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true }));
|
||||
setDocCache((prev) => ({
|
||||
...prev,
|
||||
[activeDocId]: buildControlledLoadErrorSkeleton(target),
|
||||
}));
|
||||
});
|
||||
}, [activeDocId, deliverables]);
|
||||
}, [activeDocId, deliverables, applyHydratedDocument]);
|
||||
|
||||
// 自愈后自动回写入缓存,保障后续操作直接命中
|
||||
useEffect(() => {
|
||||
@@ -1251,6 +1296,10 @@ export default function App() {
|
||||
|
||||
// 切换文档
|
||||
const handleSelectDoc = (id: string) => {
|
||||
if (id !== activeDocId && editorHydrationGateRef.current.isEditorOpen()) {
|
||||
closeBlockEditor();
|
||||
}
|
||||
editorHydrationGateRef.current.setActiveDocId(id);
|
||||
setActiveDocId(id);
|
||||
let targetDoc = deliverables.find((d) => d.docId === id);
|
||||
|
||||
@@ -1311,11 +1360,11 @@ export default function App() {
|
||||
}
|
||||
|
||||
if (targetDoc && shouldLoadControlledContent(targetDoc)) {
|
||||
const token = editorHydrationGateRef.current.beginHydration(id);
|
||||
if (!token) return;
|
||||
void hydrateControlledDocumentContent(id, targetDoc)
|
||||
.then((ast) => {
|
||||
if (!ast) return;
|
||||
setDocCache((prev) => ({ ...prev, [id]: ast }));
|
||||
setControlledContentReady((prev) => ({ ...prev, [id]: true }));
|
||||
applyHydratedDocument(token, ast, id, { refreshToolbar: false });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('获取受控文档正文失败:', err);
|
||||
@@ -1738,7 +1787,6 @@ export default function App() {
|
||||
}, [session, sessionValidated, currentProjectId]);
|
||||
|
||||
// 模态弹窗与侧栏
|
||||
const [editingBlock, setEditingBlock] = useState<DocumentBlock | null>(null);
|
||||
const [commentingBlock, setCommentingBlock] = useState<DocumentBlock | null>(null);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(null);
|
||||
const [pendingProposal, setPendingProposal] = useState<{
|
||||
@@ -3324,11 +3372,12 @@ export default function App() {
|
||||
// 块操作:先 PATCH,成功才提交 AST;失败保留编辑器并明确提示
|
||||
const handleSaveBlockEdit = async (updated: DocumentBlock) => {
|
||||
const children = docAST.rootBlock.children || [];
|
||||
const idx = children.findIndex((c) => c.id === updated.id);
|
||||
if (idx === -1) {
|
||||
setEditingBlock(null);
|
||||
const targetBlock = resolveSaveTargetBlock(children, updated);
|
||||
if (!targetBlock.ok) {
|
||||
setDocRevConflict(targetBlock.message);
|
||||
return;
|
||||
}
|
||||
const idx = targetBlock.index;
|
||||
const nextRoot = {
|
||||
...docAST.rootBlock,
|
||||
children: children.map((c, i) => (i === idx ? updated : { ...c })),
|
||||
@@ -3343,7 +3392,7 @@ export default function App() {
|
||||
target.governanceMode === 'freeform' &&
|
||||
!docAST.rootBlock?.children?.length
|
||||
) {
|
||||
setEditingBlock(null);
|
||||
closeBlockEditor();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -3368,19 +3417,17 @@ export default function App() {
|
||||
if (res.state?.etag) {
|
||||
aligned.revisionEtag = res.state.etag;
|
||||
}
|
||||
editorHydrationGateRef.current.markSaved();
|
||||
setDocAST(aligned);
|
||||
setDocCache((prev) => ({ ...prev, [activeDocId]: aligned }));
|
||||
setEditingBlock(null);
|
||||
closeBlockEditor();
|
||||
setDocRevConflict(null);
|
||||
setDocRevOverlay(res.manual_overlay || null);
|
||||
setDocRevRefreshKey((k) => k + 1);
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } };
|
||||
if (err.code === 'DOCUMENT_HEAD_CONFLICT') {
|
||||
setDocRevConflict('保存失败:文档已被他人更新。编辑器仍保留你的未保存修改,请刷新对照后重试。');
|
||||
} else {
|
||||
setDocRevConflict(`保存失败:${err.message || '请求未成功'}。编辑器仍保留当前修改,请重试。`);
|
||||
}
|
||||
const ui = interpretBlockSaveError(err);
|
||||
setDocRevConflict(ui.conflict);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4219,7 +4266,7 @@ export default function App() {
|
||||
<BlockRenderer
|
||||
ast={docAST}
|
||||
currentRole={currentDocRole}
|
||||
onEditBlock={setEditingBlock}
|
||||
onEditBlock={openBlockEditor}
|
||||
onInsertBlock={handleInsertBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
@@ -4278,7 +4325,7 @@ export default function App() {
|
||||
block={editingBlock}
|
||||
actorLabel={`${currentUser.name} (${currentUser.position || '成员'})`}
|
||||
onSave={handleSaveBlockEdit}
|
||||
onClose={() => setEditingBlock(null)}
|
||||
onClose={closeBlockEditor}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolvePatchEtag } from './revisionEtag';
|
||||
import {
|
||||
HEAD_CONFLICT_SAVE_MESSAGE,
|
||||
STALE_BLOCK_SAVE_MESSAGE,
|
||||
interpretBlockSaveError,
|
||||
nextRevisionEtagAfterPatch,
|
||||
resolveSaveTargetBlock,
|
||||
shouldCloseEditorAfterSave,
|
||||
} from './blockSavePolicy';
|
||||
|
||||
describe('resolveSaveTargetBlock', () => {
|
||||
it('keeps the editor and does not fake success when the block id is gone', () => {
|
||||
const decision = resolveSaveTargetBlock(
|
||||
[{ id: 'blk_rebuilt', content: '同一段正文' }],
|
||||
{ id: 'blk_from_modal', content: '同一段正文' },
|
||||
);
|
||||
assert.equal(decision.ok, false);
|
||||
if (decision.ok) return;
|
||||
assert.equal(decision.keepEditor, true);
|
||||
assert.equal(decision.reason, 'block_not_found');
|
||||
assert.equal(decision.message, STALE_BLOCK_SAVE_MESSAGE);
|
||||
assert.equal(shouldCloseEditorAfterSave('block_not_found'), false);
|
||||
});
|
||||
|
||||
it('never retargets another block that happens to share the same content', () => {
|
||||
const children = [
|
||||
{ id: 'blk_other', content: '条款甲' },
|
||||
{ id: 'blk_current', content: '条款甲' },
|
||||
];
|
||||
const stale = resolveSaveTargetBlock(children, { id: 'blk_missing', content: '条款甲' });
|
||||
assert.equal(stale.ok, false);
|
||||
const hit = resolveSaveTargetBlock(children, { id: 'blk_current', content: '条款甲(已改)' });
|
||||
assert.deepEqual(hit, { ok: true, index: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('block save PATCH / 409 policy', () => {
|
||||
it('successful save still uses the hydrate ETag baseline and adopts the response ETag', () => {
|
||||
const etag = resolvePatchEtag({
|
||||
revisionEtag: '1:0',
|
||||
governanceMode: 'controlled',
|
||||
});
|
||||
assert.equal(etag, '1:0');
|
||||
assert.equal(
|
||||
nextRevisionEtagAfterPatch({ previousEtag: '1:0', responseEtag: '1:1' }),
|
||||
'1:1',
|
||||
);
|
||||
assert.equal(shouldCloseEditorAfterSave('success'), true);
|
||||
});
|
||||
|
||||
it('409 keeps the draft in the editor', () => {
|
||||
const ui = interpretBlockSaveError({ code: 'DOCUMENT_HEAD_CONFLICT' });
|
||||
assert.equal(ui.keepEditor, true);
|
||||
assert.equal(ui.conflict, HEAD_CONFLICT_SAVE_MESSAGE);
|
||||
assert.equal(shouldCloseEditorAfterSave('conflict'), false);
|
||||
});
|
||||
|
||||
it('other PATCH failures also keep the editor', () => {
|
||||
const ui = interpretBlockSaveError({ message: 'network down' });
|
||||
assert.equal(ui.keepEditor, true);
|
||||
assert.match(ui.conflict, /network down/);
|
||||
assert.match(ui.conflict, /仍保留/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export const STALE_BLOCK_SAVE_MESSAGE =
|
||||
'保存失败:当前正文块身份已随后台刷新失效,编辑器仍保留你的修改。请勿关闭以免丢稿;刷新对照后重新打开该块再保存。';
|
||||
|
||||
export const HEAD_CONFLICT_SAVE_MESSAGE =
|
||||
'保存失败:文档已被他人更新。编辑器仍保留你的未保存修改,请刷新对照后重试。';
|
||||
|
||||
export type SaveTargetDecision =
|
||||
| { ok: true; index: number }
|
||||
| { ok: false; keepEditor: true; reason: 'block_not_found'; message: string };
|
||||
|
||||
/**
|
||||
* Resolve the PATCH target strictly by block id.
|
||||
* Matching another node by identical markdown would write the wrong slot.
|
||||
*/
|
||||
export function resolveSaveTargetBlock(
|
||||
children: ReadonlyArray<{ id: string; content?: string }>,
|
||||
updated: { id: string; content?: string },
|
||||
): SaveTargetDecision {
|
||||
const index = children.findIndex((child) => child.id === updated.id);
|
||||
if (index === -1) {
|
||||
return {
|
||||
ok: false,
|
||||
keepEditor: true,
|
||||
reason: 'block_not_found',
|
||||
message: STALE_BLOCK_SAVE_MESSAGE,
|
||||
};
|
||||
}
|
||||
return { ok: true, index };
|
||||
}
|
||||
|
||||
export function interpretBlockSaveError(err: { code?: string; message?: string }): {
|
||||
keepEditor: true;
|
||||
conflict: string;
|
||||
} {
|
||||
if (err.code === 'DOCUMENT_HEAD_CONFLICT') {
|
||||
return { keepEditor: true, conflict: HEAD_CONFLICT_SAVE_MESSAGE };
|
||||
}
|
||||
return {
|
||||
keepEditor: true,
|
||||
conflict: `保存失败:${err.message || '请求未成功'}。编辑器仍保留当前修改,请重试。`,
|
||||
};
|
||||
}
|
||||
|
||||
export function nextRevisionEtagAfterPatch(input: {
|
||||
previousEtag?: string;
|
||||
responseEtag?: string;
|
||||
}): string | undefined {
|
||||
return input.responseEtag || input.previousEtag;
|
||||
}
|
||||
|
||||
export function shouldCloseEditorAfterSave(
|
||||
kind: 'success' | 'conflict' | 'block_not_found',
|
||||
): boolean {
|
||||
return kind === 'success';
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
shouldLoadControlledContent,
|
||||
} from './controlledDocHydrate';
|
||||
import { resolvePatchEtag } from './revisionEtag';
|
||||
import { createEditorHydrationGate } from './editorHydrationGate';
|
||||
import type { DeliverableItem } from '../types/workbench';
|
||||
|
||||
const base: DeliverableItem = {
|
||||
@@ -89,6 +90,55 @@ describe('controlledDocHydrate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('aligns markdownToBlockAST random ids onto revision-state head_block_map', async () => {
|
||||
const markdown = '# 标题\n\n正文';
|
||||
const head_block_map = [
|
||||
{ id: 'srv_heading_intake', type: 'heading', start_offset: 0, end_offset: 4, order: 0 },
|
||||
{ id: 'srv_para_intake', type: 'paragraph', start_offset: 5, end_offset: 7, order: 1 },
|
||||
];
|
||||
const hydrate = () =>
|
||||
hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
||||
fetchContent: async () => ({
|
||||
doc_id: 'doc_proj56_intake',
|
||||
version: 1,
|
||||
content_sha256: 'abc',
|
||||
markdown,
|
||||
is_template_only: false,
|
||||
}),
|
||||
fetchRevisionState: async () => ({ etag: '1:0', head_block_map }),
|
||||
});
|
||||
const first = await hydrate();
|
||||
const second = await hydrate();
|
||||
assert.ok(first && second);
|
||||
const firstIds = first.rootBlock.children?.map((c) => c.id);
|
||||
const secondIds = second.rootBlock.children?.map((c) => c.id);
|
||||
assert.deepEqual(firstIds, ['srv_heading_intake', 'srv_para_intake']);
|
||||
assert.deepEqual(secondIds, firstIds);
|
||||
});
|
||||
|
||||
it('leaves Math.random ids when head_block_map is omitted (why gate + fail-closed remain)', async () => {
|
||||
const markdown = '# 标题\n\n正文';
|
||||
const hydrate = () =>
|
||||
hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
||||
fetchContent: async () => ({
|
||||
doc_id: 'doc_proj56_intake',
|
||||
version: 1,
|
||||
content_sha256: 'abc',
|
||||
markdown,
|
||||
is_template_only: false,
|
||||
}),
|
||||
fetchRevisionState: async () => ({ etag: '1:0' }),
|
||||
});
|
||||
const first = await hydrate();
|
||||
const second = await hydrate();
|
||||
assert.ok(first && second);
|
||||
const firstIds = first.rootBlock.children?.map((c) => c.id) ?? [];
|
||||
const secondIds = second.rootBlock.children?.map((c) => c.id) ?? [];
|
||||
assert.equal(firstIds.length, 2);
|
||||
assert.equal(secondIds.length, 2);
|
||||
assert.notDeepEqual(firstIds, secondIds);
|
||||
});
|
||||
|
||||
it('rejects controlled hydrate on NOT_FOUND or NEEDS_CONTENT', async () => {
|
||||
const missingLedger = async () => {
|
||||
const err = new Error('needs content') as Error & { code?: string };
|
||||
@@ -110,4 +160,46 @@ describe('controlledDocHydrate', () => {
|
||||
(err: Error & { code?: string }) => err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT',
|
||||
);
|
||||
});
|
||||
|
||||
it('aligned late hydrate still cannot overwrite a PATCH-success baseline', async () => {
|
||||
const markdown = '# 标题\n\n正文';
|
||||
const head_block_map = [
|
||||
{ id: 'srv_heading_intake', type: 'heading', start_offset: 0, end_offset: 4, order: 0 },
|
||||
{ id: 'srv_para_intake', type: 'paragraph', start_offset: 5, end_offset: 7, order: 1 },
|
||||
];
|
||||
let release!: (value: 'go') => void;
|
||||
const hold = new Promise<'go'>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const gate = createEditorHydrationGate();
|
||||
gate.setActiveDocId('doc_proj56_intake');
|
||||
const token = gate.beginHydration('doc_proj56_intake');
|
||||
assert.ok(token);
|
||||
|
||||
const pending = hydrateControlledDocumentContent('doc_proj56_intake', base, {
|
||||
fetchContent: async () => {
|
||||
await hold;
|
||||
return {
|
||||
doc_id: 'doc_proj56_intake',
|
||||
version: 1,
|
||||
content_sha256: 'abc',
|
||||
markdown,
|
||||
is_template_only: false,
|
||||
};
|
||||
},
|
||||
fetchRevisionState: async () => ({ etag: '1:0', head_block_map }),
|
||||
});
|
||||
|
||||
gate.setEditorOpen(true);
|
||||
gate.markSaved();
|
||||
gate.setEditorOpen(false);
|
||||
release('go');
|
||||
const ast = await pending;
|
||||
assert.ok(ast);
|
||||
assert.deepEqual(ast.rootBlock.children?.map((c) => c.id), [
|
||||
'srv_heading_intake',
|
||||
'srv_para_intake',
|
||||
]);
|
||||
assert.deepEqual(gate.decide(token), { apply: false, reason: 'superseded_by_save' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { fetchControlledDocumentContent } from '../api/projectDocuments';
|
||||
import { getRevisionState } from '../api/documentRevision';
|
||||
import { markdownToBlockAST } from '../engine/markdownToBlock';
|
||||
import { applyHeadBlockMap } from '../features/documentRevision/overlayMatch';
|
||||
import type { RevisionState } from '../features/documentRevision/types';
|
||||
import type { DocumentAST } from '../types/block';
|
||||
import type { DeliverableItem } from '../types/workbench';
|
||||
import { revisionEtagFromHydrate } from './revisionEtag';
|
||||
|
||||
/** Tests may stub a slice; production getRevisionState returns the full ledger. */
|
||||
export type HydrateRevisionState = Pick<RevisionState, 'etag'> &
|
||||
Partial<Pick<RevisionState, 'head_block_map'>>;
|
||||
|
||||
export function isControlledWorkflowDeliverable(doc: DeliverableItem): boolean {
|
||||
return (
|
||||
doc.isFixedWorkflow !== false &&
|
||||
@@ -104,7 +110,7 @@ export async function hydrateControlledDocumentContent(
|
||||
doc: DeliverableItem,
|
||||
deps?: {
|
||||
fetchContent?: typeof fetchControlledDocumentContent;
|
||||
fetchRevisionState?: (id: string) => Promise<{ etag: string }>;
|
||||
fetchRevisionState?: (id: string) => Promise<HydrateRevisionState>;
|
||||
},
|
||||
): Promise<DocumentAST | null> {
|
||||
const loadContent = deps?.fetchContent ?? fetchControlledDocumentContent;
|
||||
@@ -124,7 +130,12 @@ export async function hydrateControlledDocumentContent(
|
||||
if (!etag) {
|
||||
throw new Error('受控文档打开时未取得修订基线,请重试');
|
||||
}
|
||||
const ast = buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version);
|
||||
// markdownToBlockAST uses Math.random IDs; pin them to the ledger map so a
|
||||
// later hydrate of the same head does not invent a new identity.
|
||||
const ast = applyHeadBlockMap(
|
||||
buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version),
|
||||
rev.st.head_block_map,
|
||||
);
|
||||
ast.revisionEtag = etag;
|
||||
ast.dutiesAuthoritative = doc.dutiesAuthoritative ?? true;
|
||||
return ast;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createEditorHydrationGate,
|
||||
decideHydrationApply,
|
||||
} from './editorHydrationGate';
|
||||
|
||||
describe('decideHydrationApply', () => {
|
||||
const token = { gen: 2, saveEpoch: 0, docId: 'doc_a' };
|
||||
|
||||
it('applies only when gen, doc, save epoch match and editor is closed', () => {
|
||||
assert.deepEqual(
|
||||
decideHydrationApply({
|
||||
token,
|
||||
currentGen: 2,
|
||||
currentSaveEpoch: 0,
|
||||
activeDocId: 'doc_a',
|
||||
editorOpen: false,
|
||||
}),
|
||||
{ apply: true, reason: 'ok' },
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a response for another document', () => {
|
||||
assert.deepEqual(
|
||||
decideHydrationApply({
|
||||
token,
|
||||
currentGen: 2,
|
||||
currentSaveEpoch: 0,
|
||||
activeDocId: 'doc_b',
|
||||
editorOpen: false,
|
||||
}),
|
||||
{ apply: false, reason: 'doc_mismatch' },
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a superseded generation', () => {
|
||||
assert.deepEqual(
|
||||
decideHydrationApply({
|
||||
token,
|
||||
currentGen: 3,
|
||||
currentSaveEpoch: 0,
|
||||
activeDocId: 'doc_a',
|
||||
editorOpen: false,
|
||||
}),
|
||||
{ apply: false, reason: 'stale_generation' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEditorHydrationGate', () => {
|
||||
it('does not start a new hydrate while the editor is open (UI-06 fill-window GET)', () => {
|
||||
const gate = createEditorHydrationGate();
|
||||
gate.setActiveDocId('doc_proj6140_intake');
|
||||
const before = gate.beginHydration('doc_proj6140_intake');
|
||||
assert.ok(before);
|
||||
gate.setEditorOpen(true);
|
||||
assert.equal(gate.beginHydration('doc_proj6140_intake'), null);
|
||||
assert.equal(gate.decide(before).reason, 'editor_open');
|
||||
assert.equal(gate.decide(before).apply, false);
|
||||
});
|
||||
|
||||
it('deferred hydrate that resolves after the editor opens does not replace the session', async () => {
|
||||
const gate = createEditorHydrationGate();
|
||||
gate.setActiveDocId('doc_proj6140_intake');
|
||||
const token = gate.beginHydration('doc_proj6140_intake');
|
||||
assert.ok(token);
|
||||
|
||||
let resolveHydrate!: (ast: { blockId: string }) => void;
|
||||
const pending = new Promise<{ blockId: string }>((resolve) => {
|
||||
resolveHydrate = resolve;
|
||||
});
|
||||
|
||||
gate.setEditorOpen(true);
|
||||
resolveHydrate({ blockId: 'blk_rebuilt_during_fill' });
|
||||
const rebuilt = await pending;
|
||||
const decision = gate.decide(token);
|
||||
|
||||
assert.equal(rebuilt.blockId, 'blk_rebuilt_during_fill');
|
||||
assert.equal(decision.apply, false);
|
||||
assert.equal(decision.reason, 'editor_open');
|
||||
});
|
||||
|
||||
it('covers hydrate resolving between fill and save', async () => {
|
||||
const gate = createEditorHydrationGate();
|
||||
gate.setActiveDocId('doc_proj6140_intake');
|
||||
const token = gate.beginHydration('doc_proj6140_intake');
|
||||
assert.ok(token);
|
||||
|
||||
gate.setEditorOpen(true);
|
||||
|
||||
let resolveDuringFill!: (value: 'hydrated') => void;
|
||||
const hydrateDuringFill = new Promise<'hydrated'>((resolve) => {
|
||||
resolveDuringFill = resolve;
|
||||
});
|
||||
|
||||
resolveDuringFill('hydrated');
|
||||
await hydrateDuringFill;
|
||||
assert.equal(gate.decide(token).reason, 'editor_open');
|
||||
|
||||
gate.markSaved();
|
||||
gate.setEditorOpen(false);
|
||||
assert.deepEqual(gate.decide(token), { apply: false, reason: 'superseded_by_save' });
|
||||
});
|
||||
|
||||
it('late hydrate after a successful PATCH does not roll back the new baseline', () => {
|
||||
const gate = createEditorHydrationGate();
|
||||
gate.setActiveDocId('doc_a');
|
||||
const stale = gate.beginHydration('doc_a');
|
||||
assert.ok(stale);
|
||||
gate.setEditorOpen(true);
|
||||
gate.markSaved();
|
||||
gate.setEditorOpen(false);
|
||||
assert.deepEqual(gate.decide(stale), { apply: false, reason: 'superseded_by_save' });
|
||||
|
||||
const fresh = gate.beginHydration('doc_a');
|
||||
assert.ok(fresh);
|
||||
assert.deepEqual(gate.decide(fresh), { apply: true, reason: 'ok' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
export type HydrationApplyReason =
|
||||
| 'ok'
|
||||
| 'editor_open'
|
||||
| 'stale_generation'
|
||||
| 'doc_mismatch'
|
||||
| 'superseded_by_save';
|
||||
|
||||
export type HydrationToken = {
|
||||
gen: number;
|
||||
saveEpoch: number;
|
||||
docId: string;
|
||||
};
|
||||
|
||||
export type HydrationApplyDecision = {
|
||||
apply: boolean;
|
||||
reason: HydrationApplyReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether an in-flight controlled-doc hydration may replace the AST.
|
||||
*
|
||||
* Even after hydrate aligns IDs via revision-state head_block_map, a stale
|
||||
* GET can still rewrite markdown/ETag after PATCH. Applying a response that
|
||||
* started before the editor opened or before a successful save would drop
|
||||
* the user's draft or roll back the new baseline.
|
||||
*/
|
||||
export function decideHydrationApply(input: {
|
||||
token: HydrationToken;
|
||||
currentGen: number;
|
||||
currentSaveEpoch: number;
|
||||
activeDocId: string;
|
||||
editorOpen: boolean;
|
||||
}): HydrationApplyDecision {
|
||||
if (input.token.docId !== input.activeDocId) {
|
||||
return { apply: false, reason: 'doc_mismatch' };
|
||||
}
|
||||
if (input.token.gen !== input.currentGen) {
|
||||
return { apply: false, reason: 'stale_generation' };
|
||||
}
|
||||
if (input.editorOpen) {
|
||||
return { apply: false, reason: 'editor_open' };
|
||||
}
|
||||
if (input.token.saveEpoch !== input.currentSaveEpoch) {
|
||||
return { apply: false, reason: 'superseded_by_save' };
|
||||
}
|
||||
return { apply: true, reason: 'ok' };
|
||||
}
|
||||
|
||||
export type EditorHydrationGate = {
|
||||
setEditorOpen: (open: boolean) => void;
|
||||
isEditorOpen: () => boolean;
|
||||
setActiveDocId: (id: string) => void;
|
||||
beginHydration: (docId: string) => HydrationToken | null;
|
||||
markSaved: () => void;
|
||||
decide: (token: HydrationToken) => HydrationApplyDecision;
|
||||
};
|
||||
|
||||
export function createEditorHydrationGate(): EditorHydrationGate {
|
||||
let hydrationGen = 0;
|
||||
let saveEpoch = 0;
|
||||
let editorOpen = false;
|
||||
let activeDocId = '';
|
||||
|
||||
return {
|
||||
setEditorOpen(open) {
|
||||
editorOpen = open;
|
||||
},
|
||||
isEditorOpen() {
|
||||
return editorOpen;
|
||||
},
|
||||
setActiveDocId(id) {
|
||||
activeDocId = id;
|
||||
},
|
||||
beginHydration(docId) {
|
||||
if (editorOpen) return null;
|
||||
hydrationGen += 1;
|
||||
return { gen: hydrationGen, saveEpoch, docId };
|
||||
},
|
||||
markSaved() {
|
||||
saveEpoch += 1;
|
||||
},
|
||||
decide(token) {
|
||||
return decideHydrationApply({
|
||||
token,
|
||||
currentGen: hydrationGen,
|
||||
currentSaveEpoch: saveEpoch,
|
||||
activeDocId,
|
||||
editorOpen,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user