docs(28): research VU meter optimization phase domain
Phase 28: VU Meter Optimization - Standard stack identified (use-sync-external-store shim, requestAnimationFrame) - Architecture patterns documented (external store, direct DOM updates with refs) - Pitfalls catalogued (getSnapshot object creation, rAF cleanup, layout thrashing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9174d161c6
commit
e8c5a213c6
|
|
@ -0,0 +1,506 @@
|
|||
# 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:**
|
||||
```bash
|
||||
cd jam-ui
|
||||
npm install use-sync-external-store
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
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:**
|
||||
```javascript
|
||||
// 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:**
|
||||
```javascript
|
||||
// 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:**
|
||||
```javascript
|
||||
// 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
|
||||
```javascript
|
||||
// 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
|
||||
```javascript
|
||||
// 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
|
||||
```javascript
|
||||
// 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)
|
||||
- [React useSyncExternalStore official docs](https://react.dev/reference/react/useSyncExternalStore) - API, patterns, caveats
|
||||
- [use-sync-external-store npm (React team)](https://github.com/facebook/react/blob/main/packages/use-sync-external-store/package.json) - Shim compatibility: React 16.8+
|
||||
- [MDN requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) - Browser API specification
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [Epic React - useSyncExternalStore Demystified](https://www.epicreact.dev/use-sync-external-store-demystified-for-practical-react-development-w5ac0) - Implementation patterns
|
||||
- [OpenReplay - requestAnimationFrame in React](https://blog.openreplay.com/use-requestanimationframe-in-react-for-smoothest-animations/) - Hook patterns and cleanup
|
||||
- [SitePoint - Streaming Backends & React](https://www.sitepoint.com/streaming-backends-react-controlling-re-render-chaos/) - High-frequency data patterns
|
||||
|
||||
### 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)
|
||||
Loading…
Reference in New Issue