Files
Agent-System/web/src/hooks/useControlledDocumentHydration.ts
T
weichenandCursor 113dea4ac5 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>
2026-09-19 11:29:40 +00:00

203 lines
6.9 KiB
TypeScript

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 };
}