From d005f2e73b9aeab8c3019bf6f869f57838848221 Mon Sep 17 00:00:00 2001 From: RoyEden Date: Wed, 3 Dec 2025 09:38:36 -0300 Subject: [PATCH] feat(raf): add frameloop utils. - Added `useFrameloop` util to use unified request animation frame calls. - Added `createScheduledFrameloop` to handle request animation frame from external sources. - Fixed `createRAF` cleanup for id `0` by using `null` instead. --- packages/raf/package.json | 2 + packages/raf/src/index.ts | 126 +++++++++++++- packages/raf/test/index.test.ts | 280 +++++++++++++++++++++++++++++++- packages/raf/tsconfig.json | 6 + pnpm-lock.yaml | 6 + 5 files changed, 414 insertions(+), 6 deletions(-) diff --git a/packages/raf/package.json b/packages/raf/package.json index 9138d543e..54a231ff1 100644 --- a/packages/raf/package.json +++ b/packages/raf/package.json @@ -54,6 +54,8 @@ "primitives" ], "dependencies": { + "@solid-primitives/rootless": "workspace:^", + "@solid-primitives/set": "workspace:^", "@solid-primitives/utils": "workspace:^" }, "peerDependencies": { diff --git a/packages/raf/src/index.ts b/packages/raf/src/index.ts index e08e90c77..42a24c7cf 100644 --- a/packages/raf/src/index.ts +++ b/packages/raf/src/index.ts @@ -1,4 +1,6 @@ -import { type MaybeAccessor, noop } from "@solid-primitives/utils"; +import { createHydratableSingletonRoot } from "@solid-primitives/rootless"; +import { ReactiveSet } from "@solid-primitives/set"; +import { access, type MaybeAccessor, noop } from "@solid-primitives/utils"; import { createSignal, createMemo, type Accessor, onCleanup } from "solid-js"; import { isServer } from "solid-js/web"; @@ -23,7 +25,7 @@ function createRAF( return [() => false, noop, noop]; } const [running, setRunning] = createSignal(false); - let requestID = 0; + let requestID: number | null = null; const loop: FrameRequestCallback = timeStamp => { requestID = requestAnimationFrame(loop); @@ -36,7 +38,116 @@ function createRAF( }; const stop = () => { setRunning(false); - cancelAnimationFrame(requestID); + if (requestID !== null) cancelAnimationFrame(requestID); + }; + + onCleanup(stop); + return [running, start, stop]; +} + +/** + * Returns an advanced primitive factory function (that has an API similar to `createRAF`) to handle multiple animation frame callbacks in a single batched `requestAnimationFrame`, avoiding the overhead of scheduling multiple animation frames outside of a batch and making them all sync on the same delta. + * + * This is a [singleton root](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot) primitive. + * + * @returns Returns a factory function that works like `createRAF` but handles all scheduling in the same frame batch and optionally automatically starts and stops the global loop. + * ```ts + * (callback: FrameRequestCallback, automatic?: boolean) => [queued: Accessor, queue: VoidFunction, dequeue: VoidFunction, running: Accessor, start: VoidFunction, stop: VoidFunction] + * ``` + * + * @example + * const createScheduledFrame = useFrameloop(); + * + * const [queued, queue, dequeue, running, start, stop] = createScheduledFrame(() => { + * el.style.transform = "translateX(...)" + * }); + */ +const useFrameloop = createHydratableSingletonRoot< + ( + callback: FrameRequestCallback, + automatic?: MaybeAccessor, + ) => [ + queued: Accessor, + queue: VoidFunction, + dequeue: VoidFunction, + running: Accessor, + start: VoidFunction, + stop: VoidFunction, + ] +>(() => { + if (isServer) return () => [() => false, noop, noop, () => false, noop, noop]; + + const frameCallbacks = new ReactiveSet(); + + const [running, start, stop] = createRAF(delta => { + frameCallbacks.forEach(frameCallback => { + frameCallback(delta); + }); + }); + + return function createFrame(callback: FrameRequestCallback, automatic = false) { + const queued = () => frameCallbacks.has(callback); + const queue = () => { + frameCallbacks.add(callback); + if (access(automatic) && !running()) start(); + }; + const dequeue = () => { + frameCallbacks.delete(callback); + if (running() && frameCallbacks.size === 0) stop(); + }; + + onCleanup(dequeue); + return [queued, queue, dequeue, running, start, stop]; + }; +}); + +/** + * An advanced primitive creating reactive scheduled frameloops, for example [motion's frame util](https://motion.dev/docs/frame), that are automatically disposed onCleanup. + * + * The idea behind this is for more complex use cases, where you need scheduling and want to avoid potential issues arising from running more than one `requestAnimationFrame`. + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createScheduledFrameloop + * @param schedule The function that receives the callback and handles scheduling the frameloop + * @param cancel The function that cancels the scheduled callback + * @param callback The callback to run each scheduled frame + * @returns Returns a signal if currently running as well as start and stop methods + * ```ts + * [running: Accessor, start: VoidFunction, stop: VoidFunction] + * ``` + * + * @example + * import { type FrameData, cancelFrame, frame } from "motion"; + * + * const [running, start, stop] = createScheduledFrameloop( + * callback => frame.update(callback, true), + * cancelFrame, + * (data: FrameData) => { + * // Do something with the data.delta during the `update` phase. + * }, + * ); + */ +function createScheduledFrameloop< + RequestID extends NonNullable, + Callback extends (...args: Array) => any, +>( + schedule: (callback: Callback) => RequestID, + cancel: (requestID: RequestID) => void, + callback: Callback, +): [running: Accessor, start: VoidFunction, stop: VoidFunction] { + if (isServer) { + return [() => false, noop, noop]; + } + const [running, setRunning] = createSignal(false); + let requestID: RequestID | null = null; + + const start = () => { + if (running()) return; + setRunning(true); + requestID = schedule(callback); + }; + const stop = () => { + setRunning(false); + if (requestID !== null) cancel(requestID); }; onCleanup(stop); @@ -131,4 +242,11 @@ function createMs(fps: MaybeAccessor, limit?: MaybeAccessor): Ms return Object.assign(ms, { reset, running, start, stop }); } -export { createMs, createRAF, createRAF as default, targetFPS }; +export { + createMs, + createRAF, + createRAF as default, + createScheduledFrameloop, + targetFPS, + useFrameloop, +}; diff --git a/packages/raf/test/index.test.ts b/packages/raf/test/index.test.ts index 0a25f894c..77e50c0c8 100644 --- a/packages/raf/test/index.test.ts +++ b/packages/raf/test/index.test.ts @@ -1,20 +1,87 @@ -import { describe, it, expect, vi } from "vitest"; -import { createMs, createRAF, targetFPS } from "../src/index.js"; +import { describe, it, expect, vi, type Mock, beforeEach, afterEach } from "vitest"; +import { + createMs, + createRAF, + createScheduledFrameloop, + targetFPS, + useFrameloop, +} from "../src/index.js"; import { createRoot } from "solid-js"; describe("createRAF", () => { it("calls requestAnimationFrame after start", () => { const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); createRoot(() => { const [running, start, stop] = createRAF(ts => { expect(typeof ts === "number"); }); expect(running()).toBe(false); expect(raf).not.toHaveBeenCalled(); + expect(caf).not.toHaveBeenCalled(); start(); expect(running()).toBe(true); expect(raf).toHaveBeenCalled(); stop(); + expect(caf).toHaveBeenCalled(); + }); + }); + it("calls cancelAnimationFrame after dispose", () => { + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + createRoot(dispose => { + const [running, start] = createRAF(ts => { + expect(typeof ts === "number"); + }); + expect(running()).toBe(false); + expect(raf).not.toHaveBeenCalled(); + expect(caf).not.toHaveBeenCalled(); + start(); + expect(running()).toBe(true); + expect(raf).toHaveBeenCalled(); + dispose(); + expect(caf).toHaveBeenCalled(); + }); + }); +}); + +describe("createScheduledFrameloop", () => { + it("frameloop created with requestAnimationFrame calls requestAnimationFrame after start", () => { + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + createRoot(() => { + const [running, start, stop] = createScheduledFrameloop( + window.requestAnimationFrame, + window.cancelAnimationFrame, + ts => { + expect(typeof ts === "number"); + }, + ); + expect(running()).toBe(false); + expect(raf).not.toHaveBeenCalled(); + expect(caf).not.toHaveBeenCalled(); + start(); + expect(running()).toBe(true); + expect(raf).toHaveBeenCalled(); + stop(); + expect(caf).toHaveBeenCalled(); + }); + }); + it("frameloop created with requestAnimationFrame calls cancelAnimationFrame after dispose", () => { + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + createRoot(dispose => { + const [running, start] = createRAF(ts => { + expect(typeof ts === "number"); + }); + expect(running()).toBe(false); + expect(raf).not.toHaveBeenCalled(); + expect(caf).not.toHaveBeenCalled(); + start(); + expect(running()).toBe(true); + expect(raf).toHaveBeenCalled(); + dispose(); + expect(caf).toHaveBeenCalled(); }); }); }); @@ -35,6 +102,215 @@ describe("targetFPS", () => { }); }); +describe("useFrameloop", () => { + // Note: All frameloop roots need to be disposed before each test due to the underlying reactive set not working properly if not used like that + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it("(Manual execution) frameloop singleton calls rafs with the same timestamp", () => { + // Note on this test: For some reason, the raf is being called twice, once when started (but id doesn't invoke the callback for some strange reason) and once after the timers advance (and the callback is properly invoked). + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + createRoot(dispose => { + const timestamps = new Set(); + const createScheduledFrame = useFrameloop(); + const callback1: Mock = vi.fn(ts => timestamps.add(ts)); + const [queued1, queue1, dequeue1, running1, start1, stop1] = createScheduledFrame(callback1); + const callback2: Mock = vi.fn(ts => timestamps.add(ts)); + const [queued2, queue2, dequeue2, running2, start2, stop2] = createScheduledFrame(callback2); + + // Queue functions should not be equal + expect(queued1).not.toEqual(queued2); + expect(queue1).not.toEqual(queue2); + expect(dequeue1).not.toEqual(dequeue2); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + + // Frameloop functions should be equal because of the singleton + expect(running1).toEqual(running2); + expect(start1).toEqual(start2); + expect(stop1).toEqual(stop2); + + // Aliases + const running = running1; + const start = start1; + const stop = stop1; + + expect(queued1()).toBe(false); + queue1(); + expect(queued1()).toBe(true); + expect(queued2()).toBe(false); + expect(running()).toBe(false); + start(); + vi.advanceTimersToNextFrame(); + expect(running()).toBe(true); + expect(raf).toHaveBeenCalledTimes(2); + expect(callback1).toHaveBeenCalledTimes(1); + expect(timestamps.size).toEqual(1); + stop(); + expect(running()).toBe(false); + expect(caf).toHaveBeenCalledTimes(1); + queue2(); + expect(queued2()).toBe(true); + start(); + vi.advanceTimersToNextFrame(); + expect(raf).toHaveBeenCalledTimes(4); + expect(timestamps.size).toEqual(2); + stop(); + expect(caf).toHaveBeenCalledTimes(2); + dispose(); + }); + }); + it("(Manual execution) frameloop singleton skips calls when not queued / dequeued", () => { + // Note on this test: For some reason, the raf is being called twice, once when started (but id doesn't invoke the callback for some strange reason) and once after the timers advance (and the callback is properly invoked). + // Running the timer guarantees that the callback is properly tested for invokation + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + createRoot(dispose => { + const createScheduledFrame = useFrameloop(); + const callback: Mock = vi.fn(); + const [queued, queue, dequeue, running, start, stop] = createScheduledFrame(callback); + + function runFrame() { + expect(running()).toBe(false); + start(); + expect(running()).toBe(true); + vi.advanceTimersToNextFrame(); + stop(); + expect(running()).toBe(false); + } + + runFrame(); + queue(); + expect(queued()).toBe(true); + expect(running()).toBe(false); + dequeue(); + expect(queued()).toBe(false); + runFrame(); + expect(raf).toHaveBeenCalledTimes(4); + expect(caf).toHaveBeenCalledTimes(2); + expect(callback).not.toHaveBeenCalled(); + dispose(); + }); + }); + it("(Automatic execution) frameloop singleton calls rafs with the same timestamp", () => { + // Note on this test: For some reason, the raf is being called twice, once when started (but id doesn't invoke the callback for some strange reason) and once after the timers advance (and the callback is properly invoked). + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + createRoot(dispose => { + const timestamps = new Set(); + const createScheduledFrame = useFrameloop(); + const callback1: Mock = vi.fn(ts => timestamps.add(ts)); + const [queued1, queue1, dequeue1, running1, start1, stop1] = createScheduledFrame( + callback1, + true, + ); + const callback2: Mock = vi.fn(ts => timestamps.add(ts)); + const [queued2, queue2, dequeue2, running2, start2, stop2] = createScheduledFrame( + callback2, + true, + ); + + // Queue functions should not be equal + expect(queued1).not.toEqual(queued2); + expect(queue1).not.toEqual(queue2); + expect(dequeue1).not.toEqual(dequeue2); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + + // Frameloop functions should be equal because of the singleton + expect(running1).toEqual(running2); + expect(start1).toEqual(start2); + expect(stop1).toEqual(stop2); + + // Aliases + const running = running1; + const stop = stop1; + + expect(queued1()).toBe(false); + queue1(); + expect(queued1()).toBe(true); + expect(queued2()).toBe(false); + expect(running()).toBe(true); + vi.advanceTimersToNextFrame(); + expect(raf).toHaveBeenCalledTimes(2); + expect(callback1).toHaveBeenCalledTimes(1); + expect(timestamps.size).toEqual(1); + stop(); + expect(running()).toBe(false); + expect(caf).toHaveBeenCalledTimes(1); + queue2(); + expect(queued2()).toBe(true); + expect(running()).toBe(true); + vi.advanceTimersToNextFrame(); + expect(raf).toHaveBeenCalledTimes(4); + expect(timestamps.size).toEqual(2); + dequeue1(); + dequeue2(); + vi.waitUntil(() => { + expect(running()).toBe(false); + expect(caf).toHaveBeenCalledTimes(2); + }); + dispose(); + }); + }); + it("(Automatic execution) frameloop singleton skips calls when not queued / dequeued", () => { + // Running the timer guarantees that the callback is properly tested for invokation + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + createRoot(() => { + const createScheduledFrame = useFrameloop(); + const callback: Mock = vi.fn(); + const [_queued, _queue, _dequeue, running, start, stop] = createScheduledFrame( + callback, + true, + ); + + expect(running()).toBe(false); + start(); + expect(running()).toBe(true); + vi.advanceTimersToNextFrame(); + stop(); + expect(running()).toBe(false); + expect(raf).toHaveBeenCalledTimes(1); + expect(caf).toHaveBeenCalledTimes(1); + expect(callback).not.toHaveBeenCalled(); + }); + }); + it("(All) frameloop dispose stops the execution and dequeues", () => { + // Note on this test: For some reason, the raf is being called twice, once when started (but id doesn't invoke the callback for some strange reason) and once after the timers advance (and the callback is properly invoked). + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + + // Manual + createRoot(dispose => { + const createScheduledFrame = useFrameloop(); + const callback: Mock = vi.fn(); + const [queued, queue, _dequeue, running, start, _stop] = createScheduledFrame(callback); + + expect(queued()).toBe(false); + queue(); + expect(queued()).toBe(true); + expect(raf).not.toHaveBeenCalled(); + expect(caf).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + expect(running()).toBe(false); + start(); + expect(running()).toBe(true); + vi.advanceTimersToNextFrame(); + expect(callback).toHaveBeenCalledTimes(1); + expect(raf).toHaveBeenCalledTimes(2); + dispose(); + vi.waitUntil(() => { + expect(caf).toHaveBeenCalledTimes(1); + }); + }); + }); +}); + describe("createMs", () => { it("yields a timestamp starting at approximately zero", () => { createRoot(() => { diff --git a/packages/raf/tsconfig.json b/packages/raf/tsconfig.json index dc1970e16..edd6ba091 100644 --- a/packages/raf/tsconfig.json +++ b/packages/raf/tsconfig.json @@ -6,6 +6,12 @@ "rootDir": "src" }, "references": [ + { + "path": "../rootless" + }, + { + "path": "../set" + }, { "path": "../utils" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8651734b2..82054e4bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -721,6 +721,12 @@ importers: packages/raf: dependencies: + '@solid-primitives/rootless': + specifier: workspace:^ + version: link:../rootless + '@solid-primitives/set': + specifier: workspace:^ + version: link:../set '@solid-primitives/utils': specifier: workspace:^ version: link:../utils