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:
- Fast Track receives high-frequency updates (pointer-move at 60fps)
- Canvas repaints immediately from volatile ref — no React involved
- On pointer-up, volatile state commits to the Slow Track as a single atomic write
- 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
- Actions & Commands — The complete 4-layer dispatch system
- Interaction System — Gesture dispatcher and hit-testing
- Rendering Pipeline — How the canvas consumes volatile state
Last updated: 2026-06-14