Files
Agent-System/web/src/lib/editorHydrationGate.ts
T
weichenandCursor 08bf615f95 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>
2026-09-14 03:33:37 +00:00

117 lines
3.1 KiB
TypeScript

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;
/**
* True when a hydrate was skipped or discarded because the editor was open.
* Consuming the flag lets the caller re-run hydrate after a safe close,
* so a first-load skeleton is not left forever (effect deps may not change).
*/
takeHydrationResume: () => boolean;
/** 列表/任务同步在编辑中保护了活动文档时,关闭编辑器后补一次 hydrate。 */
requestHydrationResume: () => void;
};
export function createEditorHydrationGate(): EditorHydrationGate {
let hydrationGen = 0;
let saveEpoch = 0;
let editorOpen = false;
let activeDocId = '';
let blockedNeedsResume = false;
return {
setEditorOpen(open) {
editorOpen = open;
},
isEditorOpen() {
return editorOpen;
},
setActiveDocId(id) {
activeDocId = id;
},
beginHydration(docId) {
if (editorOpen) {
blockedNeedsResume = true;
return null;
}
hydrationGen += 1;
return { gen: hydrationGen, saveEpoch, docId };
},
markSaved() {
saveEpoch += 1;
},
decide(token) {
const decision = decideHydrationApply({
token,
currentGen: hydrationGen,
currentSaveEpoch: saveEpoch,
activeDocId,
editorOpen,
});
if (decision.reason === 'editor_open') {
blockedNeedsResume = true;
}
return decision;
},
takeHydrationResume() {
if (!blockedNeedsResume) return false;
blockedNeedsResume = false;
return true;
},
requestHydrationResume() {
blockedNeedsResume = true;
},
};
}