Fast Track (60fps)

The Fast Track is OpenGPEX's high-performance volatile state system that enables butter-smooth 60fps interactions by completely bypassing React's Virtual DOM reconciliation during drag, resize, and drawing operations.


The Problem

In a traditional React-Redux architecture:

Pointer Move → Dispatch → Reducer → React Reconcile → DOM Commit → Paint
                          (Immer)    (vDOM diff)       (16ms+)

This pipeline takes 16ms+ per frame. At 60fps (16.67ms budget), even one extra millisecond causes visible jank during drag operations. Users experience:

  • Rubber-banding lag on layer drag
  • Stuttered rotation handles
  • Choppy viewport panning

The Solution: Dual-Track Architecture

OpenGPEX splits state into two parallel tracks:

┌─────────────────────────────────┬──────────────────────────────────┐
│  Slow Track (Persistent)         │  Fast Track (Volatile)            │
│                                  │                                   │
│  EditorState (Immer Store)       │  Mutable Ref (volatileRef)        │
│  Drives: UI panels, layer tree   │  Drives: Canvas paint, overlays   │
│  Triggers: React re-render       │  Triggers: requestAnimationFrame  │
│  Produces: Undo patches          │  Produces: Nothing (until commit) │
│  Persist: IndexedDB              │  Persist: Never                   │
└─────────────────────────────────┴──────────────────────────────────┘

During interaction:

  1. Fast Track receives high-frequency updates (pointer-move at 60fps)
  2. Canvas repaints immediately from volatile ref — no React involved
  3. On pointer-up, volatile state commits to the Slow Track as a single atomic write
  4. One undo patch is generated for the entire drag operation

Volatile State Structure

The Fast Track stores three categories of volatile data:

interface VolatileState {
  activeState: {
    interacting: boolean;    // Is a transaction in progress?
    animating: boolean;      // Is a tween running?
  };

  buffered: {
    layers: Record<string, Partial<Layer>>;   // Layer property overrides
    frames: Record<string, Partial<Frame>>;   // Frame/camera overrides
  };

  transient: Record<string, unknown>;  // Ephemeral helpers (guides, etc.)
}

InteractionTransaction

Plugins must never call raw Fast Track APIs directly. Instead, use the InteractionTransaction class:

// In an overlay or interaction handler:
const tx = new InteractionTransaction(pointerDownEvent);

// Phase 1: Begin (marks interacting = true)
tx.begin();

// Phase 2: Update (called on every pointer-move, 60fps)
tx.update(layerId, { x: newX, y: newY });

// Phase 3a: Commit (writes to store, produces 1 undo step)
tx.commit();

// Phase 3b: OR Abort (discards all changes, restores original)
tx.abort();

Lifecycle

pointer-down → tx.begin()
    │
    ▼ (60fps loop)
pointer-move → tx.update(id, props)  ← volatile only, no React
    │
    ▼
pointer-up → tx.commit()  ← fold into store, 1 undo patch

How the Canvas Reads Volatile State

The rendering engine uses fusion queries that merge persistent + volatile:

// Engine paint loop (runs every frame via rAF):
const layer = actions.fast.latestLayer(frameId, layerId);
// Returns: { ...persistentLayer, ...volatileOverrides }

const camera = actions.fast.latestCamera(frameId);
// Returns: { ...persistentCamera, ...volatileCamera }

This ensures the canvas always shows the latest position without waiting for React.


React Synchronization Boundaries

The Fast Track is an imperative system inside a declarative (React) world. This creates timing hazards.

Rule A: Inside React Tree — Trust Closures

In components and hooks, local props/state are always fresher than the global ref during a render cycle:

// ✅ Correct: merge closure data with volatile buffer
const layerDraft = volatile.buffered.layers[layer.id];
const latest = layerDraft ? { ...layer, ...layerDraft } : layer;

// ✅ Correct: pass fresh data as fallback to API
const cam = actions.fast.latestCamera(frame.id, frame);

Rule B: Outside React Tree — Trust Global API

In commands, event listeners, and GSAP tickers, closures may be stale:

// ✅ Correct: query global ref directly
const cam = actions.fast.latestCamera(activeFrameId);
const layer = actions.fast.latestLayer(frameId, layerId);

📌 Memory aid: "Inside React, trust your props. Outside React, trust the API."


API Reference

Method Description Safety
fast.override(frameId, id, props) Write volatile override 🔴 Internal only
fast.commit(id?) Fold volatile → store 🔴 Internal only
fast.latestLayer(fId, lId, fallback?) Read merged layer 🟢 Safe
fast.latestFrame(id, fallback?) Read merged frame 🟢 Safe
fast.latestCamera(id, fallback?) Read merged camera 🟢 Safe
fast.isInteracting() Check if transaction active 🟢 Safe
fast.setTransient(key, data) Store ephemeral helper data 🟡 During interaction
fast.getTransient(key) Read ephemeral helper data 🟢 Safe
fast.signal(frameId) Force canvas repaint 🟡 During interaction
fast.reset() Clear all volatile state 🟡 During interaction

When to Use Fast Track

Use Case Use Fast Track? Why
Drag layer ✅ Yes 60fps position updates
Resize handle ✅ Yes Continuous dimension changes
Viewport pan/zoom ✅ Yes Camera transforms at 60fps
Brush stroke ✅ Yes Continuous path rendering
Change opacity via slider ❌ No Use atomic action (needs undo per tick)
Toggle layer visibility ❌ No Single atomic dispatch
Keyboard shortcut ❌ No Use command bus

Next Steps


Last updated: 2026-06-14