State & Data Flow
How OpenGPEX v2 stores, updates, and tracks all editor state — from layer properties to undo history — with native-refresh-rate interaction performance and byte-sized undo steps.
Design Overview
User gesture (mouse/touch)
│
▼
Interaction dispatcher
│
├─── rAF path ─────► Fast track (volatile ref, no React re-render)
│ │
│ ▼
│ SceneAssembler reads merged state
│ → GPU repaints this frame
│
└─── Commit path ──► Reducer (immutable update)
│
├──► JSON Patch recorded into TimeTravel
│
▼
React re-render (selective via selectors)
This dual-track model carries over from v1 unchanged — v2 changes where the pixels come from (the WebGPU engine), not how state is managed.
Dual-Track Architecture (Volatile + Persistent)
| Persistent track | Volatile fast track | |
|---|---|---|
| Backing | Redux-style immutable store | Mutable ref outside React |
| Drives | UI panels, layer tree, persisted document | Canvas paint, overlays, in-progress gestures |
| Triggers | React re-render | requestAnimationFrame only |
| Undo | Produces patches per dispatch | Produces nothing until commit |
| Persist | IndexedDB (sharded) | Never |
During a drag interaction:
- The fast track receives pointer-move updates every animation frame (no React involved).
- The renderer reads merged state (persistent + volatile) and repaints via the GPU each frame.
- On pointer-up, the volatile state commits to the persistent track as one atomic write.
- One undo step is generated for the entire gesture — not one per frame.
The fast track is a single mutable ref (volatileRef, owned by useVolatileState). Consumers do not subscribe through React at all: useFastSync reads the ref inside shared-ticker (requestAnimationFrame) callbacks and applies it straight to the canvas element, bypassing the reducer and any re-render. Stable service objects (geometry, assets, engine bridge, files) are provided through a separate, reference-stable context so plugin UI never re-renders on state churn.
Normalized State
Domain entities keep the { byId, order } dictionary pattern:
byId— hash map keyed by entity ID, O(1) lookupsorder— string array defining z-order
This enables direct property-level updates without tree traversal, cheap reordering (only the order array changes), and minimal undo patches (inserting a layer produces exactly two patches instead of shifting all indices).
TimeTravel: JSON Patch Undo
Undo history is a stack of minimal JSON Patches, not state snapshots:
| Metric | Snapshot-based | OpenGPEX (patches) |
|---|---|---|
| Memory per step | 1–10 MB | tens to hundreds of bytes |
| Undo latency | 10–50 ms (deep copy) | <2 ms (patch apply) |
| 100 steps | 100–1000 MB RAM | ~20 KB RAM |
Design rules:
- Viewport stability — camera state is excluded from undo to prevent jarring jumps.
- History limit — bounded to
MAX_HISTORY_STEPS = 50per frame; oldest entries discarded automatically. - Gesture coalescing — a 500 px drag yields exactly one history entry.
Sharded Persistence
v2 splits persistence into physically isolated IndexedDB namespaces:
| Database | Driver | Contents |
|---|---|---|
State_V2 |
ShardedStateDriver / StateDriver |
Per-frame document shards (frame:{id}, history_index, project_meta — frame order, active frame, plugin config, UI prefs) |
Assets_V3 |
AssetDriver |
Asset records (ASSET_VERSION = 5), light records + raw: sources + dec: naked pixels |
State_V1 / Assets_V2 |
Legacy drivers | v1 data — strictly read-only after migration |
Sharding matters for two reasons: each auto-save writes only the frames whose object identity actually changed (===-dirty checking, one shard per frame), and the asset GC can reason per-frame about what is live. Boot-time serialization covers only frames / history / plugin config / UI / meta — ephemeral signals (boot stage, HUD messages) are never persisted.
Boot Lifecycle
Editor start-up flows through five explicit stages, surfaced to the workspace as signals:
checking → [migrating] → restoring → finalizing → READY
- checking — probe for existing State_V1 / State_V2 records.
- migrating (only if v1 data exists) — run the v1→v2 migrator with a
beforeunloadguard against closing the tab mid-write. - restoring —
storage.restore()races a fixed timeout; on timeout, restore is skipped, auto-save overwrite is blocked, and a signal is dispatched. - finalizing — hydrate the store from the saved snapshot (or start empty).
- READY —
useEditorStatusflips fromBOOTINGtoREADYas soon as the store reportsisLoaded(set byHYDRATEorSET_LOADED); only then does the workspace mount the Viewport and start accepting resize sync.
Error & Signal Surface
All command rejections and async failures converge on a single HUD toast channel driven by state.interaction.hud (error entries auto-expire). Confirmations and multi-choice prompts flow through dedicated dialog state. There is exactly one error feedback path — per-feature pulse channels from v1 were removed.
Last updated: 2026-10-12