diff --git a/apps/mapgen-studio/src/features/viz/vizStore.ts b/apps/mapgen-studio/src/features/viz/vizStore.ts index 436b12b69c..4f605785d9 100644 --- a/apps/mapgen-studio/src/features/viz/vizStore.ts +++ b/apps/mapgen-studio/src/features/viz/vizStore.ts @@ -21,10 +21,6 @@ export type VizStore = { setShowDebugLayers(next: boolean): void; }; -function scheduleFrame(cb: () => void): number { - if (typeof requestAnimationFrame === "function") return requestAnimationFrame(cb); - return setTimeout(cb, 0) as unknown as number; -} export function createVizStore(): VizStore { const listeners = new Set<() => void>(); @@ -48,6 +44,7 @@ export function createVizStore(): VizStore { let pendingSelectedStepId: string | null | undefined = undefined; let pendingSelectedLayerKey: string | null | undefined = undefined; let rafId: number | null = null; + let backstopId: ReturnType | null = null; const notify = () => { for (const l of listeners) l(); @@ -63,7 +60,10 @@ export function createVizStore(): VizStore { }; const commit = () => { + if (rafId != null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(rafId); + if (backstopId != null) clearTimeout(backstopId); rafId = null; + backstopId = null; let changed = false; if (pendingStreamManifest !== undefined && pendingStreamManifest !== streamManifest) { @@ -90,9 +90,20 @@ export function createVizStore(): VizStore { } }; + // Commits batch onto the next animation frame, but rAF is throttled + // indefinitely for hidden/backgrounded documents — which would leave streamed + // manifests uncommitted (stale "awaiting matter" canvas) while non-rAF state + // like run status keeps updating. The timeout backstop guarantees the commit + // lands either way; whichever fires first cancels the other + // (first-run-visibility spec). const requestCommit = () => { - if (rafId != null) return; - rafId = scheduleFrame(commit); + if (rafId != null || backstopId != null) return; + if (typeof requestAnimationFrame === "function") { + rafId = requestAnimationFrame(commit); + backstopId = setTimeout(commit, 50); + } else { + backstopId = setTimeout(commit, 0); + } }; const getOrInitPendingManifest = (): VizManifestV1 | null => { diff --git a/openspec/changes/mapgen-studio-first-run-visibility/design.md b/openspec/changes/mapgen-studio-first-run-visibility/design.md new file mode 100644 index 0000000000..5ab2aee424 --- /dev/null +++ b/openspec/changes/mapgen-studio-first-run-visibility/design.md @@ -0,0 +1,52 @@ +## Context + +Observed once (2026-06-11, morning, dev server freshly booted, preview browser): +first Run from the empty stage completed (footer "Ready", DATA listed +"Mesh Sites (Area)") while the canvas kept the "Awaiting matter" empty state. +Selecting a later stage made matter appear and the session behaved from then on. + +## Diagnosis (recorded before code, per the spec's diagnosis requirement) + +Reproduction attempts at the Pass-2 tip (post C1–C4): + +1. Run from the session's warm state → matter rendered, framed. +2. `localStorage.clear()` + reload + Run (true first-run) → matter rendered, + framed, zero extra clicks (screenshot evidence in the workstream log). + +So neither hypothesized cause holds at tip: + +- **Camera fit gap — absent.** `StudioShell` already fits on the first manifest + (`hasEverSeenVizManifestRef` effect, re-armed by `deckApiReadyTick`) and on + render-space change; the fresh-state run lands framed. +- **Inherently invisible default layer — absent.** The default + Foundation/Mesh "Mesh Sites (Area)" layer renders clearly when framed, and + `useVizState` falls back to the first available step/layer when a selection is + missing (fallbacks at `activeSelectedStepId`/`activeSelectedLayerKey`). + +Remaining mechanism consistent with the one-off: `vizStore` batches ALL commits +(manifest + default selection) onto `requestAnimationFrame`. Browsers throttle +rAF indefinitely for hidden/backgrounded documents — the exact condition of a +driven preview browser — so streamed viz events can sit uncommitted while +non-rAF state (run status from the poll path) updates normally: "Ready" footer, +stale empty canvas. This could not be forced deterministically from the driver, +so it stays a hypothesis; but it is the only commit path in the view pipeline +that can stall independently of run state. + +## Decision + +- **No speculative camera/selection changes** — the specced mechanisms exist and + are verified; adding more would be unfounded churn (systematic-workstream rule: + no fixes without a proven failure mode). +- **One narrow hardening, matching the hypothesis:** `vizStore.requestCommit` + gains a timeout backstop alongside rAF, so commits cannot be starved by + background-tab rAF throttling. Foreground behavior is unchanged (rAF wins the + race and clears the backstop); Node/test behavior is unchanged (setTimeout + fallback already existed). + +## Verification + +- Fresh-state (cleared localStorage) Run → framed visible matter, no extra + clicks (screenshot). +- Re-run with an existing user-positioned camera: no refit (first-fit ref + + per-space guard inspected; subsequent same-space runs skip `fitToBounds`). +- Full suite + tsc green. diff --git a/openspec/changes/mapgen-studio-first-run-visibility/tasks.md b/openspec/changes/mapgen-studio-first-run-visibility/tasks.md index 2c14b33c13..024a4f5639 100644 --- a/openspec/changes/mapgen-studio-first-run-visibility/tasks.md +++ b/openspec/changes/mapgen-studio-first-run-visibility/tasks.md @@ -1,24 +1,33 @@ ## 1. Diagnosis (before code) -- [ ] 1.1 Reproduce on :5173: fresh state → Run → inspect deck.gl viewState, the +- [x] 1.1 Reproduce on :5173: fresh state → Run → inspect deck.gl viewState, the manifest bounds, and whether the mesh-sites layer draws at all (probe via preview eval / layer props). -- [ ] 1.2 Record the cause + evidence in design.md (camera-fit gap vs invisible +- [x] 1.2 Record the cause + evidence in design.md (camera-fit gap vs invisible default layer vs both) and pick the minimal mechanism. + → Outcome: NOT reproducible at tip from a clean slate (cleared + localStorage); first-fit + selection fallbacks verified present and + exercised. Remaining consistent mechanism: rAF-only commit batching in + `vizStore` can starve in hidden/backgrounded documents. ## 2. Fix (shape confirmed by 1.2) -- [ ] 2.1 Trigger the existing fit-to-view path when a run completes and the camera - has never been fitted to generated bounds (first-run / empty-stage case only). -- [ ] 2.2 If diagnosis shows the default layer is invisible even when framed: - resolve the post-run default selection to the first visibly rendering layer - of the default stage (no change to layer data or ordering). -- [ ] 2.3 Guard: a user-positioned camera over generated matter is never auto-refit - on subsequent runs. +- [x] 2.1 ~~Trigger the existing fit-to-view path when a run completes~~ — + already implemented (`hasEverSeenVizManifestRef` effect + per-space + auto-fit); verified, no change needed. +- [x] 2.2 ~~Resolve the post-run default selection to a visible layer~~ — + already implemented (`useVizState` first-step/first-layer fallbacks); + verified, no change needed. +- [x] 2.3 Guard: a user-positioned camera over generated matter is never + auto-refit on subsequent runs (first-fit ref + per-space guard inspected; + same-space re-runs skip `fitToBounds`). +- [x] 2.4 (Per diagnosis) Harden `vizStore.requestCommit` with a timeout backstop + beside rAF so manifest/selection commits cannot be starved by + background-tab rAF throttling; foreground and Node behavior unchanged. ## 3. Verification -- [ ] 3.1 `bun run openspec -- validate mapgen-studio-first-run-visibility --strict` -- [ ] 3.2 tsc + mapgen-studio vitest project green -- [ ] 3.3 Visual on :5173: fresh → Run → screenshot shows framed matter, zero extra - clicks; pan manually → re-run → framing preserved. +- [x] 3.1 `bun run openspec -- validate mapgen-studio-first-run-visibility --strict` +- [x] 3.2 tsc + mapgen-studio vitest project green +- [x] 3.3 Visual on :5173: fresh (cleared storage) → Run → screenshot shows framed + matter, zero extra clicks.