@fcannizzaro/streamdeck-react

Rendering Pipeline

How the virtual tree becomes an image on Stream Deck hardware.

VNode Tree

After the React reconciler commits, the host nodes form a virtual tree:

interface VNode {
  type: string; // 'div', 'span', 'img', 'svg', etc.
  props: Record<string, unknown>;
  children: VNode[];
  text?: string; // For text instances
  /** @internal Back-pointer to parent VNode for dirty propagation. */
  _parent?: VNode | VContainer;
  /** @internal True when this node or a descendant has been mutated since last flush. */
  _dirty?: boolean;
  /** @internal Cached Merkle hash for this subtree. */
  _hash?: number;
  /** @internal True when _hash is still valid (no mutations since last hash). */
  _hashValid?: boolean;
}

When a VNode is mutated, the _parent back-pointer enables O(depth) dirty propagation — markDirty walks up from the changed node, setting _dirty on each ancestor until it reaches the container root. This is the foundation of Phase 1 in the skip hierarchy.

Direct VNode-to-Takumi Bypass

The pipeline converts VNodes directly to Takumi's node format in a single tree walk, bypassing two intermediate steps that the standard path would require:

Standard path (eliminated):
  VNode → vnodeToElement() → React element → fromJsx() → Takumi node
  (2 full tree walks + 2× node allocations per render)

Bypass path (used):
  VNode → vnodeToTakumiNode() → Takumi node
  (1 tree walk, saves ~1–5ms per frame)

The mapping handles four VNode types:

VNode typeTakumi node typeNotes
#textTextNode{ type: "text", text }
imgImageNode{ type: "image", src }
svgImageNodeSVG subtree serialized to markup string
* (all others)ContainerNode{ type: "container", children }

The className prop is mapped to Takumi's tw field for built-in Tailwind CSS resolution.

4-Phase Skip Hierarchy

Every render attempt passes through a multi-tier skip hierarchy designed to avoid redundant work. Each phase has progressively higher cost; earlier phases act as fast-path exits.

flush()


Phase 1: Dirty-flag check (O(1))
  │  If no VNode was mutated since last flush, skip entirely.


Phase 2: Merkle-tree hash → Image cache lookup
  │  Compute structural hash of the VNode tree.
  │  If hash+config key is in the LRU image cache, return cached result.


Phase 3: Takumi render (main thread or worker)
  │  Convert VNode tree → Takumi nodes (direct bypass).
  │  Pass to native Takumi renderer for rasterization.


Phase 4: xxHash output dedup
   │  Hash the raw raster buffer (xxHash-wasm, FNV-1a fallback).
   │  If identical to the previous frame, skip encoding and hardware push.


Encode → base64 data URI → store in cache → push to hardware

Phase 1: Dirty-Flag Check

Cost: O(1) — a single boolean check on the container.

When a VNode is mutated, dirty flags propagate up through _parent back-pointers to the container root. If the container's dirty flag is false, no node was modified since the last flush, and the entire render is skipped.

Phase 2: Merkle Hash + Image Cache

Cost: O(depth) for single-node mutations, O(n) worst case.

A Merkle-style hash is computed over the VNode tree. Unchanged subtrees reuse their cached _hash values, so a single-node mutation only rehashes the path from the changed node to the root. The resulting tree hash is combined with render parameters (width, height, device pixel ratio, image format) to form a cache key.

If the key is found in the byte-bounded LRU image cache, the cached data URI (or raw buffer for TouchStrip) is returned immediately — skipping the entire Takumi render.

Phase 3: Takumi Render

Cost: 5–30ms depending on tree complexity.

The VNode tree is converted to Takumi nodes via the direct bypass path and passed to the native Rust renderer for rasterization. This can happen on the main thread or be offloaded to a worker thread (see Worker Thread Pool).

Phase 4: xxHash Output Dedup

Cost: O(n) over the output buffer.

After rendering, the raw pixel buffer is hashed via xxHash-wasm — a WASM-compiled xxHash that processes the entire buffer in native code, significantly faster than a JS byte loop even for 320 KB TouchStrip frames. If the hash matches the previous frame's hash (lastSvgHash), the component re-rendered but produced no visual change — encoding and the hardware push are skipped.

If the WASM module hasn't compiled yet (brief window at startup, ~1ms), the pipeline falls back to FNV-1a with strided sampling (every 16th byte) until xxHash is ready.

In debug mode, consecutive identical renders are counted and a warning is logged after 3 duplicates, helping identify components that re-render without visual effect.

Two Entry Points

The pipeline provides two render functions for different surfaces:

Entry pointSurfaceReturnsCache
renderToDataUriKeys, dialsBase64 data URI stringImage cache (16 MB default)
renderToRawTouchStripRaw RGBA BufferTouchStrip cache (8 MB default)

Both follow the same 4-phase skip hierarchy. The key difference is that renderToRaw skips the base64 encoding step — the caller (TouchStripRoot) crops per-encoder segments from the raw buffer and encodes each independently.

Render Configuration

The full render configuration is built internally from your createPlugin() options:

interface RenderConfig {
  renderer: Renderer;
  imageFormat: OutputFormat; // "png" | "webp"
  caching: boolean;
  devicePixelRatio: number;
  debug: boolean;
  imageCacheMaxBytes: number; // Default: 16 MB
  touchStripCacheMaxBytes: number; // Default: 8 MB
  renderPool: RenderPool | null;
  onRender?: (container: VContainer, dataUri: string) => void;
  onProfile?: (profile: RenderProfile) => void;
}

