/** * FPS monitoring hook for performance measurement * * Tracks both UI thread and JS thread FPS using Reanimated's useFrameCallback * and requestAnimationFrame respectively. * * @example * ```tsx * const { metrics, start, stop, reset, getSnapshot, getSnapshotImmediate } = useFPSMonitor(); * * // Start monitoring * start(); * * // During render (for live display) - uses React state, render-safe * const snapshot = getSnapshot(); * console.log(`Avg FPS: ${snapshot.avg}`); * * // For final capture (outside render) - reads SharedValue directly, accurate * const finalSnapshot = getSnapshotImmediate(); * * // Stop and reset * stop(); * reset(); * ``` */ import { useCallback, useRef, useEffect, useState } from 'react'; import { useSharedValue, useFrameCallback } from 'react-native-reanimated'; import type { FPSMetrics, FPSMonitorConfig } from '../types'; import { DEFAULT_FPS_METRICS, DEFAULT_FPS_MONITOR_CONFIG } from '../types'; /** * Circular buffer for storing frame timestamps * Used to calculate rolling FPS metrics * * OPTIMIZED: Pre-allocates reusable array to avoid per-frame GC pressure * OPTIMIZED: Tracks jank frames inline to avoid per-frame filter() allocation */ class FrameBuffer { private buffer: number[]; private index: number = 0; private count: number = 0; private readonly size: number; // Pre-allocated array for frame times to avoid per-frame allocation private readonly frameTimes: number[]; // Rolling window jank tracking - avoids unbounded counter accumulation private readonly jankFlags: boolean[]; private jankFramesInWindow: number = 0; private jankThreshold: number = 55; // FPS below this is considered jank constructor(size: number) { this.size = size; this.buffer = new Array(size).fill(0); this.frameTimes = new Array(size).fill(0); this.jankFlags = new Array(size).fill(false); } setJankThreshold(threshold: number): void { this.jankThreshold = threshold; } push(timestamp: number): void { this.buffer[this.index] = timestamp; this.index = (this.index + 1) % this.size; if (this.count < this.size) { this.count++; } } /** * Push a frame and track jank in rolling window. * Call this instead of push() when you want to track jank. */ pushWithJank(timestamp: number, frameTimeMs: number): void { // If overwriting an old frame, check if it was jank if (this.count >= this.size) { if (this.jankFlags[this.index]) { this.jankFramesInWindow--; } } // Check if this frame is jank (FPS below threshold) const fps = frameTimeMs > 0 ? 1000 / frameTimeMs : 0; const isJank = fps > 0 && fps < this.jankThreshold; this.buffer[this.index] = timestamp; this.jankFlags[this.index] = isJank; if (isJank) { this.jankFramesInWindow++; } this.index = (this.index + 1) % this.size; if (this.count < this.size) { this.count++; } } /** * Get jank percentage from rolling window (last N frames). * This replaces the unbounded jankCount/totalFrames accumulation. */ getJankPercentage(): number { return this.count > 0 ? (this.jankFramesInWindow / this.count) * 100 : 0; } getJankFramesInWindow(): number { return this.jankFramesInWindow; } /** * Returns frame times (deltas between consecutive frames) * OPTIMIZED: Reuses pre-allocated array, only allocates on final slice */ getFrameTimes(): number[] { if (this.count === 0) return []; const start = this.count < this.size ? 0 : this.index; let writeIdx = 0; for (let i = 1; i < this.count; i++) { const prevIdx = (start + i - 1) % this.size; const currIdx = (start + i) % this.size; const delta = this.buffer[currIdx] - this.buffer[prevIdx]; if (delta > 0) { this.frameTimes[writeIdx++] = delta; } } // Only allocate on return - this is unavoidable but happens less frequently return this.frameTimes.slice(0, writeIdx); } clear(): void { this.buffer.fill(0); this.jankFlags.fill(false); this.index = 0; this.count = 0; this.jankFramesInWindow = 0; } getCount(): number { return this.count; } } export interface UseFPSMonitorResult { /** SharedValue containing current FPS metrics (for UI thread access) */ metrics: ReturnType>; /** Start FPS monitoring */ start: () => void; /** Stop FPS monitoring */ stop: () => void; /** Reset all metrics */ reset: () => void; /** Render-safe snapshot (uses React state, may be slightly stale due to throttling) */ getSnapshot: () => FPSMetrics; /** Direct SharedValue read (accurate but NOT render-safe - use for final capture only) */ getSnapshotImmediate: () => FPSMetrics; /** Whether monitoring is currently active */ isActive: boolean; } /** * Hook for monitoring FPS on both UI and JS threads * * Uses: * - useFrameCallback for UI thread FPS (Reanimated pattern) * - requestAnimationFrame for JS thread FPS * * @param config - Optional configuration for thresholds and buffer size */ export function useFPSMonitor( config: Partial = {}, ): UseFPSMonitorResult { const { jankThreshold, bufferSize } = { ...DEFAULT_FPS_MONITOR_CONFIG, ...config, }; // SharedValue for UI thread access const metrics = useSharedValue(DEFAULT_FPS_METRICS); // SharedValue for cross-thread active state (can't use ref with worklets) const isActiveShared = useSharedValue(false); // State for triggering React re-renders const [stateMetrics, setStateMetrics] = useState(DEFAULT_FPS_METRICS); // Refs for JS thread state (NOT used in worklets) const uiFrameBuffer = useRef(new FrameBuffer(bufferSize)); const jsFrameBuffer = useRef(new FrameBuffer(bufferSize)); const jsRafId = useRef(null); const lastJsTimestamp = useRef(0); // JS-thread active tracking (separate from SharedValue to avoid worklet issues) const isActiveJS = useRef(false); // Update counter for throttling state updates const updateCounter = useRef(0); // Frame counter for throttling heavy metrics computation const frameCounter = useRef(0); // Set jank threshold on the buffer uiFrameBuffer.current.setJankThreshold(jankThreshold); // Calculate FPS from frame times // OPTIMIZED: Avoid array allocations (map, spread) by using a single loop const calculateFPS = useCallback((frameTimes: number[]): { avg: number; min: number; max: number } => { if (frameTimes.length === 0) { return { avg: 0, min: 0, max: 0 }; } // Single loop: calculate sum, min, max without intermediate arrays let sum = 0; let min = Infinity; let max = -Infinity; for (let i = 0; i < frameTimes.length; i++) { const fps = frameTimes[i] > 0 ? 1000 / frameTimes[i] : 0; sum += fps; if (fps < min) min = fps; if (fps > max) max = fps; } return { avg: Math.round((sum / frameTimes.length) * 10) / 10, min: Math.round(min * 10) / 10, max: Math.round(max * 10) / 10, }; }, []); // Update metrics from frame buffers const updateMetrics = useCallback(() => { const uiFrameTimes = uiFrameBuffer.current.getFrameTimes(); const uiFPS = calculateFPS(uiFrameTimes); // Use rolling window jank from FrameBuffer (no per-frame filter() allocation) const jankPercentage = Math.round(uiFrameBuffer.current.getJankPercentage() * 10) / 10; const jankFramesInWindow = uiFrameBuffer.current.getJankFramesInWindow(); const totalFramesInWindow = uiFrameBuffer.current.getCount(); // Get current FPS from last frame time const lastFrameTime = uiFrameTimes[uiFrameTimes.length - 1]; const currentFPS = lastFrameTime > 0 ? Math.round((1000 / lastFrameTime) * 10) / 10 : 0; const newMetrics: FPSMetrics = { current: currentFPS, avg: uiFPS.avg, min: uiFPS.min, max: uiFPS.max, jankFrames: jankFramesInWindow, totalFrames: totalFramesInWindow, jankPercentage, isActive: isActiveJS.current, }; // Update SharedValue (always) metrics.value = newMetrics; // Update React state (throttled to ~2fps to minimize re-render overhead) // Changed from every 6 frames (~10/sec) to every 30 frames (~2/sec) updateCounter.current += 1; // Force immediate update on first data, then throttle to every 30 calls (~500ms) if (updateCounter.current === 1 || updateCounter.current % 30 === 0) { setStateMetrics(newMetrics); } }, [calculateFPS, metrics]); // UI thread frame callback (via Reanimated) const frameCallback = useFrameCallback( () => { 'worklet'; if (!isActiveShared.value) return; // The actual measurement happens on JS thread via RAF // This is here for future UI thread measurement expansion }, false, // Don't auto-start ); // JS thread RAF loop for measuring JS thread FPS const jsFrameLoop = useCallback( (timestamp: number) => { if (!isActiveJS.current) return; if (lastJsTimestamp.current > 0) { // Calculate frame time delta for jank tracking const frameTimeMs = timestamp - lastJsTimestamp.current; // O(1) - just push timestamp (every frame) uiFrameBuffer.current.pushWithJank(timestamp, frameTimeMs); jsFrameBuffer.current.push(timestamp); // O(120) - only compute metrics every 6 frames (~10fps updates) // This reduces heavy computation from 60x/sec to 10x/sec frameCounter.current += 1; // Frame 2 = first frame with actual data (need 2 timestamps for 1 delta) // Then every 6 frames after that if (frameCounter.current === 2 || frameCounter.current % 6 === 0) { updateMetrics(); } } lastJsTimestamp.current = timestamp; jsRafId.current = requestAnimationFrame(jsFrameLoop); }, [updateMetrics], ); // Start monitoring const start = useCallback(() => { if (isActiveJS.current) return; isActiveJS.current = true; isActiveShared.value = true; lastJsTimestamp.current = 0; updateCounter.current = 0; frameCounter.current = 0; // Start frame callback (UI thread) frameCallback.setActive(true); // Start RAF loop (JS thread) jsRafId.current = requestAnimationFrame(jsFrameLoop); // Update metrics to reflect active state const activeMetrics = { ...DEFAULT_FPS_METRICS, isActive: true, }; metrics.value = activeMetrics; setStateMetrics(activeMetrics); }, [frameCallback, jsFrameLoop, metrics, isActiveShared]); // Stop monitoring const stop = useCallback(() => { if (!isActiveJS.current) return; isActiveJS.current = false; isActiveShared.value = false; // Stop frame callback frameCallback.setActive(false); // Cancel RAF if (jsRafId.current !== null) { cancelAnimationFrame(jsRafId.current); jsRafId.current = null; } // Final state update with inactive flag const finalMetrics = { ...metrics.value, isActive: false, }; metrics.value = finalMetrics; setStateMetrics(finalMetrics); }, [frameCallback, metrics, isActiveShared]); // Reset all metrics const reset = useCallback(() => { uiFrameBuffer.current.clear(); jsFrameBuffer.current.clear(); lastJsTimestamp.current = 0; updateCounter.current = 0; frameCounter.current = 0; const resetMetrics = { ...DEFAULT_FPS_METRICS, isActive: isActiveJS.current, }; metrics.value = resetMetrics; setStateMetrics(resetMetrics); }, [metrics]); // Render-safe snapshot (uses React state) // May be slightly stale due to throttled updates (~2fps), but safe to call during render const getSnapshot = useCallback((): FPSMetrics => { return stateMetrics; }, [stateMetrics]); // Direct SharedValue read (accurate but NOT render-safe) // Use this for final benchmark capture, NOT during React render (triggers Reanimated warning) const getSnapshotImmediate = useCallback((): FPSMetrics => { return metrics.value; }, [metrics]); // Cleanup on unmount useEffect(() => { return () => { if (jsRafId.current !== null) { cancelAnimationFrame(jsRafId.current); } frameCallback.setActive(false); }; }, [frameCallback]); return { metrics, start, stop, reset, getSnapshot, getSnapshotImmediate, isActive: isActiveJS.current, }; }