diff --git a/src/components/nodes/FloatingNodeHeader.tsx b/src/components/nodes/FloatingNodeHeader.tsx index 7aed7af2..bd5cb5d6 100644 --- a/src/components/nodes/FloatingNodeHeader.tsx +++ b/src/components/nodes/FloatingNodeHeader.tsx @@ -7,7 +7,6 @@ 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; @@ -122,11 +121,15 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({ updatePosition(); - const loop = createThrottledLoop(updatePosition); - loop.start(); + let animationId: number; + const trackPosition = () => { + updatePosition(); + animationId = requestAnimationFrame(trackPosition); + }; + animationId = requestAnimationFrame(trackPosition); return () => { - loop.stop(); + cancelAnimationFrame(animationId); }; }, [showCommentTooltip, isCommentFocused]); diff --git a/src/components/nodes/ModelParameters.tsx b/src/components/nodes/ModelParameters.tsx index 19ba3c93..6ca6667c 100644 --- a/src/components/nodes/ModelParameters.tsx +++ b/src/components/nodes/ModelParameters.tsx @@ -5,7 +5,6 @@ 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"; @@ -197,16 +196,17 @@ function ModelParametersInner({ useEffect(() => { const el = gridRef.current; if (!el || !useGrid) { setColCount(1); return; } - const throttle = createThrottledRAF(); + let rafId: number; const observer = new ResizeObserver(() => { - throttle.schedule(() => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { const cols = getComputedStyle(el).gridTemplateColumns.split(" ").length; setColCount(prev => prev === cols ? prev : cols); }); }); observer.observe(el); return () => { - throttle.cancel(); + cancelAnimationFrame(rafId); observer.disconnect(); }; }, [useGrid]); diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index c4b23747..c0a74cc9 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -62,7 +62,6 @@ 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, @@ -383,9 +382,9 @@ let nodeIdCounter = 0; let groupIdCounter = 0; let autoSaveIntervalId: ReturnType | null = null; -// Throttled RAF for hover updates — coalesces rapid mouseenter/mouseleave events -// and caps at 60fps regardless of display refresh rate -const hoverRAF = createThrottledRAF(); +// RAF debounce for hover updates — coalesces rapid mouseenter/mouseleave events +// into a single store update per animation frame +let hoverRafId: number | null = null; // Track pending save-generation syncs to ensure IDs are resolved before workflow save const pendingImageSyncs = new Map>(); @@ -531,7 +530,9 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ }, setHoveredNodeId: (id: string | null) => { - hoverRAF.schedule(() => { + if (hoverRafId !== null) cancelAnimationFrame(hoverRafId); + hoverRafId = requestAnimationFrame(() => { + hoverRafId = null; if (get().hoveredNodeId !== id) set({ hoveredNodeId: id }); }); }, diff --git a/src/utils/throttledRAF.ts b/src/utils/throttledRAF.ts deleted file mode 100644 index 01b85b5a..00000000 --- a/src/utils/throttledRAF.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 }; -}