jam-cloud/.planning/phases/28-vu-meter-optimization/28-RESEARCH.md

19 KiB

Phase 28: VU Meter Optimization - Research

Researched: 2026-03-03 Domain: React performance optimization for high-frequency UI updates Confidence: HIGH

Summary

This phase addresses the root cause of page freezes during long sessions: VU meter updates at 50-70 updates/second flowing through React state management, triggering expensive reconciliation cycles. The current implementation uses useState in useVuHelpers.js to store VU levels, causing React to re-render every time the native C++ client sends a VU update.

The solution involves three complementary patterns:

  1. External VU Store - Store VU data outside React using useSyncExternalStore (with shim for React 16 compatibility)
  2. requestAnimationFrame Batching - Limit updates to 60fps to match display refresh rate
  3. Direct DOM Updates via Refs - Bypass React reconciliation entirely for the visual meter updates

Primary recommendation: Create a standalone VU store module that buffers incoming VU data, batches updates with requestAnimationFrame, and exposes a subscription API. VU meter components use refs to directly manipulate DOM elements (CSS classes/styles) rather than relying on React state for visual updates.

Standard Stack

The established libraries/tools for this domain:

Core

Library Version Purpose Why Standard
use-sync-external-store ^1.6.0 useSyncExternalStore shim Official React shim, works with React 16.8+
React refs (useRef) built-in Direct DOM access Bypasses reconciliation for high-frequency updates
requestAnimationFrame browser API Frame synchronization Limits updates to 60fps, matches display refresh

Supporting

Library Version Purpose When to Use
lodash/throttle existing Fallback throttling If rAF batching insufficient

Alternatives Considered

Instead of Could Use Tradeoff
External store Canvas rendering More complex, overkill for simple meter
Direct DOM CSS animations Less control over frame-by-frame updates
useSyncExternalStore Zustand Would add dependency, shim is simpler for this use case

Installation:

cd jam-ui
npm install use-sync-external-store

Architecture Patterns

src/
├── stores/
│   └── vuStore.js           # External VU store (NEW)
├── hooks/
│   ├── useVuHelpers.js      # MODIFY: Remove useState, use external store
│   └── useVuStore.js        # NEW: Hook wrapping external store subscription
├── components/
│   └── client/
│       └── SessionTrackVU.js # MODIFY: Use refs for DOM updates
└── context/
    └── VuContext.js         # MODIFY: Simplified, provides store access

Pattern 1: External Store with requestAnimationFrame Batching

What: A plain JavaScript module that holds VU state outside React, batches incoming updates, and notifies subscribers on each animation frame. When to use: When data arrives faster than React can efficiently render (50+ updates/sec). Example:

// Source: React official docs + best practices for high-frequency data
// src/stores/vuStore.js

let vuLevels = {};  // { mixerId: { level: 0, clipping: false, timestamp: 0 } }
let listeners = new Set();
let pendingUpdates = {};
let rafId = null;

function emitChange() {
  for (const listener of listeners) {
    listener();
  }
}

function flushUpdates() {
  // Merge pending updates into vuLevels
  if (Object.keys(pendingUpdates).length > 0) {
    vuLevels = { ...vuLevels, ...pendingUpdates };
    pendingUpdates = {};
    emitChange();
  }
  rafId = requestAnimationFrame(flushUpdates);
}

export const vuStore = {
  // Called by native client bridge at 50-70/sec
  updateLevel(mixerId, level, clipping) {
    pendingUpdates[mixerId] = { level, clipping, timestamp: performance.now() };
    // Don't emit - rAF loop handles it
  },

  removeLevel(mixerId) {
    delete pendingUpdates[mixerId];
    if (vuLevels[mixerId]) {
      vuLevels = { ...vuLevels };
      delete vuLevels[mixerId];
      emitChange();
    }
  },

  getSnapshot() {
    return vuLevels;  // Must return same reference if unchanged
  },

  subscribe(listener) {
    listeners.add(listener);
    // Start rAF loop when first subscriber
    if (listeners.size === 1 && !rafId) {
      rafId = requestAnimationFrame(flushUpdates);
    }
    return () => {
      listeners.delete(listener);
      // Stop rAF loop when no subscribers
      if (listeners.size === 0 && rafId) {
        cancelAnimationFrame(rafId);
        rafId = null;
      }
    };
  },

  // For single meter subscription (more efficient)
  getLevelSnapshot(mixerId) {
    return vuLevels[mixerId] || null;
  }
};

