Three related correctness findings in the renderer's reactive core, from the 2026-07 full-codebase review. Line references are against the remove-global-glyph-state working tree.
1. No glitch freedom; batch() does not defer computed-mediated effects
SignalImpl._notify only defers EffectImpl subscribers during a batch (lib/signals/signal.ts:378-384), but ComputedImpl.execute calls subscriber.execute() unconditionally (signal.ts:505-516). Any effect subscribed through a computed runs immediately, mid-batch, once per write.
Consequences:
- Every
*Cell the app exposes is a computed, so FontStore.applyWorkspaceChange's batch() (FontStore.ts:123) and patchPositions' batch() (GlyphLayerState.ts:353) provide no coalescing to most consumers.
- Diamond graphs (
s → c1, c2 → effect) run the effect twice per write, and the first run observes torn state (c1 new, c2 stale).
- Computeds propagate dirt with no recompute-and-compare cut-off, so identity-preserving caches don't stop cascades: any workspace echo re-runs
new SourceGeometryCache(...) (Glyph.ts:1025-1032) and rebuilds outline part arrays (GlyphOutline.ts:262-269) even when the resolved layer is the identical cached object.
The test suite only covers direct signal→effect edges (signal.test.ts:385-434) and a lazy-read diamond with no effect (:730-739), so both defects are invisible to CI. The renderer survives today only because render effects are rAF-scheduled.
Fix direction: defer all effect execution behind a batch/notification flush (topological or two-phase notify), and add an equality cut-off to computed recomputation. The cut-off also eliminates the wasted cascade rebuilds.
2. Rejected intent → resync discards all loaded geometry and reloads nothing
WorkspaceEditCoordinator.#enqueueApply catch (WorkspaceEditCoordinator.ts:199-202) → #resync() (:233-236) → FontStore.replaceWorkspace (FontStore.ts:89-99) nulls and clears every layer-state cell and all glyph/layer models. Nothing re-issues Font.loadGlyphs for open glyphs; GlyphOutline falls through to emptyGlyphGeometry() (Font.ts:899, 1386-1388), so the canvas goes blank until an unrelated flow happens to reload.
Additionally: public apply()/undo()/redo() rejections are neither caught nor rolled back, and Editor.undo() (Editor.ts:554-557) uses void, producing an unhandled rejection with locally-diverged optimistic state. The only user-visible signal for a rejected edit is a console.error.
Fix direction: resync must reload snapshots for glyphs that had loaded layer state (or scene-referenced glyphs), and rejection must surface to the user as UI, not console noise.
3. Echo folding clobbers live previews and defeats per-contour invalidation
FontStore.applyWorkspaceChange calls replaceValues(layer.values) for every echoed layer (FontStore.ts:171); replaceValues swaps the entire LayerCoordinateBuffers container (GlyphLayerState.ts:170-178).
Two consequences:
- A pointer preview is local-only (
GlyphLayerEditDraft.previewPositionPatch → patchPositions, no queue entry), so an echo from an earlier operation (previous drag commit, setXAdvance, undo) landing mid-drag snaps dragged points back to committed values under the cursor.
- Even absent the race, replacing container identity invalidates every contour path and outline part on every commit — the sparse per-contour reactivity the design pays for is thrown away on each echo instead of folding values into existing buffers.
Fix direction: fold echoed values into the existing buffer containers (preserve identity, dirty only changed contours), and reconcile echoes against open drafts — skip or re-apply preview deltas for a layer with an active draft.
Sequencing note
Item 1 is the foundation: fixing batch/glitch semantics first makes the behavior of 2 and 3 deterministic and testable. All three are renderer-local and independent of the crates/ file-safety work.
Three related correctness findings in the renderer's reactive core, from the 2026-07 full-codebase review. Line references are against the
remove-global-glyph-stateworking tree.1. No glitch freedom;
batch()does not defer computed-mediated effectsSignalImpl._notifyonly defersEffectImplsubscribers during a batch (lib/signals/signal.ts:378-384), butComputedImpl.executecallssubscriber.execute()unconditionally (signal.ts:505-516). Any effect subscribed through a computed runs immediately, mid-batch, once per write.Consequences:
*Cellthe app exposes is a computed, soFontStore.applyWorkspaceChange'sbatch()(FontStore.ts:123) andpatchPositions'batch()(GlyphLayerState.ts:353) provide no coalescing to most consumers.s → c1, c2 → effect) run the effect twice per write, and the first run observes torn state (c1 new, c2 stale).new SourceGeometryCache(...)(Glyph.ts:1025-1032) and rebuilds outline part arrays (GlyphOutline.ts:262-269) even when the resolved layer is the identical cached object.The test suite only covers direct signal→effect edges (
signal.test.ts:385-434) and a lazy-read diamond with no effect (:730-739), so both defects are invisible to CI. The renderer survives today only because render effects are rAF-scheduled.Fix direction: defer all effect execution behind a batch/notification flush (topological or two-phase notify), and add an equality cut-off to computed recomputation. The cut-off also eliminates the wasted cascade rebuilds.
2. Rejected intent → resync discards all loaded geometry and reloads nothing
WorkspaceEditCoordinator.#enqueueApplycatch (WorkspaceEditCoordinator.ts:199-202) →#resync()(:233-236) →FontStore.replaceWorkspace(FontStore.ts:89-99) nulls and clears every layer-state cell and all glyph/layer models. Nothing re-issuesFont.loadGlyphsfor open glyphs;GlyphOutlinefalls through toemptyGlyphGeometry()(Font.ts:899, 1386-1388), so the canvas goes blank until an unrelated flow happens to reload.Additionally: public
apply()/undo()/redo()rejections are neither caught nor rolled back, andEditor.undo()(Editor.ts:554-557) usesvoid, producing an unhandled rejection with locally-diverged optimistic state. The only user-visible signal for a rejected edit is aconsole.error.Fix direction: resync must reload snapshots for glyphs that had loaded layer state (or scene-referenced glyphs), and rejection must surface to the user as UI, not console noise.
3. Echo folding clobbers live previews and defeats per-contour invalidation
FontStore.applyWorkspaceChangecallsreplaceValues(layer.values)for every echoed layer (FontStore.ts:171);replaceValuesswaps the entireLayerCoordinateBufferscontainer (GlyphLayerState.ts:170-178).Two consequences:
GlyphLayerEditDraft.previewPositionPatch→patchPositions, no queue entry), so an echo from an earlier operation (previous drag commit,setXAdvance, undo) landing mid-drag snaps dragged points back to committed values under the cursor.Fix direction: fold echoed values into the existing buffer containers (preserve identity, dirty only changed contours), and reconcile echoes against open drafts — skip or re-apply preview deltas for a layer with an active draft.
Sequencing note
Item 1 is the foundation: fixing batch/glitch semantics first makes the behavior of 2 and 3 deterministic and testable. All three are renderer-local and independent of the
crates/file-safety work.