Files
simply_cpq/website/node_modules/@base-ui/utils/useAnimationFrame.mjs
T
2026-08-07 21:39:30 +02:00

127 lines
4.3 KiB
JavaScript

'use client';
import { useRefWithInit } from "./useRefWithInit.mjs";
import { useOnMount } from "./useOnMount.mjs";
/** Unlike `setTimeout`, rAF doesn't guarantee a positive integer return value, so we can't have
* a monomorphic `uint` type with `0` meaning empty.
* See warning note at:
* https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame#return_value */
const EMPTY = null;
let LAST_RAF = globalThis.requestAnimationFrame;
class Scheduler {
/* This implementation uses an array as a backing data-structure for frame callbacks.
* It allows `O(1)` callback cancelling by inserting a `null` in the array, though it
* never calls the native `cancelAnimationFrame` if there are no frames left. This can
* be much more efficient if there is a call pattern that alterns as
* "request-cancel-request-cancel-…".
* But in the case of "request-request-…-cancel-cancel-…", it leaves the final animation
* frame to run anyway. We turn that frame into a `O(1)` no-op via `callbacksCount`. */
callbacks = [];
callbacksCount = 0;
nextId = 1;
startId = 1;
isScheduled = false;
tick = timestamp => {
this.isScheduled = false;
const currentCallbacks = this.callbacks;
const currentCallbacksCount = this.callbacksCount;
// Update these before iterating, callbacks could call `requestAnimationFrame` again.
this.callbacks = [];
this.callbacksCount = 0;
this.startId = this.nextId;
if (currentCallbacksCount > 0) {
for (let i = 0; i < currentCallbacks.length; i += 1) {
currentCallbacks[i]?.(timestamp);
}
}
};
request(fn) {
const id = this.nextId;
this.nextId += 1;
this.callbacks.push(fn);
this.callbacksCount += 1;
/* In a test environment with fake timers, a fake `requestAnimationFrame` can be called
* but there's no guarantee that the animation frame will actually run before the fake
* timers are teared, which leaves `isScheduled` set, but won't run our `tick()`. */
const didRAFChange = process.env.NODE_ENV !== 'production' && LAST_RAF !== requestAnimationFrame && (LAST_RAF = requestAnimationFrame, true);
if (!this.isScheduled || didRAFChange) {
requestAnimationFrame(this.tick);
this.isScheduled = true;
}
return id;
}
cancel(id) {
const index = id - this.startId;
if (index < 0 || index >= this.callbacks.length) {
return;
}
this.callbacks[index] = null;
this.callbacksCount -= 1;
}
}
let scheduler = new Scheduler();
/**
* Replaces the shared scheduler and drops all pending animation frame callbacks.
*
* For test environments only. The scheduler is process-global, so a callback scheduled in one test
* but never run (e.g. requested under fake timers that were torn down before the frame fired) would
* otherwise survive into a later test and run there against stale state. Call between tests to drop
* such leftovers.
*/
export function resetAnimationFrameScheduler() {
const previous = scheduler;
scheduler = new Scheduler();
// Continue the id sequence so `cancel()` calls from `AnimationFrame` instances created before the
// reset cannot cancel callbacks scheduled after it.
scheduler.nextId = previous.nextId;
scheduler.startId = previous.nextId;
// A frame requested before the reset may still be pending and holds the previous scheduler's
// `tick`; empty its queue in place so that frame runs nothing when it eventually fires.
previous.callbacks = [];
previous.callbacksCount = 0;
}
export class AnimationFrame {
static create() {
return new AnimationFrame();
}
static request(fn) {
return scheduler.request(fn);
}
static cancel(id) {
return scheduler.cancel(id);
}
currentId = EMPTY;
/**
* Executes `fn` after `delay`, clearing any previously scheduled call.
*/
request(fn) {
this.cancel();
this.currentId = scheduler.request(() => {
this.currentId = EMPTY;
fn();
});
}
cancel = () => {
if (this.currentId !== EMPTY) {
scheduler.cancel(this.currentId);
this.currentId = EMPTY;
}
};
disposeEffect = () => {
return this.cancel;
};
}
/**
* A `requestAnimationFrame` with automatic cleanup and guard.
*/
export function useAnimationFrame() {
const timeout = useRefWithInit(AnimationFrame.create).current;
useOnMount(timeout.disposeEffect);
return timeout;
}