Pattern 2: Direct DOM Updates with Refs

What: VU meter components use refs to directly manipulate DOM elements, bypassing React reconciliation. When to use: For visual updates that happen more than 30 times per second. Example:

// Source: React performance best practices
// Direct DOM manipulation for VU lights

function VuMeterDirect({ mixerId, lightCount = 16 }) {
  const containerRef = useRef(null);
  const lightsRef = useRef([]);

  // Subscribe to just this mixer's data
  useEffect(() => {
    let rafId = null;

    const updateVisuals = () => {
      const data = vuStore.getLevelSnapshot(mixerId);
      if (!data || !containerRef.current) return;

      const { level, clipping } = data;
      const activeLights = Math.round(level * lightCount);

      // Direct DOM manipulation - no React involvement
      lightsRef.current.forEach((light, i) => {
        if (!light) return;
        const isActive = (lightCount - 1 - i) < activeLights;
        const positionFromBottom = lightCount - 1 - i;

        // Remove all state classes
        light.classList.remove('vu-bg-success', 'vu-bg-warning', 'vu-bg-danger', 'vu-bg-secondary');

        if (isActive) {
          if (clipping) {
            light.classList.add('vu-bg-danger');
          } else if (positionFromBottom >= Math.floor(lightCount * 0.75)) {
            light.classList.add('vu-bg-danger');
          } else if (positionFromBottom >= Math.floor(lightCount * 0.5)) {
            light.classList.add('vu-bg-warning');
          } else {
            light.classList.add('vu-bg-success');
          }
        } else {
          light.classList.add('vu-bg-secondary');
        }
      });

      rafId = requestAnimationFrame(updateVisuals);
    };

    rafId = requestAnimationFrame(updateVisuals);
    return () => cancelAnimationFrame(rafId);
  }, [mixerId, lightCount]);

  // Render once, never re-render for VU updates
  return (
    <div ref={containerRef} className="vu">
      <div className="d-flex flex-column">
        {Array.from({ length: lightCount }).map((_, i) => (
          <div
            key={i}
            ref={el => lightsRef.current[i] = el}
            className="vu-bg-secondary"
            style={{ height: '10px', width: '25px', marginTop: '1px', borderRadius: '2px', border: '1px solid #eee' }}
          />
        ))}
      </div>
    </div>
  );
}

Pattern 3: useSyncExternalStore Hook Wrapper

What: A custom hook that safely subscribes React components to the external store. When to use: When components need to respond to VU changes (e.g., clipping indicators that need to trigger other UI). Example:

// Source: React official documentation for useSyncExternalStore
// src/hooks/useVuStore.js

import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { vuStore } from '../stores/vuStore';

// Subscribe to all VU levels (use sparingly - causes re-renders)
export function useAllVuLevels() {
  return useSyncExternalStore(
    vuStore.subscribe,
    vuStore.getSnapshot
  );
}

// Subscribe to single mixer - more efficient
export function useVuLevel(mixerId) {
  const getSnapshot = useCallback(() => vuStore.getLevelSnapshot(mixerId), [mixerId]);
  return useSyncExternalStore(
    vuStore.subscribe,
    getSnapshot
  );
}

Anti-Patterns to Avoid

  • useState for VU levels: Creates new state on every update, triggers reconciliation
  • Redux for VU data: 50-70 actions/sec overwhelms Redux DevTools and middleware
  • Context for high-frequency updates: Each update re-renders all consumers
  • Creating new objects in getSnapshot: Must return same reference if data unchanged
  • Multiple rAF loops: Centralize animation handling, don't create per-component loops

Don't Hand-Roll

Problems that look simple but have existing solutions:

Problem Don't Build Use Instead Why
External store subscription Custom subscription system useSyncExternalStore shim Handles concurrent rendering edge cases
Frame rate limiting setInterval/setTimeout requestAnimationFrame Syncs with display refresh, pauses when tab hidden
React 16 compatibility Custom useSyncExternalStore use-sync-external-store npm Official React team maintained

