mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Serialize live-preview renders to prevent concurrent embeds (M3)
This commit is contained in:
@@ -92,6 +92,10 @@ export function LivePreview() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const handleRef = useRef<RenderHandle | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
// Serializes node mutations across overlapping renders: each render chains onto
|
||||
// the previous one's promise so only one vega-embed ever touches the shared host
|
||||
// node at a time (see the render effect for why this matters).
|
||||
const renderChainRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const shownText = useSnippetStore(selectShownText);
|
||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
@@ -133,32 +137,23 @@ export function LivePreview() {
|
||||
// returns void (it handles its own errors internally — nothing awaits it).
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
// Empty/blank is not an error — clean, empty pane (spec §04).
|
||||
if (text === '') {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
setError(`Invalid JSON: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Every render — including the empty/clear path — advances the generation
|
||||
// token, so a slow in-flight render that resolves later knows it's stale.
|
||||
// (Clearing must invalidate a pending render too, or it would redraw the
|
||||
// chart the user just cleared.)
|
||||
const mine = ++generationRef.current;
|
||||
const isEmpty = text === '';
|
||||
|
||||
// Arm the busy indicator: if the render hasn't settled within ~1s, flip the
|
||||
// flag. Sub-1s renders (the common case) never show the overlay — no flicker
|
||||
// (arch §10.2; NN/g ≤1s = uninterrupted thought, >1s = noticeably waiting).
|
||||
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||
busyTimerRef.current = setTimeout(() => {
|
||||
// Only arm for the current generation; a superseded render doesn't own busy.
|
||||
if (mine === generationRef.current) setBusy(true);
|
||||
}, 1000);
|
||||
// Parse before touching the node so a syntax error leaves the chart up.
|
||||
let parsed: unknown;
|
||||
if (!isEmpty) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) setError(`Invalid JSON: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear busy and the timer unconditionally — called on settle or error. */
|
||||
const clearBusy = () => {
|
||||
@@ -169,43 +164,83 @@ export function LivePreview() {
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
// Arm the busy indicator: if a (non-empty) render hasn't settled within ~1s,
|
||||
// flip the flag. Sub-1s renders (the common case) never show the overlay — no
|
||||
// flicker (arch §10.2; NN/g ≤1s = uninterrupted thought, >1s = noticeably waiting).
|
||||
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||
if (!isEmpty) {
|
||||
busyTimerRef.current = setTimeout(() => {
|
||||
// Only arm for the current generation; a superseded render doesn't own busy.
|
||||
if (mine === generationRef.current) setBusy(true);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Serialize node mutations: wait for any in-flight render to settle before
|
||||
// we touch the shared host node. Two concurrent vega-embed calls on one node
|
||||
// interleave, and a superseded render's destroy() (which clears the node)
|
||||
// could blank the chart a newer render is showing. Holding this lock makes
|
||||
// both impossible. After the wait, bail if a newer render already superseded
|
||||
// us — no wasted embed, and the clear/destroy below only runs while we hold
|
||||
// the lock, so it can never wipe a chart another render is currently showing.
|
||||
const prior = renderChainRef.current;
|
||||
let release!: () => void;
|
||||
renderChainRef.current = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
try {
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
||||
const config = chartConfigFor(uiTheme);
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
const handle = await renderSpec(node, prepared as VisualizationSpec, config);
|
||||
await prior;
|
||||
if (mine !== generationRef.current) {
|
||||
// TODO: a superseded render's destroy() calls node.replaceChildren(),
|
||||
// which can blank the live chart if two embeds on the same node are
|
||||
// ever in flight at once (heavy spec whose embed outlasts the 300ms
|
||||
// debounce). The debounce makes this rare; when dataset work (M3)
|
||||
// lands, serialize renders or finalize the stale view without
|
||||
// clearing the shared node.
|
||||
handle.destroy(); // a newer render superseded this one
|
||||
clearBusy();
|
||||
return;
|
||||
}
|
||||
handleRef.current = handle;
|
||||
setError(null);
|
||||
clearBusy();
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) {
|
||||
|
||||
// Empty/blank is not an error — clean, empty pane (spec §04).
|
||||
if (isEmpty) {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setError(null);
|
||||
clearBusy();
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||
if (e instanceof DatasetNotFoundError) {
|
||||
setError(
|
||||
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
|
||||
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
||||
const config = chartConfigFor(uiTheme);
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
const handle = await renderSpec(node, prepared as VisualizationSpec, config);
|
||||
if (mine !== generationRef.current) {
|
||||
// A newer render superseded this one. We still hold the lock, so the
|
||||
// newer render hasn't drawn yet (it's queued behind us) — finalizing
|
||||
// this handle and clearing the node is safe; the newer render redraws
|
||||
// on a clean node when it acquires the lock.
|
||||
handle.destroy();
|
||||
return;
|
||||
}
|
||||
handleRef.current = handle;
|
||||
setError(null);
|
||||
clearBusy();
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) {
|
||||
clearBusy();
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||
if (e instanceof DatasetNotFoundError) {
|
||||
setError(
|
||||
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
|
||||
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
})();
|
||||
}, delay);
|
||||
|
||||
Reference in New Issue
Block a user