Serialize live-preview renders to prevent concurrent embeds (M3)

This commit is contained in:
2026-06-07 23:08:56 +03:00
parent 5ea2a0d038
commit 1d7798abc5
2 changed files with 188 additions and 56 deletions
+99 -2
View File
@@ -15,9 +15,35 @@ import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { LivePreview } from './LivePreview';
// Heavy services and sub-components the LivePreview wires — not under test here.
// Controllable renderSpec: each call gets an id and parks a resolver in `H.pending`
// so a test can decide exactly when (and in what order) embeds settle — the only
// way to exercise the overlapping-render serialization deterministically. `destroy`
// records its id and clears the node, mirroring the real handle (which calls
// node.replaceChildren()), so a wrongful blank would be observable.
const H = vi.hoisted(() => ({
calls: 0,
pending: [] as Array<() => void>,
destroyed: [] as number[],
}));
vi.mock('../services/chart-renderer', () => ({
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
renderSpec: (node: HTMLElement) => {
const id = ++H.calls;
return new Promise((resolve) => {
H.pending.push(() => {
node.replaceChildren(); // a real embed wipes then rebuilds the host
const mark = document.createElement('div');
mark.dataset.render = String(id);
node.appendChild(mark);
resolve({
destroy() {
node.replaceChildren();
H.destroyed.push(id);
},
resize() {},
});
});
});
},
}));
vi.mock('./SettingsPopover', () => ({
SettingsPopover: () => null,
@@ -31,6 +57,9 @@ let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
H.calls = 0;
H.pending.length = 0;
H.destroyed.length = 0;
usePreviewStore.setState({ error: null, busy: false });
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
@@ -85,3 +114,71 @@ describe('LivePreview busy overlay', () => {
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
});
});
describe('LivePreview render serialization', () => {
/** Drive the debounced render effect to completion under fake timers. */
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
test('a second render waits for the in-flight one, which is finalized when superseded', async () => {
vi.useFakeTimers();
try {
// Render A: valid, ref-less spec so prepareSpecForRender passes and we reach
// renderSpec (which now parks, awaiting our resolver).
act(() => {
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
});
await tick();
expect(H.calls).toBe(1); // A invoked renderSpec and is in flight
// Render B arrives while A is still embedding. It must NOT start a second
// embed on the shared node — it queues behind A's lock.
act(() => {
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"bar"}' });
});
await tick();
expect(H.calls).toBe(1); // still only A's embed; B is serialized behind it
// A settles. It was superseded by B, so its handle is finalized (destroy),
// and only now does B acquire the lock and start its embed.
act(() => H.pending[0]());
await tick(0);
expect(H.destroyed).toContain(1); // superseded A finalized
expect(H.calls).toBe(2); // B now embeds
// B settles and becomes the live chart — never destroyed, and its node
// content survives (A's finalize ran before B drew, so nothing blanked it).
act(() => H.pending[1]());
await tick(0);
expect(H.destroyed).not.toContain(2);
expect(container.querySelector('[data-render="2"]')).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
test('clearing the editor invalidates a pending render (no stale redraw)', async () => {
vi.useFakeTimers();
try {
act(() => {
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
});
await tick();
expect(H.calls).toBe(1); // render A in flight
// Editor cleared while A embeds. The clear advances the generation token, so
// when A finally resolves it knows it is stale and finalizes instead of
// committing — the pane ends up empty, not showing A's chart.
act(() => {
useSnippetStore.setState({ draftText: '' });
});
await tick();
act(() => H.pending[0]());
await tick(0);
expect(H.destroyed).toContain(1); // stale A finalized, not committed
expect(container.querySelector('[data-render="1"]')).toBeNull();
} finally {
vi.useRealTimers();
}
});
});