Launch OpenGPEX

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:

  1. The fast track receives pointer-move updates every animation frame (no React involved).
  2. The renderer reads merged state (persistent + volatile) and repaints via the GPU each frame.
  3. On pointer-up, the volatile state commits to the persistent track as one atomic write.
  4. 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) lookups
  • order — 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 = 50 per 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
  1. checking — probe for existing State_V1 / State_V2 records.
  2. migrating (only if v1 data exists) — run the v1→v2 migrator with a beforeunload guard against closing the tab mid-write.
  3. restoring — storage.restore() races a fixed timeout; on timeout, restore is skipped, auto-save overwrite is blocked, and a signal is dispatched.
  4. finalizing — hydrate the store from the saved snapshot (or start empty).
  5. READY — useEditorStatus flips from BOOTING to READY as soon as the store reports isLoaded (set by HYDRATE or SET_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