Editor: Vega-Lite expression intelligence; single-home render errors

This commit is contained in:
2026-07-01 05:00:47 +03:00
parent 9b2618cac6
commit da6a675982
20 changed files with 1001 additions and 260 deletions
+41 -30
View File
@@ -1,10 +1,11 @@
/**
* LivePreview — busy overlay guard (spec §04; arch §10.2).
* LivePreview — busy overlay + render serialization (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.
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so
* `renderSpec` is mocked to park each embed in `H.pending` — a test decides when
* embeds settle. Render status (`error`/`busy`) is the pane's own local state, so
* the busy overlay is driven through its real path — a render left in flight past
* the ~1s timer — not by poking a flag.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
@@ -12,7 +13,6 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { chartConfigForSelection } from '@core/vega-themes';
import { useAppStore } from '../stores/AppStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { LivePreview } from './LivePreview';
@@ -71,7 +71,6 @@ beforeEach(() => {
H.pending.length = 0;
H.destroyed.length = 0;
H.configs.length = 0;
usePreviewStore.setState({ error: null, busy: false });
useSnippetStore.getState().reset();
useDatasetStore.getState().reset();
@@ -84,11 +83,12 @@ beforeEach(() => {
afterEach(() => {
act(() => root.unmount());
container.remove();
usePreviewStore.setState({ error: null, busy: false });
vi.clearAllMocks();
});
describe('LivePreview busy overlay', () => {
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
// The overlay is the aria-hidden element carrying the "Rendering…" label — a
// bare [aria-hidden] query would also match decorative bits of the header
// controls (e.g. the chart-theme select's caret).
@@ -97,33 +97,44 @@ describe('LivePreview busy overlay', () => {
/rendering/i.test(el.textContent ?? ''),
) ?? null;
test('does not render the busy overlay when busy=false', () => {
// The overlay element should not be in the DOM at all during normal operation.
// Start a render and leave it parked in `H.pending`; advancing past the debounce
// and the ~1s busy timer flips `busy` on — the real (and only) path now that it
// is local state.
const startSlowRender = async () => {
act(() => {
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
});
await tick();
};
test('no overlay and no aria-busy before a render is in flight', () => {
expect(overlay()).toBeNull();
});
test('renders the busy overlay when PreviewStore.busy=true', () => {
act(() => usePreviewStore.setState({ busy: true }));
expect(overlay()).not.toBeNull();
});
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(overlay()).not.toBeNull();
act(() => usePreviewStore.setState({ busy: false }));
expect(overlay()).toBeNull();
test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => {
vi.useFakeTimers();
try {
await startSlowRender();
expect(overlay()).not.toBeNull();
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
} finally {
vi.useRealTimers();
}
});
test('the overlay clears once the render settles', async () => {
vi.useFakeTimers();
try {
await startSlowRender();
expect(overlay()).not.toBeNull();
act(() => H.pending[0]()); // the parked embed resolves → busy cleared on settle
await tick(0);
expect(overlay()).toBeNull();
} finally {
vi.useRealTimers();
}
});
});
+37 -26
View File
@@ -7,9 +7,10 @@
* mode applied) → renderSpec (vega-embed). A render-generation token guards
* against a slow render resolving after a newer one.
*
* The pane header carries the Fit control (4 sizing modes, §04). Render errors
* are published to the shared PreviewStore so the editor pane mirrors them
* (§03E); the preview shows the same message in place of the chart.
* The pane header carries the Fit control (4 sizing modes, §04). A render or
* parse error shows here in place of the chart (error xor chart) — its single
* message home (arch 10 §1); the editor pinpoints the cause with a squiggle
* (§03E) rather than repeating the text.
*
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
* (M3) plugs into prepareSpecForRender without changing this component.
@@ -22,6 +23,7 @@ import type { Config } from 'vega-lite';
import { referencedUploadedFonts } from '@core/chart-export';
import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { firstExpressionError } from '@core/spec-expressions';
import {
chartConfigForSelection,
chartThemeOptions,
@@ -33,7 +35,6 @@ import { useAppStore } from '../stores/AppStore';
import { useCustomThemeStore } from '../stores/CustomThemeStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { useFontStore } from '../stores/FontStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport';
@@ -198,10 +199,11 @@ export function LivePreview() {
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
const error = usePreviewStore((s) => s.error);
const setError = usePreviewStore((s) => s.setError);
const busy = usePreviewStore((s) => s.busy);
const setBusy = usePreviewStore((s) => s.setBusy);
// Render status is local to this pane — it is both the only producer and the
// only consumer, so it needs no store (arch 01). `error` is the render/parse
// message (null = clean or blank); `busy` gates the >1s render overlay.
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Mirrors whether `handleRef` currently holds a live view, so the per-chart
// export's image actions (which need the view) can enable/disable reactively —
// a ref change alone wouldn't re-render. Set true on a successful render, false
@@ -253,7 +255,7 @@ export function LivePreview() {
try {
parsed = JSON.parse(text);
} catch (e) {
if (mine === generationRef.current) setError(`Invalid JSON: ${(e as Error).message}`);
if (mine === generationRef.current) setError(`Invalid JSON · ${(e as Error).message}`);
return;
}
}
@@ -333,19 +335,25 @@ export function LivePreview() {
setChartReady(false);
setRenderEpoch((e) => e + 1);
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).
// All render-failure messages share a line-led / noun-led terse shape
// (`<location|noun> · <detail>`, arch 10 §1). A missing dataset and a
// malformed expression are attributed precisely — naming the fixable cause
// beats the generic "check your JSON" hint, which is wrong when the JSON is
// valid (council: GOV.UK error-message "be specific" + name 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.`,
`Dataset "${e.datasetName}" not found · create it from Datasets (⌘/Ctrl+K)`,
);
} else {
setError(
`Rendering error: ${(e as Error).message}. ` +
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
);
// A malformed Vega expression is located by line (the editor also
// squiggles it) and carries the same parser message the hover shows.
// Scanned over the untrimmed buffer so the line matches the editor's.
const exprError = firstExpressionError(shownText);
if (exprError) {
setError(`Line ${exprError.line} · ${exprError.message.replace(/\.$/, '')}`);
} else {
setError(`Render error · ${(e as Error).message}`);
}
}
}
}
@@ -434,16 +442,14 @@ export function LivePreview() {
return () => ro.disconnect();
}, []);
// Finalize the live view on unmount, and clear the shared error + busy state so
// stale transient state never outlives this pane.
// Finalize the live view on unmount so its timers/listeners don't outlive the
// pane. Render status is local state and dies with the component.
useEffect(
() => () => {
handleRef.current?.destroy();
handleRef.current = null;
setChartReady(false);
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
usePreviewStore.getState().setError(null);
usePreviewStore.getState().setBusy(false);
},
[],
);
@@ -474,10 +480,15 @@ export function LivePreview() {
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
<div className={styles.host} ref={hostRef} />
</div>
{/* Visual only — no live region. The same error is announced once by the
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
so adding one here would double-announce it. */}
{error !== null && <pre className={styles.error}>{error}</pre>}
{/* The single home for a render/parse error (arch 10 §1): it sits where the
chart would be — error XOR chart — and is the lone live region, assertive
since the user just caused it. The editor pinpoints the spot via its squiggle,
so the message lives here, not duplicated under the editor. */}
{error !== null && (
<pre className={styles.error} role="alert">
{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
-17
View File
@@ -79,20 +79,3 @@
background: var(--bg);
pointer-events: none;
}
/* Inline render/parse error surface (spec §03E) — monospaced, distinct. */
.error {
flex: 0 0 auto;
max-height: 30%;
overflow: auto;
margin: 0;
padding: var(--space-3) var(--space-4);
border-top: var(--border-width) solid var(--support-error);
background: var(--layer-01);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
color: var(--support-error);
white-space: pre-wrap;
word-break: break-word;
}
+17 -12
View File
@@ -9,8 +9,9 @@
*
* The pane header carries the Draft/Published toggle plus Publish and Revert
* (spec §03D). The published view is read-only — it shows the last published
* spec for reference; all editing happens on the draft. Render problems surface
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
* spec for reference; all editing happens on the draft. Render/parse problems
* surface in the preview pane (arch 10 §1); the editor marks the spot with an
* inline squiggle (spec §03E).
*/
import { useEffect, useMemo, useRef, type RefObject } from 'react';
@@ -45,6 +46,10 @@ import {
runWrapViews,
} from '../services/spec-transform-actions';
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
import {
configureSpecExpressionHints,
installExpressionMarkers,
} from '../services/spec-expression-hints';
import { runExtract } from '../services/extract-action';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
@@ -52,7 +57,6 @@ import { useDatasetStore } from '../stores/DatasetStore';
import { hasExtractableData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon';
@@ -167,6 +171,8 @@ configureJsonFormatter();
configureSpecTransformCodeActions();
// Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08).
configureSpecDatasetHints();
// Register the expression completion/signature-help/hover providers once (docs/architecture/08).
configureSpecExpressionHints();
/** The two spec↔config operations, surfaced as an overflow menu (council:
* Carbon menu-buttons — overflow for additional options under space
@@ -390,7 +396,6 @@ export function SpecEditor() {
const uiTheme = useAppStore((s) => s.uiTheme);
const revealTarget = useAppStore((s) => s.revealTarget);
const composeRequest = useAppStore((s) => s.composeRequest);
const error = usePreviewStore((s) => s.error);
// Editor preferences (spec §07 → Editor); applied live below as they change.
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
@@ -450,6 +455,11 @@ export function SpecEditor() {
// editor, because its commands need this editor's handle to apply the edit.
const codeLensSub = installSpecTransformCodeLens(editor);
// Validate Vega expressions in the draft and squiggle the invalid ones — per
// editor, because it writes markers to this model (docs/architecture/08).
// Debounced internally; recomputes on edit and on a draft↔published toggle.
const exprMarkersSub = installExpressionMarkers(editor);
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
// "bind listeners in exactly one place"), which publishes before the
// interactive-context gate so it works while the editor has focus. Monaco
@@ -461,6 +471,7 @@ export function SpecEditor() {
configActionsSub.dispose();
transformActionsSub.dispose();
codeLensSub.dispose();
exprMarkersSub.dispose();
editor.dispose();
editorRef.current = null;
};
@@ -551,14 +562,8 @@ export function SpecEditor() {
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} />
</div>
{/* The single live region for render/parse errors: assertive, since the
user just caused it. The preview shows the same text visually but is
not a live region, so the message is announced once (doc §10.1). */}
{error !== null && (
<pre className={styles.error} role="alert">
{error}
</pre>
)}
{/* Render/parse errors surface in the preview pane (arch 10 §1), where the
chart would be; the editor pinpoints the spot with its squiggle. */}
</div>
);
}