Key insight: The combination of external store + rAF batching + direct DOM refs is a well-established pattern for this exact problem (high-frequency visual updates). The useSyncExternalStore shim handles React's concurrent rendering complexities that are easy to get wrong with a custom implementation.

Common Pitfalls

Pitfall 1: getSnapshot Returns New Object Every Call

What goes wrong: Infinite re-render loop or excessive renders Why it happens: useSyncExternalStore uses Object.is() comparison; new objects always differ How to avoid: Store immutable snapshots, return the stored reference directly Warning signs: React DevTools shows constant re-renders, console shows "maximum update depth exceeded"

Pitfall 2: subscribe Function Recreated Every Render

What goes wrong: Unnecessary unsubscribe/resubscribe on every render Why it happens: Defining subscribe inside component creates new function reference each render How to avoid: Define subscribe outside component or use useCallback with stable dependencies Warning signs: VU meters flicker or drop updates during other component updates

Pitfall 3: Memory Leaks from Uncancelled Animation Frames

What goes wrong: rAF callbacks continue after component unmount, accessing null refs Why it happens: Missing cleanup in useEffect, or cleanup doesn't cancel all pending frames How to avoid: Store rafId in ref, cancel in cleanup, check refs before accessing Warning signs: Console errors about "cannot read property of null" after leaving session

Pitfall 4: Blocking Main Thread with DOM Reads/Writes

What goes wrong: Layout thrashing causes jank even at 60fps Why it happens: Interleaving offsetWidth reads with style writes forces browser reflow How to avoid: Batch all reads, then all writes; use classList instead of style for class changes Warning signs: React DevTools Profiler shows long "Commit" times, browser DevTools shows forced reflow

Pitfall 5: Not Handling Missing Mixers

What goes wrong: VU meter crashes when mixer removed mid-session Why it happens: Native client may remove mixer before React component unmounts How to avoid: Always check if mixer/level exists before updating, handle null gracefully Warning signs: Console errors during session participant leave

Code Examples

Verified patterns from official sources:

Bridge Callback Integration

// Source: Current codebase pattern + optimization
// Modify handleBridgeCallback in useMixerStore.js

import { vuStore } from '../stores/vuStore';

const handleBridgeCallback = useCallback((vuData) => {
  for (const vuInfo of vuData) {
    const eventName = vuInfo[0];
    if (eventName === "vu") {
      const mixerId = vuInfo[1];
      const mode = vuInfo[2];
      const leftValue = vuInfo[3];
      const leftClipping = vuInfo[4];

      // Convert dB to 0.0-1.0 range
      const normalizedLevel = (leftValue + 80) / 80;

      // Create qualified ID matching current pattern
      const fqId = (mode ? 'M' : 'P') + mixerId;

      // Update external store instead of React state
      vuStore.updateLevel(fqId, normalizedLevel, leftClipping);
    }
  }
}, []);

VU Context Simplified

// Source: Pattern from codebase + useSyncExternalStore best practices
// Modify VuContext.js

import React, { createContext, useContext } from 'react';
import { vuStore } from '../stores/vuStore';

const VuContext = createContext();

export const VuProvider = ({ children }) => {
  return (
    <VuContext.Provider value={vuStore}>
      {children}
    </VuContext.Provider>
  );
};

export const useVuContext = () => {
  const context = useContext(VuContext);
  if (!context) {
    throw new Error('useVuContext must be used within a VuProvider');
  }
  return context;
};

SessionTrackVU Optimized

// Source: Current component + direct DOM pattern
// Modify SessionTrackVU.js

import React, { useEffect, useRef, useCallback, memo } from 'react';
import { vuStore } from '../../stores/vuStore';

