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/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..17056ab95 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,13 +38,144 @@ function createRAF( }; const stop = () => { setRunning(false); - cancelAnimationFrame(requestID); + if (requestID !== null) cancelAnimationFrame(requestID); }; onCleanup(stop); return [running, start, stop]; } +/** + * 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` 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, startWhenAdded?: boolean) => [added: Accessor, add: VoidFunction, remove: VoidFunction, running: Accessor, start: VoidFunction, stop: VoidFunction] + * ``` + * + * @example + * const createGlobalRAFCallback = useGlobalRAF(); + * + * 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 useGlobalRAF = createHydratableSingletonRoot< + ( + callback: FrameRequestCallback, + startWhenAdded?: MaybeAccessor, + ) => [ + added: Accessor, + add: VoidFunction, + remove: VoidFunction, + running: Accessor, + start: VoidFunction, + stop: VoidFunction, + ] +>(() => { + if (isServer) return () => [() => false, noop, noop, () => false, noop, noop]; + + const [callback, callbacksSet] = createCallbacksSet(); + const [running, start, stop] = createRAF(callback); + + return function createGlobalRAFCallback(callback: FrameRequestCallback, startWhenAdded = false) { + const added = () => callbacksSet.has(callback); + const add = () => { + callbacksSet.add(callback); + if (access(startWhenAdded) && !running()) start(); + }; + const remove = () => { + callbacksSet.delete(callback); + if (running() && callbacksSet.size === 0) stop(); + }; + + onCleanup(remove); + return [added, add, remove, running, start, stop]; + }; +}); + +/** + * 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#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 + * (callback: Callback) => [running: Accessor, start: VoidFunction, stop: VoidFunction] + * ``` + * + * @example + * 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(...)" + * ); + */ +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] { + 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); + }; + + onCleanup(stop); + return [running, start, stop]; + }; +} + /** * A primitive for wrapping `window.requestAnimationFrame` callback function to limit the execution of the callback to specified number of FPS. * @@ -131,4 +264,12 @@ function createMs(fps: MaybeAccessor, limit?: MaybeAccessor): Ms return Object.assign(ms, { reset, running, start, stop }); } -export { createMs, createRAF, createRAF as default, targetFPS }; +export { + createMs, + createCallbacksSet, + createRAF, + createRAF as default, + createScheduledLoop, + targetFPS, + useGlobalRAF, +}; diff --git a/packages/raf/test/index.test.ts b/packages/raf/test/index.test.ts index 0a25f894c..868a80ef9 100644 --- a/packages/raf/test/index.test.ts +++ b/packages/raf/test/index.test.ts @@ -1,20 +1,81 @@ -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, createScheduledLoop, targetFPS, useGlobalRAF } 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("createScheduledLoop", () => { + it("frameloop created with requestAnimationFrame calls requestAnimationFrame after start", () => { + const raf = vi.spyOn(window, "requestAnimationFrame"); + const caf = vi.spyOn(window, "cancelAnimationFrame"); + createRoot(() => { + // TODO add better test + const [running, start, stop] = createScheduledLoop( + 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 +96,212 @@ describe("targetFPS", () => { }); }); +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 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 createGlobalRAFCallback = useGlobalRAF(); + const callback1: Mock = vi.fn(ts => timestamps.add(ts)); + const [added1, add1, remove1, running1, start1, stop1] = createGlobalRAFCallback(callback1); + const callback2: Mock = vi.fn(ts => timestamps.add(ts)); + const [added2, add2, remove2, running2, start2, stop2] = createGlobalRAFCallback(callback2); + + // Queue functions should not be equal + expect(added1).not.toEqual(added2); + expect(add1).not.toEqual(add2); + expect(remove1).not.toEqual(remove2); + 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(added1()).toBe(false); + add1(); + expect(added1()).toBe(true); + expect(added2()).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); + add2(); + expect(added2()).toBe(true); + start(); + vi.advanceTimersToNextFrame(); + expect(raf).toHaveBeenCalledTimes(4); + expect(timestamps.size).toEqual(2); + stop(); + expect(caf).toHaveBeenCalledTimes(2); + dispose(); + }); + }); + 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 createGlobalRAFCallback = useGlobalRAF(); + const callback: Mock = vi.fn(); + const [added, add, remove, running, start, stop] = createGlobalRAFCallback(callback); + + function runFrame() { + expect(running()).toBe(false); + start(); + expect(running()).toBe(true); + vi.advanceTimersToNextFrame(); + stop(); + expect(running()).toBe(false); + } + + runFrame(); + add(); + expect(added()).toBe(true); + expect(running()).toBe(false); + remove(); + expect(added()).toBe(false); + runFrame(); + expect(raf).toHaveBeenCalledTimes(4); + expect(caf).toHaveBeenCalledTimes(2); + expect(callback).not.toHaveBeenCalled(); + dispose(); + }); + }); + 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 createGlobalRAFCallback = useGlobalRAF(); + const callback1: Mock = vi.fn(ts => timestamps.add(ts)); + const [added1, add1, remove1, running1, start1, stop1] = createGlobalRAFCallback( + callback1, + true, + ); + const callback2: Mock = vi.fn(ts => timestamps.add(ts)); + const [added2, add2, remove2, running2, start2, stop2] = createGlobalRAFCallback( + callback2, + true, + ); + + // Queue functions should not be equal + expect(added1).not.toEqual(added2); + expect(add1).not.toEqual(add2); + expect(remove1).not.toEqual(remove2); + 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(added1()).toBe(false); + add1(); + expect(added1()).toBe(true); + expect(added2()).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); + add2(); + expect(added2()).toBe(true); + expect(running()).toBe(true); + vi.advanceTimersToNextFrame(); + expect(raf).toHaveBeenCalledTimes(4); + expect(timestamps.size).toEqual(2); + remove1(); + remove2(); + vi.waitUntil(() => { + expect(running()).toBe(false); + expect(caf).toHaveBeenCalledTimes(2); + }); + dispose(); + }); + }); + 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 createGlobalRAFCallback = useGlobalRAF(); + const callback: Mock = vi.fn(); + const [_added, _add, _remove, running, start, stop] = createGlobalRAFCallback(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 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 createGlobalRAFCallback = useGlobalRAF(); + const callback: Mock = vi.fn(); + const [added, add, _remove, running, start, _stop] = createGlobalRAFCallback(callback); + + expect(added()).toBe(false); + add(); + expect(added()).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