Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
9f25e05d87
  1. 11
      src/components/nodes/FloatingNodeHeader.tsx
  2. 8
      src/components/nodes/ModelParameters.tsx
  3. 11
      src/store/workflowStore.ts
  4. 75
      src/utils/throttledRAF.ts

11
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]);

8
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]);

11
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<typeof setInterval> | 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<string, Promise<void>>();
@ -530,9 +531,7 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
},
setHoveredNodeId: (id: string | null) => {
if (hoverRafId !== null) cancelAnimationFrame(hoverRafId);
hoverRafId = requestAnimationFrame(() => {
hoverRafId = null;
hoverRAF.schedule(() => {
if (get().hoveredNodeId !== id) set({ hoveredNodeId: id });
});
},

75
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 };
}
Loading…
Cancel
Save