const SessionTrackVU = memo(function SessionTrackVU({
  lightCount = 16,
  orientation = 'vertical',
  lightWidth = 15,
  lightHeight = 10,
  mixers
}) {
  const containerRef = useRef(null);
  const lightsRef = useRef([]);
  const mixerIdRef = useRef(null);
  const rafIdRef = useRef(null);

  // Update mixerId when mixers change
  useEffect(() => {
    const mixer = mixers?.vuMixer;
    mixerIdRef.current = mixer ? `${mixer.mode ? 'M' : 'P'}${mixer.id}` : null;

    return () => {
      // Cleanup VU state on unmount
      if (mixerIdRef.current) {
        vuStore.removeLevel(mixerIdRef.current);
      }
    };
  }, [mixers]);

  // Animation loop for direct DOM updates
  useEffect(() => {
    const updateVisuals = () => {
      const mixerId = mixerIdRef.current;
      if (!mixerId) {
        rafIdRef.current = requestAnimationFrame(updateVisuals);
        return;
      }

      const data = vuStore.getLevelSnapshot(mixerId);
      const level = data?.level ?? 0;
      const clipping = data?.clipping ?? false;
      const activeLights = Math.round(level * lightCount);

      // Direct DOM manipulation
      for (let i = 0; i < lightCount; i++) {
        const light = lightsRef.current[i];
        if (!light) continue;

        const positionFromBottom = lightCount - 1 - i;
        const isActive = positionFromBottom < activeLights;

        // Use classList for efficient class manipulation
        light.className = isActive
          ? (clipping || positionFromBottom >= lightCount * 0.75)
            ? 'vu-light vu-bg-danger'
            : (positionFromBottom >= lightCount * 0.5)
              ? 'vu-light vu-bg-warning'
              : 'vu-light vu-bg-success'
          : 'vu-light vu-bg-secondary';
      }

      rafIdRef.current = requestAnimationFrame(updateVisuals);
    };

    rafIdRef.current = requestAnimationFrame(updateVisuals);

    return () => {
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
      }
    };
  }, [lightCount]);

  // Render only once - never re-renders for VU updates
  return (
    <div ref={containerRef} className="vu">
      <div className="d-flex flex-column" style={{ height: `${lightCount * (lightHeight + 1)}px` }}>
        {Array.from({ length: lightCount }).map((_, i) => (
          <div
            key={i}
            ref={el => lightsRef.current[i] = el}
            className="vu-light vu-bg-secondary"
            style={{
              height: `${lightHeight}px`,
              width: '25px',
              marginTop: '1px',
              borderRadius: '2px',
              border: '1px solid #eee'
            }}
          />
        ))}
      </div>
    </div>
  );
});

export default SessionTrackVU;

State of the Art

Old Approach Current Approach When Changed Impact
useState for frequent updates useSyncExternalStore + refs React 18 (2022) Eliminates reconciliation overhead
setInterval for animation requestAnimationFrame Browser standard Syncs with display, battery efficient
Inline styles in render classList manipulation Performance best practice Avoids style recalculation
Context for high-freq data External stores React 18 concurrent Prevents cascade re-renders

Deprecated/outdated:

  • ReactUpdates.injection.injectBatchingStrategy: This was a React internal that older batching solutions used. Not applicable to modern React.
  • componentWillReceiveProps for VU: Lifecycle methods are deprecated; use effects and refs instead.

Open Questions

Things that couldn't be fully resolved:

  1. Optimal rAF batching threshold

    • What we know: 60fps is standard display refresh, native client sends 50-70/sec (150ms refresh rate)
    • What's unclear: Whether we should batch to exactly 60fps or adapt based on actual update frequency
    • Recommendation: Start with rAF (60fps cap), measure performance, adjust if needed
  2. React DevTools Profiler verification approach

    • What we know: Requirement says "0 re-renders from VU updates"
    • What's unclear: Exact profiler setup to verify this claim
    • Recommendation: Document profiler steps in verification task, capture before/after screenshots
  3. Interaction with existing MixersContext re-renders

    • What we know: MixersContext was identified as creating new references every render (investigation finding)
    • What's unclear: Whether VU optimization alone is sufficient or MixersContext needs parallel fixes
    • Recommendation: VU optimization addresses VU-specific re-renders; MixersContext is separate scope

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • WebSearch results for "React VU meter" - Confirmed general patterns but no authoritative 2026 sources

Metadata

Confidence breakdown:

  • Standard stack: HIGH - Official React shim documented with version compatibility
  • Architecture: HIGH - Patterns verified against React official docs
  • Pitfalls: MEDIUM - Based on documented useSyncExternalStore caveats + general React performance patterns

Research date: 2026-03-03 Valid until: 2026-04-03 (30 days - stable patterns, no breaking changes expected)