Most of these map directly to PluginConfig options:

PluginConfig optionRenderConfig fieldDefault
imageFormatimageFormat"png"
cachingcachingtrue
devicePixelRatiodevicePixelRatio1
debugdebugNODE_ENV !== 'production'
imageCacheMaxBytesimageCacheMaxBytes16777216 (16 MB)
touchStripCacheMaxBytestouchStripCacheMaxBytes8388608 (8 MB)
useWorkerrenderPooltrue (creates a RenderPool)

Image Cache

The image cache is a byte-bounded LRU cache shared across all React roots. Unlike a count-bounded cache, byte-bounding handles the wide size variance of Stream Deck images correctly — a 72×72 key PNG is ~2KB while an 800×100 raw RGBA TouchStrip buffer is 320KB.

Two separate caches exist:

  • Image cache (ImageCache<string>) — stores base64 data URIs for key/dial renders. Default budget: 16 MB.
  • TouchStrip cache (ImageCache<Buffer>) — stores raw RGBA buffers. Default budget: 8 MB.

Both use a doubly-linked list for O(1) LRU eviction and a Map for O(1) lookups. When the total byte size exceeds the budget, the least-recently-used entries are evicted from the tail.

Cache sizes are configurable via imageCacheMaxBytes and touchStripCacheMaxBytes in createPlugin(). Set to 0 to disable.

Buffer Pool

At 30fps TouchStrip rendering, each frame allocates ~320KB of raw RGBA buffers (800×100×4 bytes). Without pooling, V8's garbage collector must reclaim ~10MB/s of short-lived buffers, causing periodic frame drops visible as stutter.

The BufferPool recycles buffers by exact byte size:

  • acquire(size) — returns a zeroed buffer from the matching bucket, or allocates a new one
  • release(buf) — returns the buffer to its size bucket for reuse

Each size bucket is capped at 8 buffers to prevent unbounded memory growth during brief spikes of concurrent renders. The pool is shared as a singleton across all roots.

Worker Thread Pool

The RenderPool offloads Takumi rasterization to a worker thread so the main thread stays responsive for SDK event handling and React reconciliation.

Why: Takumi's native rasterization (Rust via NAPI) blocks the calling thread for 5–30ms per frame. During 30fps TouchStrip animation, this leaves almost no time for processing keyDown or dialRotate events on the main thread.

The worker receives serialized VNode trees (stripped of back-pointers and function props that can't cross the structured-clone boundary), converts them to Takumi nodes, and returns the raster buffer via zero-copy ArrayBuffer transfer.

If the worker fails to initialize (e.g., native addon can't load in worker context), the pipeline transparently falls back to main-thread rendering. Controlled via useWorker in PluginConfig (default: true).

Adaptive Debounce

Rather than a fixed debounce for all renders, the pipeline detects rendering patterns and chooses the optimal delay:

ModeConditionDebounceWhy
Animating>2 renders in a 100ms window0msSpring/tween animations need every frame delivered
InteractiveUser input within 500msmin(configured, 16ms)Key press and dial rotation need fast visual feedback
IdleNo recent activityconfigured (default 16ms)Settings changes, initial render — batch updates

Multiple state updates within a single event handler are still batched by React's automatic batching. The adaptive debounce is an additional layer on top, applied after the reconciler commits.

The debounce mode also drives render priority — animating roots get priority 0 (highest), ensuring they flush to hardware before idle roots.

Render Metrics

The RenderMetrics collector tracks pipeline statistics for performance monitoring:

interface RenderMetrics {
  flushCount: number; // Total flush() calls
  renderCount: number; // Flushes that reached Takumi
  cacheHitCount: number; // Phase 2 skips (cache hit)
  dirtySkipCount: number; // Phase 1 skips (clean tree)
  hashDedupCount: number; // Phase 4 skips (xxHash output dedup)
  avgRenderMs: number; // Average Takumi render time
  peakRenderMs: number; // Worst-case render time
  imageCacheBytes: number; // Image cache memory usage
  touchStripCacheBytes: number; // TouchStrip cache memory usage
}

In debug mode, metrics are logged to the console every 10 seconds with a summary including the skip rate — the percentage of flush attempts that were short-circuited by the caching tiers. A well-optimized plugin typically sees 60–90% skip rates.

Cumulative counters (never reset) are also maintained for the DevTools Performance panel, which reads them via snapshot().

Render Profiling

Each individual render can be profiled via the onProfile callback in RenderConfig:

interface RenderProfile {
  /** Time to convert VNode tree to Takumi node tree (ms). */
  vnodeConversionMs: number;
  takumiRenderMs: number;
  hashMs: number;
  base64Ms: number;
  totalMs: number;
  skipped: boolean;
  cacheHit: boolean;
  treeDepth: number;
  nodeCount: number;
  cacheStats: CacheStats | null;
}

This data flows through the devtools bridge to the Performance panel via SSE, providing per-render timing breakdowns for diagnosing bottlenecks.

Output Format

The renderer writes PNG by default and can be configured to output WebP via imageFormat: "webp" in createPlugin().

Dimensions (width/height) are determined automatically from the device type and controller. See Device Sizes for the full table.

On this page