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>
117 lines
3.1 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|