Skip to content

Reactive core correctness: batch/glitch semantics, resync without reload, echo vs live preview #115

Description

@kostyafarber

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.previewPositionPatchpatchPositions, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions