From 9f25e05d87c0a26540764f19c83e2bfe2112a3c4 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Tue, 10 Mar 2026 23:50:10 +1300 Subject: [PATCH] perf: cap RAF-based callbacks to 60fps on high refresh rate displays On 144Hz+ displays, requestAnimationFrame fires ~144 times/sec. This adds a throttledRAF utility that limits callback execution to ~60fps regardless of display refresh rate, normalizing performance across 60Hz, 120Hz, and 144Hz monitors. Applied to: - workflowStore hover debounce (event-driven throttle) - FloatingNodeHeader position tracking (continuous loop throttle) - ModelParameters ResizeObserver (event-driven throttle) Co-Authored-By: Claude Opus 4.6 --- src/components/nodes/FloatingNodeHeader.tsx | 11 ++- src/components/nodes/ModelParameters.tsx | 8 +-- src/store/workflowStore.ts | 11 ++- src/utils/throttledRAF.ts | 75 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 17 deletions(-) create mode 100644 src/utils/throttledRAF.ts diff --git a/src/components/nodes/FloatingNodeHeader.tsx b/src/components/nodes/FloatingNodeHeader.tsx index bd5cb5d6..7aed7af2 100644 --- a/src/components/nodes/FloatingNodeHeader.tsx +++ b/src/components/nodes/FloatingNodeHeader.tsx @@ -7,6 +7,7 @@ import { NodeType, ProviderType } from "@/types"; import { useWorkflowStore } from "@/store/workflowStore"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; import { ProviderBadge } from "./ProviderBadge"; +import { createThrottledLoop } from "@/utils/throttledRAF"; export interface CommentNavigationProps { currentIndex: number; @@ -121,15 +122,11 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({ updatePosition(); - let animationId: number; - const trackPosition = () => { - updatePosition(); - animationId = requestAnimationFrame(trackPosition); - }; - animationId = requestAnimationFrame(trackPosition); + const loop = createThrottledLoop(updatePosition); + loop.start(); return () => { - cancelAnimationFrame(animationId); + loop.stop(); }; }, [showCommentTooltip, isCommentFocused]); diff --git a/src/components/nodes/ModelParameters.tsx b/src/components/nodes/ModelParameters.tsx index 6ca6667c..19ba3c93 100644 --- a/src/components/nodes/ModelParameters.tsx +++ b/src/components/nodes/ModelParameters.tsx @@ -5,6 +5,7 @@ import { ProviderType, ModelInputDef } from "@/types"; import { ModelParameter } from "@/lib/providers/types"; import { useProviderApiKeys } from "@/store/workflowStore"; import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; +import { createThrottledRAF } from "@/utils/throttledRAF"; // localStorage cache for model schemas (persists across dev server restarts) const SCHEMA_CACHE_KEY = "node-banana-schema-cache"; @@ -196,17 +197,16 @@ function ModelParametersInner({ useEffect(() => { const el = gridRef.current; if (!el || !useGrid) { setColCount(1); return; } - let rafId: number; + const throttle = createThrottledRAF(); const observer = new ResizeObserver(() => { - cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(() => { + throttle.schedule(() => { const cols = getComputedStyle(el).gridTemplateColumns.split(" ").length; setColCount(prev => prev === cols ? prev : cols); }); }); observer.observe(el); return () => { - cancelAnimationFrame(rafId); + throttle.cancel(); observer.disconnect(); }; }, [useGrid]); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index c0a74cc9..c4b23747 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -62,6 +62,7 @@ import { import { getConnectedInputsPure, validateWorkflowPure } from "./utils/connectedInputs"; import { evaluateRule } from "./utils/ruleEvaluation"; import { computeDimmedNodes } from "./utils/dimmingUtils"; +import { createThrottledRAF } from "@/utils/throttledRAF"; import { executeAnnotation, executeArray, @@ -382,9 +383,9 @@ let nodeIdCounter = 0; let groupIdCounter = 0; let autoSaveIntervalId: ReturnType | null = null; -// RAF debounce for hover updates — coalesces rapid mouseenter/mouseleave events -// into a single store update per animation frame -let hoverRafId: number | null = null; +// Throttled RAF for hover updates — coalesces rapid mouseenter/mouseleave events +// and caps at 60fps regardless of display refresh rate +const hoverRAF = createThrottledRAF(); // Track pending save-generation syncs to ensure IDs are resolved before workflow save const pendingImageSyncs = new Map>(); @@ -530,9 +531,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ }, setHoveredNodeId: (id: string | null) => { - if (hoverRafId !== null) cancelAnimationFrame(hoverRafId); - hoverRafId = requestAnimationFrame(() => { - hoverRafId = null; + hoverRAF.schedule(() => { if (get().hoveredNodeId !== id) set({ hoveredNodeId: id }); }); }, diff --git a/src/utils/throttledRAF.ts b/src/utils/throttledRAF.ts new file mode 100644 index 00000000..01b85b5a --- /dev/null +++ b/src/utils/throttledRAF.ts @@ -0,0 +1,75 @@ +const FRAME_BUDGET = 1000 / 60; // ~16.67ms → 60fps cap + +/** + * Creates a throttled requestAnimationFrame scheduler that limits + * callback execution to ~60fps regardless of display refresh rate. + * + * On 144Hz displays, RAF fires every ~6.9ms. This utility coalesces + * rapid schedule() calls and skips execution when less than one 60fps + * frame budget has elapsed, so only the latest callback runs at most + * once per ~16ms. + * + * For event-driven use (hover, ResizeObserver): the event source + * keeps firing, so skipped frames naturally get picked up by the + * next event. + */ +export function createThrottledRAF() { + let rafId: number | null = null; + let lastTime: number | null = null; + + function schedule(callback: () => void): void { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame((now) => { + rafId = null; + if (lastTime !== null && now - lastTime < FRAME_BUDGET) return; + lastTime = now; + callback(); + }); + } + + function cancel(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + return { schedule, cancel }; +} + +/** + * Creates a continuous animation loop throttled to ~60fps. + * + * Unlike createThrottledRAF (which is event-driven and relies on + * external events to re-trigger), this runs a self-scheduling loop + * that executes the callback at most once per ~16ms frame budget. + * The RAF loop itself runs at the display's native rate but skips + * work on intermediate frames. + */ +export function createThrottledLoop(callback: () => void) { + let rafId: number | null = null; + let lastTime = 0; + + function tick(now: number) { + if (now - lastTime >= FRAME_BUDGET) { + lastTime = now; + callback(); + } + rafId = requestAnimationFrame(tick); + } + + function start(): void { + if (rafId !== null) return; + lastTime = 0; // Execute immediately on first frame + rafId = requestAnimationFrame(tick); + } + + function stop(): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + return { start, stop }; +}