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:
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user