mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add live-preview busy indicator for slow renders (M6, §04/§10)
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
|
position: relative; /* establishes stacking context for the busy overlay */
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -88,3 +89,46 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Busy overlay — non-blocking; floats over the chart body only (arch §10.2;
|
||||||
|
* spec §04/§10). position:absolute relative to .body's position:relative.
|
||||||
|
* pointer-events:none ensures the editor and other controls stay fully
|
||||||
|
* interactive while the overlay is visible.
|
||||||
|
*
|
||||||
|
* The spinner animation is a pure CSS transform-based rotation so it respects
|
||||||
|
* the global prefers-reduced-motion rule in base.css (animation-duration → 0.01ms).
|
||||||
|
*/
|
||||||
|
.busyOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
background: color-mix(in srgb, var(--bg) 75%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busySpinner {
|
||||||
|
display: block;
|
||||||
|
width: var(--icon-lg);
|
||||||
|
height: var(--icon-lg);
|
||||||
|
border: 2px solid var(--border-strong);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: busySpin 600ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busyLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes busySpin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* LivePreview — busy overlay guard (spec §04; arch §10.2).
|
||||||
|
*
|
||||||
|
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco); the
|
||||||
|
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests
|
||||||
|
* set that flag directly and assert the DOM result — no timing, no mocking of the
|
||||||
|
* async render path.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { usePreviewStore } from '../stores/PreviewStore';
|
||||||
|
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.
|
||||||
|
vi.mock('../services/chart-renderer', () => ({
|
||||||
|
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
|
||||||
|
}));
|
||||||
|
vi.mock('./SettingsPopover', () => ({
|
||||||
|
SettingsPopover: () => null,
|
||||||
|
SettingRow: () => null,
|
||||||
|
RangeControl: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
usePreviewStore.setState({ error: null, busy: false });
|
||||||
|
useSnippetStore.getState().reset();
|
||||||
|
useDatasetStore.getState().reset();
|
||||||
|
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
act(() => root.render(<LivePreview />));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
usePreviewStore.setState({ error: null, busy: false });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LivePreview busy overlay', () => {
|
||||||
|
test('does not render the busy overlay when busy=false', () => {
|
||||||
|
// The overlay element should not be in the DOM at all during normal operation.
|
||||||
|
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the busy overlay when PreviewStore.busy=true', () => {
|
||||||
|
act(() => usePreviewStore.setState({ busy: true }));
|
||||||
|
const overlay = container.querySelector('[aria-hidden="true"]');
|
||||||
|
expect(overlay).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay carries a visible label for sighted users', () => {
|
||||||
|
act(() => usePreviewStore.setState({ busy: true }));
|
||||||
|
const label = container.querySelector('[aria-hidden="true"]')?.textContent;
|
||||||
|
expect(label).toMatch(/rendering/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the preview body carries aria-busy=true when busy', () => {
|
||||||
|
act(() => usePreviewStore.setState({ busy: true }));
|
||||||
|
// The body element has aria-busy when the store says busy.
|
||||||
|
const busyEl = container.querySelector('[aria-busy="true"]');
|
||||||
|
expect(busyEl).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aria-busy is absent when busy=false (no aria-busy="false" noise)', () => {
|
||||||
|
// aria-busy="false" is technically valid but needlessly verbose; we omit it.
|
||||||
|
expect(container.querySelector('[aria-busy]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlay disappears when busy returns to false', () => {
|
||||||
|
act(() => usePreviewStore.setState({ busy: true }));
|
||||||
|
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
|
||||||
|
act(() => usePreviewStore.setState({ busy: false }));
|
||||||
|
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -110,11 +110,14 @@ export function LivePreview() {
|
|||||||
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
||||||
const error = usePreviewStore((s) => s.error);
|
const error = usePreviewStore((s) => s.error);
|
||||||
const setError = usePreviewStore((s) => s.setError);
|
const setError = usePreviewStore((s) => s.setError);
|
||||||
|
const busy = usePreviewStore((s) => s.busy);
|
||||||
|
const setBusy = usePreviewStore((s) => s.setBusy);
|
||||||
|
|
||||||
|
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
||||||
|
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
||||||
|
// avoid flicker on typical fast renders).
|
||||||
|
const busyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// TODO (council backfill, doc §10.2): a render that exceeds ~1s owes a
|
|
||||||
// non-blocking busy indication (overlay + aria-busy), gated by a threshold so
|
|
||||||
// sub-1s renders show nothing. Deferred — it pairs with the heavier M3 dataset
|
|
||||||
// renders the budget flags; today's inline-data renders are effectively instant.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = hostRef.current;
|
const node = hostRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -147,6 +150,25 @@ export function LivePreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mine = ++generationRef.current;
|
const mine = ++generationRef.current;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
/** Clear busy and the timer unconditionally — called on settle or error. */
|
||||||
|
const clearBusy = () => {
|
||||||
|
if (busyTimerRef.current !== null) {
|
||||||
|
clearTimeout(busyTimerRef.current);
|
||||||
|
busyTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
||||||
const config = chartConfigFor(uiTheme);
|
const config = chartConfigFor(uiTheme);
|
||||||
@@ -165,8 +187,10 @@ export function LivePreview() {
|
|||||||
}
|
}
|
||||||
handleRef.current = handle;
|
handleRef.current = handle;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
clearBusy();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mine === generationRef.current) {
|
if (mine === generationRef.current) {
|
||||||
|
clearBusy();
|
||||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||||
// tailored, fixable message instead of the generic syntax hint (council:
|
// tailored, fixable message instead of the generic syntax hint (council:
|
||||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||||
@@ -187,7 +211,17 @@ export function LivePreview() {
|
|||||||
}, delay);
|
}, delay);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView, renderDebounce]);
|
}, [
|
||||||
|
shownText,
|
||||||
|
fitMode,
|
||||||
|
uiTheme,
|
||||||
|
datasets,
|
||||||
|
setError,
|
||||||
|
setBusy,
|
||||||
|
bufferEpoch,
|
||||||
|
editorView,
|
||||||
|
renderDebounce,
|
||||||
|
]);
|
||||||
|
|
||||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||||
// observe the element, so we do: one observer on the stable host node for the
|
// observe the element, so we do: one observer on the stable host node for the
|
||||||
@@ -205,13 +239,15 @@ export function LivePreview() {
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Finalize the live view on unmount, and clear the shared error so a stale
|
// Finalize the live view on unmount, and clear the shared error + busy state so
|
||||||
// message never outlives this pane.
|
// stale transient state never outlives this pane.
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
handleRef.current?.destroy();
|
handleRef.current?.destroy();
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
|
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||||
usePreviewStore.getState().setError(null);
|
usePreviewStore.getState().setError(null);
|
||||||
|
usePreviewStore.getState().setBusy(false);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -222,7 +258,12 @@ export function LivePreview() {
|
|||||||
<FitControl />
|
<FitControl />
|
||||||
<PreviewSettings />
|
<PreviewSettings />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.body}>
|
{/*
|
||||||
|
* aria-busy on the chart region tells AT the area is being updated (arch §10.2;
|
||||||
|
* spec §04). The overlay is a non-blocking sibling inside the relative-positioned
|
||||||
|
* body; it never covers the header or editor, so editing stays fully interactive.
|
||||||
|
*/}
|
||||||
|
<div className={styles.body} aria-busy={busy || undefined}>
|
||||||
{/* Frame is React-owned and carries the fit-sizing class; the inner host
|
{/* Frame is React-owned and carries the fit-sizing class; the inner host
|
||||||
is owned by vega-embed (it brands it `.vega-embed` and mutates its
|
is owned by vega-embed (it brands it `.vega-embed` and mutates its
|
||||||
classList at runtime), so its className stays static and React never
|
classList at runtime), so its className stays static and React never
|
||||||
@@ -234,6 +275,18 @@ export function LivePreview() {
|
|||||||
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
|
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
|
||||||
so adding one here would double-announce it. */}
|
so adding one here would double-announce it. */}
|
||||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||||
|
{/*
|
||||||
|
* Busy overlay: non-blocking, overlays only the chart body, never the header
|
||||||
|
* or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
|
||||||
|
* so sub-1s renders produce no flicker. The spinner animation is suppressed
|
||||||
|
* under prefers-reduced-motion (base.css *{animation-duration:0.01ms}).
|
||||||
|
*/}
|
||||||
|
{busy && (
|
||||||
|
<div className={styles.busyOverlay} aria-hidden="true">
|
||||||
|
<span className={styles.busySpinner} />
|
||||||
|
<span className={styles.busyLabel}>Rendering…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { usePreviewStore } from './PreviewStore';
|
||||||
|
|
||||||
|
const store = () => usePreviewStore.getState();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset to a known clean state between tests so store leaks don't affect order.
|
||||||
|
store().setError(null);
|
||||||
|
store().setBusy(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PreviewStore — error slice', () => {
|
||||||
|
it('starts with null error', () => {
|
||||||
|
expect(store().error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setError stores the provided message', () => {
|
||||||
|
store().setError('Rendering error: something went wrong.');
|
||||||
|
expect(store().error).toBe('Rendering error: something went wrong.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setError(null) clears the message', () => {
|
||||||
|
store().setError('an error');
|
||||||
|
store().setError(null);
|
||||||
|
expect(store().error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PreviewStore — busy slice', () => {
|
||||||
|
it('starts with busy=false', () => {
|
||||||
|
expect(store().busy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setBusy(true) sets busy to true', () => {
|
||||||
|
store().setBusy(true);
|
||||||
|
expect(store().busy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setBusy(false) clears busy', () => {
|
||||||
|
store().setBusy(true);
|
||||||
|
store().setBusy(false);
|
||||||
|
expect(store().busy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('busy and error are independent — setting one does not affect the other', () => {
|
||||||
|
store().setBusy(true);
|
||||||
|
store().setError('some error');
|
||||||
|
expect(store().busy).toBe(true);
|
||||||
|
expect(store().error).toBe('some error');
|
||||||
|
|
||||||
|
store().setBusy(false);
|
||||||
|
expect(store().error).toBe('some error'); // error unchanged by clearing busy
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
* Live Preview is the single producer; it writes the current error here and both
|
* Live Preview is the single producer; it writes the current error here and both
|
||||||
* panes subscribe. `null` means the current spec rendered cleanly (or is blank).
|
* panes subscribe. `null` means the current spec rendered cleanly (or is blank).
|
||||||
*
|
*
|
||||||
|
* `busy` tracks whether a render is in flight long enough to warrant a visible
|
||||||
|
* indicator (arch §10.2: >~1s owes a non-blocking busy overlay). LivePreview arms
|
||||||
|
* a 1 s timer when a render starts and sets `busy = true` only if the render has
|
||||||
|
* not settled by then; it clears `busy` on settle or error regardless.
|
||||||
|
*
|
||||||
* Kept as its own tiny store rather than folded into the SnippetStore: this is
|
* Kept as its own tiny store rather than folded into the SnippetStore: this is
|
||||||
* transient render state, not durable domain data, and it must not be persisted.
|
* transient render state, not durable domain data, and it must not be persisted.
|
||||||
*/
|
*/
|
||||||
@@ -20,9 +25,19 @@ export interface PreviewState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
/** Set (or clear) the current render error. */
|
/** Set (or clear) the current render error. */
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
|
/**
|
||||||
|
* True while a render has been in flight for longer than the ~1s NN/g threshold
|
||||||
|
* (arch §10.2). The LivePreview overlay reads this to show a non-blocking busy
|
||||||
|
* indication; aria-busy on the preview region mirrors it.
|
||||||
|
*/
|
||||||
|
busy: boolean;
|
||||||
|
/** Set or clear the busy flag. */
|
||||||
|
setBusy: (busy: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePreviewStore = create<PreviewState>((set) => ({
|
export const usePreviewStore = create<PreviewState>((set) => ({
|
||||||
error: null,
|
error: null,
|
||||||
setError: (error) => set({ error }),
|
setError: (error) => set({ error }),
|
||||||
|
busy: false,
|
||||||
|
setBusy: (busy) => set({ busy }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user