From d005f2e73b9aeab8c3019bf6f869f57838848221 Mon Sep 17 00:00:00 2001 From: RoyEden Date: Wed, 3 Dec 2025 09:38:36 -0300 Subject: [PATCH 1/2] 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 From 1a8b2e8414394f24575c142853ca8adf3d3bde6f Mon Sep 17 00:00:00 2001 From: Royeden Date: Tue, 30 Dec 2025 10:24:29 -0300 Subject: [PATCH 2/2] feat(raf): Addressed PR comments, renamed new functions and added documentation. --- packages/raf/README.md | 123 +++++++++++++++++++++++++ packages/raf/src/index.ts | 153 ++++++++++++++++++-------------- packages/raf/test/index.test.ts | 115 +++++++++++------------- 3 files changed, 264 insertions(+), 127 deletions(-) diff --git a/packages/raf/README.md b/packages/raf/README.md index c524b595e..721f66652 100644 --- a/packages/raf/README.md +++ b/packages/raf/README.md @@ -117,6 +117,129 @@ function createMs( }; ``` +## createCallbacksSet + +A primitive for executing multiple callbacks at once, intended for usage in conjunction with primitives like `createRAF`, where you want to execute multiple callbacks in the same `window.requestAnimationFrame` (sharing the timestamp). + +#### Definition + +```ts +function createCallbacksSet void>(...initialCallbacks: Array): [callback: T, callbacksSet: ReactiveSet] +``` + +## useGlobalRAF + +A singleton root that returns a function similar to `createRAF` that batches multiple `window.requestAnimationFrame` executions within the same same timestamp (same RAF cycle) instead of skipping requests in separate frames. This is done by using a single `createRAF` in a [singleton root](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot) in conjuction with [`createCallbacksSet`](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createCallbacksSet) + +Returns a factory function that works like `createRAF` with an additional parameter to start the global RAF loop when adding the callback to the callbacks set. This function return is also similar to `createRAF`, but it's first three elements of the tuple are related to the presence of the callback in the callbacks set, while the next three are the same as `createRAF`, but for the global loop that executes all the callbacks present in the callbacks set. + +```ts +import { useGlobalRAF } from "@solid-primitives/raf"; + +const createScheduledLoop = useGlobalRAF() +const [hasAddedManual, addManual, removeManual, isRunningManual] = createScheduledLoop( + timeStamp => console.log("Time stamp is", timeStamp) +); +const [hasAddedAuto, addAuto, removeAuto, isRunningAuto] = createScheduledLoop( + timeStamp => console.log("Time stamp is", timeStamp), + true +); + +hasAddedManual() // false +addManual() +hasAddedManual() // true +isRunningManual() // false + +hasAddedAuto() // false +addAuto() +hasAddedAuto() // true +// Both are running on the same global loop +isRunningAuto() // true +isRunningManual() // true +``` + +#### Example + +```ts +import { targetFPS, useGlobalRAF } from "@solid-primitives/raf"; + +const createScheduledLoop = useGlobalRAF() + +const [hasAddedLowFramerate, addLowFramerate, removeLowFramerate] = createScheduledLoop( + targetFPS( + () => { + /* Low framerate loop, for example for video / webcam sampling where the framerate can be capped by external sources */ + }, + 30 + ), + true +); +const [hasAddedHighFramerate, addHighFramerate, removeHighFramerate] = createScheduledLoop( + targetFPS( + () => { + /* High framerate loop for an animation / drawing to a canvas */ + }, + 60 + ), + true +); +``` + +#### Definition + +```ts +function useGlobalRAF(): (callback: FrameRequestCallback, startWhenAdded?: MaybeAccessor) => [ + added: Accessor, + add: VoidFunction, + remove: VoidFunction, + running: Accessor, + start: VoidFunction, + stop: VoidFunction +]; +``` + +#### Warning + +Only use this when you absolutely need to schedule animations on the same frame and stick to quick executions trying not to overload the amount of work performed in the current animation frame. If you need to ensure multiple things run on the same request but you also want to schedule multiple requests, you can use [`createCallbacksSet`](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createCallbacksSet) and a singleton `createRAF` to compose something similar to this primitive. + +## createScheduledLoop + +A primitive for creating reactive interactions with external frameloop related functions (for example using [motion's frame util](https://motion.dev/docs/frame)) that are automatically disposed onCleanup. + +```ts +import { cancelFrame, frame } from "motion"; + +const createMotionFrameRender = createScheduledLoop( + callback => frame.render(callback, true), + cancelFrame, +); +const [running, start, stop] = createMotionFrameRender( + data => element.style.transform = "translateX(...)" +); + +// Alternative syntax (for a single execution in place): +import { cancelFrame, frame } from "motion"; + +const [running, start, stop] = createScheduledLoop( + callback => frame.render(callback, true), + cancelFrame, +)( + data => element.style.transform = "translateX(...)" +); +``` + +#### Definition + +```ts +function createScheduledLoop< + RequestID extends NonNullable, + Callback extends (...args: Array) => any, +>( + schedule: (callback: Callback) => RequestID, + cancel: (requestID: RequestID) => void, +): (callback: Callback) => [running: Accessor, start: VoidFunction, stop: VoidFunction] +``` + ## Demo You may view a working example here: https://codesandbox.io/s/solid-primitives-raf-demo-4xvmjd?file=/src/index.tsx diff --git a/packages/raf/src/index.ts b/packages/raf/src/index.ts index 42a24c7cf..17056ab95 100644 --- a/packages/raf/src/index.ts +++ b/packages/raf/src/index.ts @@ -46,30 +46,49 @@ function createRAF( } /** - * 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. + * A primitive for executing multiple callbacks at once, intended for usage in conjunction with primitives like `createRAF`. + * @param initialCallbacks + * @returns a main callback function that executes all the callbacks at once, as well as the `ReactiveSet` that contains all the callbacks + * ```ts + * [callback: T, callbacksSet: ReactiveSet] + * ``` + */ +function createCallbacksSet void>( + ...initialCallbacks: Array +): [callback: T, callbacksSet: ReactiveSet] { + const callbacksSet = new ReactiveSet(initialCallbacks); + + return [((...args) => callbacksSet.forEach(callback => callback(...args))) as T, callbacksSet]; +} + +/** + * A singleton root that returns a function similar to `createRAF` that batches multiple `window.requestAnimationFrame` executions within the same same timestamp (same RAF cycle) instead of skipping requests in separate frames. This is done by using a single `createRAF` in a [singleton root](https://github.com/solidjs-community/solid-primitives/tree/main/packages/rootless#createSingletonRoot) in conjuction with [`createCallbacksSet`](https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createCallbacksSet) * - * @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. + * @returns Returns a factory function that works like `createRAF` with an additional parameter to start the global RAF loop when adding the callback to the callbacks set. This function return is also similar to `createRAF`, but it's first three elements of the tuple are related to the presence of the callback in the callbacks set, while the next three are the same as `createRAF`, but for the global loop that executes all the callbacks present in the callbacks set. * ```ts - * (callback: FrameRequestCallback, automatic?: boolean) => [queued: Accessor, queue: VoidFunction, dequeue: VoidFunction, running: Accessor, start: VoidFunction, stop: VoidFunction] + * (callback: FrameRequestCallback, startWhenAdded?: boolean) => [added: Accessor, add: VoidFunction, remove: VoidFunction, running: Accessor, start: VoidFunction, stop: VoidFunction] * ``` * * @example - * const createScheduledFrame = useFrameloop(); + * const createGlobalRAFCallback = useGlobalRAF(); * - * const [queued, queue, dequeue, running, start, stop] = createScheduledFrame(() => { + * const [added, add, remove, running, start, stop] = createGlobalRAFCallback(() => { * el.style.transform = "translateX(...)" * }); + * + * // Usage with targetFPS + * const [added, add, remove, running, start, stop] = createGlobalRAFCallback(targetFPS(() => { + * el.style.transform = "translateX(...)" + * }, 60)); */ -const useFrameloop = createHydratableSingletonRoot< +const useGlobalRAF = createHydratableSingletonRoot< ( callback: FrameRequestCallback, - automatic?: MaybeAccessor, + startWhenAdded?: MaybeAccessor, ) => [ - queued: Accessor, - queue: VoidFunction, - dequeue: VoidFunction, + added: Accessor, + add: VoidFunction, + remove: VoidFunction, running: Accessor, start: VoidFunction, stop: VoidFunction, @@ -77,81 +96,84 @@ const useFrameloop = createHydratableSingletonRoot< >(() => { if (isServer) return () => [() => false, noop, noop, () => false, noop, noop]; - const frameCallbacks = new ReactiveSet(); + const [callback, callbacksSet] = createCallbacksSet(); + const [running, start, stop] = createRAF(callback); - 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(); + return function createGlobalRAFCallback(callback: FrameRequestCallback, startWhenAdded = false) { + const added = () => callbacksSet.has(callback); + const add = () => { + callbacksSet.add(callback); + if (access(startWhenAdded) && !running()) start(); }; - const dequeue = () => { - frameCallbacks.delete(callback); - if (running() && frameCallbacks.size === 0) stop(); + const remove = () => { + callbacksSet.delete(callback); + if (running() && callbacksSet.size === 0) stop(); }; - onCleanup(dequeue); - return [queued, queue, dequeue, running, start, stop]; + onCleanup(remove); + return [added, add, remove, 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`. + * A primitive for creating reactive interactions with external frameloop related functions (for example using [motion's frame util](https://motion.dev/docs/frame)) that are automatically disposed onCleanup. * - * @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 + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/raf#createScheduledLoop + * @param schedule The function that receives the callback and handles it's loop scheduling, returning a requestID that is used to cancel the loop + * @param cancel The function that cancels the scheduled callback using the requestID. + * @returns Returns a function that receives a callback that's compatible with the provided scheduler and returns a signal if currently running as well as start and stop methods * ```ts - * [running: Accessor, start: VoidFunction, stop: VoidFunction] + * (callback: Callback) => [running: Accessor, start: VoidFunction, stop: VoidFunction] * ``` * * @example - * import { type FrameData, cancelFrame, frame } from "motion"; + * import { cancelFrame, frame } from "motion"; + * + * const createMotionFrameRender = createScheduledLoop( + * callback => frame.render(callback, true), + * cancelFrame, + * ); + * const [running, start, stop] = createMotionFrameRender( + * data => element.style.transform = "translateX(...)" + * ); + * + * // Alternative syntax (for a single execution in place): + * import { cancelFrame, frame } from "motion"; * - * const [running, start, stop] = createScheduledFrameloop( - * callback => frame.update(callback, true), + * const [running, start, stop] = createScheduledLoop( + * callback => frame.render(callback, true), * cancelFrame, - * (data: FrameData) => { - * // Do something with the data.delta during the `update` phase. - * }, + * )( + * data => element.style.transform = "translateX(...)" * ); */ -function createScheduledFrameloop< +function createScheduledLoop< 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; +): (callback: Callback) => [running: Accessor, start: VoidFunction, stop: VoidFunction] { + return (callback: Callback) => { + 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); - }; + const start = () => { + if (running()) return; + setRunning(true); + requestID = schedule(callback); + }; + const stop = () => { + setRunning(false); + if (requestID !== null) cancel(requestID); + }; - onCleanup(stop); - return [running, start, stop]; + onCleanup(stop); + return [running, start, stop]; + }; } /** @@ -244,9 +266,10 @@ function createMs(fps: MaybeAccessor, limit?: MaybeAccessor): Ms export { createMs, + createCallbacksSet, createRAF, createRAF as default, - createScheduledFrameloop, + createScheduledLoop, targetFPS, - useFrameloop, + useGlobalRAF, }; diff --git a/packages/raf/test/index.test.ts b/packages/raf/test/index.test.ts index 77e50c0c8..868a80ef9 100644 --- a/packages/raf/test/index.test.ts +++ b/packages/raf/test/index.test.ts @@ -1,11 +1,5 @@ import { describe, it, expect, vi, type Mock, beforeEach, afterEach } from "vitest"; -import { - createMs, - createRAF, - createScheduledFrameloop, - targetFPS, - useFrameloop, -} from "../src/index.js"; +import { createMs, createRAF, createScheduledLoop, targetFPS, useGlobalRAF } from "../src/index.js"; import { createRoot } from "solid-js"; describe("createRAF", () => { @@ -45,18 +39,18 @@ describe("createRAF", () => { }); }); -describe("createScheduledFrameloop", () => { +describe("createScheduledLoop", () => { 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( + // TODO add better test + const [running, start, stop] = createScheduledLoop( window.requestAnimationFrame, window.cancelAnimationFrame, - ts => { - expect(typeof ts === "number"); - }, - ); + )(ts => { + expect(typeof ts === "number"); + }); expect(running()).toBe(false); expect(raf).not.toHaveBeenCalled(); expect(caf).not.toHaveBeenCalled(); @@ -102,30 +96,30 @@ 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 +describe("useGlobalRAF", () => { + // Note: All roots need to be disposed before each test due to the underlying reactive set not working properly if we don't beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.restoreAllMocks(); }); - it("(Manual execution) frameloop singleton calls rafs with the same timestamp", () => { + it("(Manual start) global RAF 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 createGlobalRAFCallback = useGlobalRAF(); const callback1: Mock = vi.fn(ts => timestamps.add(ts)); - const [queued1, queue1, dequeue1, running1, start1, stop1] = createScheduledFrame(callback1); + const [added1, add1, remove1, running1, start1, stop1] = createGlobalRAFCallback(callback1); const callback2: Mock = vi.fn(ts => timestamps.add(ts)); - const [queued2, queue2, dequeue2, running2, start2, stop2] = createScheduledFrame(callback2); + const [added2, add2, remove2, running2, start2, stop2] = createGlobalRAFCallback(callback2); // Queue functions should not be equal - expect(queued1).not.toEqual(queued2); - expect(queue1).not.toEqual(queue2); - expect(dequeue1).not.toEqual(dequeue2); + expect(added1).not.toEqual(added2); + expect(add1).not.toEqual(add2); + expect(remove1).not.toEqual(remove2); expect(callback1).not.toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); @@ -139,10 +133,10 @@ describe("useFrameloop", () => { const start = start1; const stop = stop1; - expect(queued1()).toBe(false); - queue1(); - expect(queued1()).toBe(true); - expect(queued2()).toBe(false); + expect(added1()).toBe(false); + add1(); + expect(added1()).toBe(true); + expect(added2()).toBe(false); expect(running()).toBe(false); start(); vi.advanceTimersToNextFrame(); @@ -153,8 +147,8 @@ describe("useFrameloop", () => { stop(); expect(running()).toBe(false); expect(caf).toHaveBeenCalledTimes(1); - queue2(); - expect(queued2()).toBe(true); + add2(); + expect(added2()).toBe(true); start(); vi.advanceTimersToNextFrame(); expect(raf).toHaveBeenCalledTimes(4); @@ -164,15 +158,15 @@ describe("useFrameloop", () => { dispose(); }); }); - it("(Manual execution) frameloop singleton skips calls when not queued / dequeued", () => { + it("(Manual start) global RAF singleton skips callbacks when not added", () => { // 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 createGlobalRAFCallback = useGlobalRAF(); const callback: Mock = vi.fn(); - const [queued, queue, dequeue, running, start, stop] = createScheduledFrame(callback); + const [added, add, remove, running, start, stop] = createGlobalRAFCallback(callback); function runFrame() { expect(running()).toBe(false); @@ -184,11 +178,11 @@ describe("useFrameloop", () => { } runFrame(); - queue(); - expect(queued()).toBe(true); + add(); + expect(added()).toBe(true); expect(running()).toBe(false); - dequeue(); - expect(queued()).toBe(false); + remove(); + expect(added()).toBe(false); runFrame(); expect(raf).toHaveBeenCalledTimes(4); expect(caf).toHaveBeenCalledTimes(2); @@ -196,28 +190,28 @@ describe("useFrameloop", () => { dispose(); }); }); - it("(Automatic execution) frameloop singleton calls rafs with the same timestamp", () => { + it("(Automatic start) global RAF 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 createGlobalRAFCallback = useGlobalRAF(); const callback1: Mock = vi.fn(ts => timestamps.add(ts)); - const [queued1, queue1, dequeue1, running1, start1, stop1] = createScheduledFrame( + const [added1, add1, remove1, running1, start1, stop1] = createGlobalRAFCallback( callback1, true, ); const callback2: Mock = vi.fn(ts => timestamps.add(ts)); - const [queued2, queue2, dequeue2, running2, start2, stop2] = createScheduledFrame( + const [added2, add2, remove2, running2, start2, stop2] = createGlobalRAFCallback( callback2, true, ); // Queue functions should not be equal - expect(queued1).not.toEqual(queued2); - expect(queue1).not.toEqual(queue2); - expect(dequeue1).not.toEqual(dequeue2); + expect(added1).not.toEqual(added2); + expect(add1).not.toEqual(add2); + expect(remove1).not.toEqual(remove2); expect(callback1).not.toHaveBeenCalled(); expect(callback2).not.toHaveBeenCalled(); @@ -230,10 +224,10 @@ describe("useFrameloop", () => { const running = running1; const stop = stop1; - expect(queued1()).toBe(false); - queue1(); - expect(queued1()).toBe(true); - expect(queued2()).toBe(false); + expect(added1()).toBe(false); + add1(); + expect(added1()).toBe(true); + expect(added2()).toBe(false); expect(running()).toBe(true); vi.advanceTimersToNextFrame(); expect(raf).toHaveBeenCalledTimes(2); @@ -242,14 +236,14 @@ describe("useFrameloop", () => { stop(); expect(running()).toBe(false); expect(caf).toHaveBeenCalledTimes(1); - queue2(); - expect(queued2()).toBe(true); + add2(); + expect(added2()).toBe(true); expect(running()).toBe(true); vi.advanceTimersToNextFrame(); expect(raf).toHaveBeenCalledTimes(4); expect(timestamps.size).toEqual(2); - dequeue1(); - dequeue2(); + remove1(); + remove2(); vi.waitUntil(() => { expect(running()).toBe(false); expect(caf).toHaveBeenCalledTimes(2); @@ -257,17 +251,14 @@ describe("useFrameloop", () => { dispose(); }); }); - it("(Automatic execution) frameloop singleton skips calls when not queued / dequeued", () => { + it("(Automatic start) global RAF singleton skips callbacks when not added", () => { // 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 createGlobalRAFCallback = useGlobalRAF(); const callback: Mock = vi.fn(); - const [_queued, _queue, _dequeue, running, start, stop] = createScheduledFrame( - callback, - true, - ); + const [_added, _add, _remove, running, start, stop] = createGlobalRAFCallback(callback, true); expect(running()).toBe(false); start(); @@ -280,20 +271,20 @@ describe("useFrameloop", () => { expect(callback).not.toHaveBeenCalled(); }); }); - it("(All) frameloop dispose stops the execution and dequeues", () => { + it("(All) frameloop dispose stops the execution and cancels all callbacks", () => { // 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 createGlobalRAFCallback = useGlobalRAF(); const callback: Mock = vi.fn(); - const [queued, queue, _dequeue, running, start, _stop] = createScheduledFrame(callback); + const [added, add, _remove, running, start, _stop] = createGlobalRAFCallback(callback); - expect(queued()).toBe(false); - queue(); - expect(queued()).toBe(true); + expect(added()).toBe(false); + add(); + expect(added()).toBe(true); expect(raf).not.toHaveBeenCalled(); expect(caf).not.toHaveBeenCalled(); expect(callback).not.toHaveBeenCalled();