refactor(web): hydrate controlled documents from one hook

Keep the editor gate, cancel, and error skeleton together so App no longer owns the fetch path.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 11:29:40 +00:00
co-authored by Cursor
parent 27c1be86d5
commit 113dea4ac5
4 changed files with 235 additions and 138 deletions
@@ -46,7 +46,7 @@ updated: 2026-09-19
| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` |
| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 |
| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 |
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。工作台主体仍在 `App.tsx` |
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存写入在 `useWorkbenchPersistence`。登录后的项目/成员/会话同步在 `useAuthenticatedWorkspaceSync`。受控文档补水与重试在 `useControlledDocumentHydration`。工作台主体仍在 `App.tsx` |
| H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 |
仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
+18 -137
View File
@@ -75,12 +75,10 @@ import {
isBlockedGovernanceStatus,
} from './lib/blockedGovernanceCopy';
import {
buildControlledLoadErrorSkeleton,
buildControlledLoadingSkeleton,
canonicalDocIdForTask,
controlledHydrateFingerprint,
hydrateControlledDocumentContent,
resolveCanonicalDocId,
shouldLoadControlledContent,
} from './lib/controlledDocHydrate';
import {
@@ -90,10 +88,8 @@ import {
type ControlledInvalidationPlan,
} from './lib/controlledContentInvalidation';
import { applyLeaseUpdates, sameLease } from './lib/deliverableLeaseSync';
import { ControlledContentRequestError } from './api/projectDocuments';
import {
createEditorHydrationGate,
type HydrationToken,
} from './lib/editorHydrationGate';
import {
decideActiveDocSwitch,
@@ -199,6 +195,7 @@ import {
} from './data/storage';
import { useChatWindow } from './hooks/useChatWindow';
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
import { useWorkbenchPersistence } from './hooks/useWorkbenchPersistence';
import { useTurnRuntime } from './hooks/useTurnRuntime';
import { useTaskRunStream } from './hooks/useTaskRunStream';
@@ -805,139 +802,23 @@ export default function App() {
});
}, [docCache, activeDocId, deliverables, projects, visibleDeliverables, controlledContentReady, currentUser]);
// legacy doc-p{projectId}-{taskKey} → 服务端 doc_id 映射
useEffect(() => {
const canonicalId = resolveCanonicalDocId(activeDocId, deliverables);
if (canonicalId === activeDocId) return;
setActiveDocId(canonicalId);
setDocCache((prev) => {
if (!prev[activeDocId]) return prev;
const next = { ...prev };
delete next[activeDocId];
return next;
});
}, [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 }));
setDocLoadErrorInfo((prev) => {
if (!prev[docId]) return prev;
const next = { ...prev };
delete next[docId];
return next;
});
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) => {
const next = { ...prev };
delete next[activeDocId];
return next;
});
void hydrateControlledDocumentContent(activeDocId, target)
.then((ast) => {
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 }));
setDocLoadErrorInfo((prev) => ({
...prev,
[activeDocId]: {
code: err instanceof ControlledContentRequestError ? err.code : undefined,
message: err instanceof Error ? err.message : String(err),
correlationId:
err instanceof ControlledContentRequestError ? err.correlationId : undefined,
},
}));
setDocCache((prev) => ({
...prev,
[activeDocId]: buildControlledLoadErrorSkeleton(target),
}));
});
return () => {
cancelled = true;
};
}, [session, sessionValidated, activeDocId, activeHydrateFingerprint, applyHydratedDocument, hydrateResumeKey]);
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 };
delete next[activeDocId];
return next;
});
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: false }));
setDocCache((prev) => ({
...prev,
[activeDocId]: buildControlledLoadingSkeleton(target),
}));
void hydrateControlledDocumentContent(activeDocId, target)
.then((ast) => {
applyHydratedDocument(token, ast, activeDocId);
})
.catch((err) => {
console.warn('重试拉取受控文档正文失败:', err);
if (!editorHydrationGateRef.current.decide(token).apply) return;
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true }));
setDocLoadErrorInfo((prev) => ({
...prev,
[activeDocId]: {
code: err instanceof ControlledContentRequestError ? err.code : undefined,
message: err instanceof Error ? err.message : String(err),
correlationId:
err instanceof ControlledContentRequestError ? err.correlationId : undefined,
},
}));
setDocCache((prev) => ({
...prev,
[activeDocId]: buildControlledLoadErrorSkeleton(target),
}));
});
}, [activeDocId, deliverables, applyHydratedDocument]);
// 自愈后自动回写入缓存,保障后续操作直接命中
useEffect(() => {
if (!docAST || docCache[docAST.docId]) return;
const target = deliverables.find((d) => d.docId === docAST.docId);
if (target && shouldLoadControlledContent(target)) return;
setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST }));
}, [docAST, docCache, deliverables]);
const { applyHydratedDocument, retryControlledDocLoad } = useControlledDocumentHydration({
session,
sessionValidated,
activeDocId,
setActiveDocId,
deliverables,
docCache,
setDocCache,
setControlledContentReady,
setDocLoadErrors,
setDocLoadErrorInfo,
setDocRevRefreshKey,
editorHydrationGateRef,
activeHydrateFingerprint,
hydrateResumeKey,
docAST,
});
// 根据权威 user id 计算当前文档职责;未知身份 fail-closed 为 viewer
const currentDocRole: UserRole = resolveCurrentDocRole({
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('controlled document hydration lives in one hook', () => {
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
const hook = readFileSync(new URL('./useControlledDocumentHydration.ts', import.meta.url), 'utf8');
assert.equal((app.match(/useControlledDocumentHydration\(/g) || []).length, 1);
assert.equal(app.includes('自动拉取受控文档正文失败'), false);
assert.equal(app.includes('重试拉取受控文档正文失败'), false);
assert.equal(hook.includes('beginHydration'), true);
assert.equal(hook.includes('hydrateControlledDocumentContent'), true);
assert.equal(hook.includes('decide(token).apply'), true);
});
@@ -0,0 +1,202 @@
import { useCallback, useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { ControlledContentRequestError } from '../api/projectDocuments';
import {
buildControlledLoadErrorSkeleton,
buildControlledLoadingSkeleton,
hydrateControlledDocumentContent,
resolveCanonicalDocId,
shouldLoadControlledContent,
} from '../lib/controlledDocHydrate';
import {
createEditorHydrationGate,
type HydrationToken,
} from '../lib/editorHydrationGate';
import type { LoginResult } from '../types/auth';
import type { DocumentAST } from '../types/block';
import type { DeliverableItem } from '../types/workbench';
export type DocLoadErrorInfo = { code?: string; message: string; correlationId?: string };
type Gate = ReturnType<typeof createEditorHydrationGate>;
export interface ControlledDocumentHydrationInput {
session: LoginResult | null;
sessionValidated: boolean | null;
activeDocId: string;
setActiveDocId: Dispatch<SetStateAction<string>>;
deliverables: DeliverableItem[];
docCache: Record<string, DocumentAST>;
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
setControlledContentReady: Dispatch<SetStateAction<Record<string, boolean>>>;
setDocLoadErrors: Dispatch<SetStateAction<Record<string, boolean>>>;
setDocLoadErrorInfo: Dispatch<SetStateAction<Record<string, DocLoadErrorInfo>>>;
setDocRevRefreshKey: Dispatch<SetStateAction<number>>;
editorHydrationGateRef: MutableRefObject<Gate>;
activeHydrateFingerprint: string;
hydrateResumeKey: number;
docAST: DocumentAST;
}
export interface ControlledDocumentHydration {
applyHydratedDocument: (
token: HydrationToken,
ast: DocumentAST | null | undefined,
docId: string,
opts?: { refreshToolbar?: boolean },
) => boolean;
retryControlledDocLoad: () => void;
}
function loadErrorInfo(err: unknown): DocLoadErrorInfo {
return {
code: err instanceof ControlledContentRequestError ? err.code : undefined,
message: err instanceof Error ? err.message : String(err),
correlationId: err instanceof ControlledContentRequestError ? err.correlationId : undefined,
};
}
export function useControlledDocumentHydration(
input: ControlledDocumentHydrationInput,
): ControlledDocumentHydration {
const {
session,
sessionValidated,
activeDocId,
setActiveDocId,
deliverables,
docCache,
setDocCache,
setControlledContentReady,
setDocLoadErrors,
setDocLoadErrorInfo,
setDocRevRefreshKey,
editorHydrationGateRef,
activeHydrateFingerprint,
hydrateResumeKey,
docAST,
} = input;
useEffect(() => {
const canonicalId = resolveCanonicalDocId(activeDocId, deliverables);
if (canonicalId === activeDocId) return;
setActiveDocId(canonicalId);
setDocCache((prev) => {
if (!prev[activeDocId]) return prev;
const next = { ...prev };
delete next[activeDocId];
return next;
});
}, [deliverables, activeDocId, setActiveDocId, setDocCache]);
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 }));
setDocLoadErrorInfo((prev) => {
if (!prev[docId]) return prev;
const next = { ...prev };
delete next[docId];
return next;
});
if (opts?.refreshToolbar !== false) {
setDocRevRefreshKey((k) => k + 1);
}
return true;
},
[editorHydrationGateRef, setControlledContentReady, setDocCache, setDocLoadErrorInfo, setDocLoadErrors, setDocRevRefreshKey],
);
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) => {
const next = { ...prev };
delete next[activeDocId];
return next;
});
void hydrateControlledDocumentContent(activeDocId, target)
.then((ast) => {
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 }));
setDocLoadErrorInfo((prev) => ({ ...prev, [activeDocId]: loadErrorInfo(err) }));
setDocCache((prev) => ({
...prev,
[activeDocId]: buildControlledLoadErrorSkeleton(target),
}));
});
return () => {
cancelled = true;
};
}, [session, sessionValidated, activeDocId, activeHydrateFingerprint, applyHydratedDocument, hydrateResumeKey]);
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 };
delete next[activeDocId];
return next;
});
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: false }));
setDocCache((prev) => ({
...prev,
[activeDocId]: buildControlledLoadingSkeleton(target),
}));
void hydrateControlledDocumentContent(activeDocId, target)
.then((ast) => {
applyHydratedDocument(token, ast, activeDocId);
})
.catch((err) => {
console.warn('重试拉取受控文档正文失败:', err);
if (!editorHydrationGateRef.current.decide(token).apply) return;
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: true }));
setDocLoadErrorInfo((prev) => ({ ...prev, [activeDocId]: loadErrorInfo(err) }));
setDocCache((prev) => ({
...prev,
[activeDocId]: buildControlledLoadErrorSkeleton(target),
}));
});
}, [
activeDocId,
deliverables,
applyHydratedDocument,
editorHydrationGateRef,
setControlledContentReady,
setDocCache,
setDocLoadErrorInfo,
setDocLoadErrors,
]);
useEffect(() => {
if (!docAST || docCache[docAST.docId]) return;
const target = deliverables.find((d) => d.docId === docAST.docId);
if (target && shouldLoadControlledContent(target)) return;
setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST }));
}, [docAST, docCache, deliverables, setDocCache]);
return { applyHydratedDocument, retryControlledDocLoad };
}