feat(runtime): add shared animation scheduler
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
// Internal module not in package exports — import via relative source path,
|
||||
// matching the convention in integration/lifecycle/write-synchronized.test.ts.
|
||||
import {
|
||||
createAnimationScheduler,
|
||||
createNoOpAnimationScheduler,
|
||||
normalizeInterval,
|
||||
} from "../../runtime/src/animation-scheduler.ts";
|
||||
|
||||
describe("normalizeInterval", () => {
|
||||
test("clamps and defaults", () => {
|
||||
expect(normalizeInterval(50)).toBe(50);
|
||||
expect(normalizeInterval(0)).toBe(1);
|
||||
expect(normalizeInterval(-10)).toBe(1);
|
||||
expect(normalizeInterval(undefined)).toBe(100);
|
||||
expect(normalizeInterval(Number.NaN)).toBe(100);
|
||||
expect(normalizeInterval(Number.POSITIVE_INFINITY)).toBe(100);
|
||||
expect(normalizeInterval(Number.MAX_SAFE_INTEGER)).toBe(2_147_483_647);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAnimationScheduler", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("same-interval subscribers share one timer", () => {
|
||||
const s = createAnimationScheduler();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
s.subscribe(a, 50);
|
||||
s.subscribe(b, 50);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
expect(setTimeoutSpy.mock.calls.every((c) => c[1] === 50)).toBe(true);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test("different intervals wake at earliest deadline", () => {
|
||||
const s = createAnimationScheduler();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
s.subscribe(vi.fn(), 50);
|
||||
s.subscribe(vi.fn(), 80);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(setTimeoutSpy.mock.calls.at(-1)?.[1]).toBe(30);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test("late subscriber with earlier deadline reschedules", () => {
|
||||
const s = createAnimationScheduler();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
s.subscribe(vi.fn(), 100);
|
||||
vi.advanceTimersByTime(20);
|
||||
const early = vi.fn();
|
||||
s.subscribe(early, 10);
|
||||
expect(setTimeoutSpy.mock.calls.at(-1)?.[1]).toBe(10);
|
||||
vi.advanceTimersByTime(10);
|
||||
expect(early).toHaveBeenCalledTimes(1);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test("last unsubscribe clears the timer", () => {
|
||||
const s = createAnimationScheduler();
|
||||
const { unsubscribe } = s.subscribe(vi.fn(), 50);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
unsubscribe();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("partial unsubscribe keeps timer alive", () => {
|
||||
const s = createAnimationScheduler();
|
||||
const first = s.subscribe(vi.fn(), 50);
|
||||
s.subscribe(vi.fn(), 50);
|
||||
first.unsubscribe();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
s.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
test("elapsed-time frame catch-up", () => {
|
||||
const s = createAnimationScheduler();
|
||||
let lastFrame = -1;
|
||||
let startTime = 0;
|
||||
const handle = s.subscribe((now) => {
|
||||
lastFrame = Math.floor((now - startTime) / 50);
|
||||
}, 50);
|
||||
startTime = handle.startTime;
|
||||
vi.advanceTimersByTime(220);
|
||||
expect(lastFrame).toBe(4);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test("reentrancy: callback A unsubscribes B before B's turn", () => {
|
||||
const s = createAnimationScheduler();
|
||||
const b = vi.fn();
|
||||
let bHandle: { unsubscribe: () => void };
|
||||
const a = vi.fn(() => bHandle.unsubscribe());
|
||||
s.subscribe(a, 50);
|
||||
bHandle = s.subscribe(b, 50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).not.toHaveBeenCalled();
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test("reentrancy: callback subscribes during dispatch", () => {
|
||||
const s = createAnimationScheduler();
|
||||
const c = vi.fn();
|
||||
const a = vi.fn(() => {
|
||||
s.subscribe(c, 50);
|
||||
});
|
||||
s.subscribe(a, 50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(c).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(c).toHaveBeenCalledTimes(1);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test("reentrancy: callback resets itself without crashing", () => {
|
||||
const s = createAnimationScheduler();
|
||||
let handle: { startTime: number; unsubscribe: () => void };
|
||||
const cb = vi.fn(() => {
|
||||
handle.unsubscribe();
|
||||
handle = s.subscribe(cb, 50);
|
||||
});
|
||||
handle = s.subscribe(cb, 50);
|
||||
expect(() => vi.advanceTimersByTime(150)).not.toThrow();
|
||||
expect(cb).toHaveBeenCalled();
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
test("dispose clears everything", () => {
|
||||
const s = createAnimationScheduler();
|
||||
s.subscribe(vi.fn(), 50);
|
||||
s.subscribe(vi.fn(), 80);
|
||||
s.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNoOpAnimationScheduler", () => {
|
||||
test("subscribe returns inert handle, never ticks", () => {
|
||||
const s = createNoOpAnimationScheduler();
|
||||
const cb = vi.fn();
|
||||
const { startTime, unsubscribe } = s.subscribe(cb, 50);
|
||||
expect(startTime).toBe(0);
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
expect(() => s.dispose()).not.toThrow();
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
const DEFAULT_INTERVAL = 100;
|
||||
const MAX_TIMER_INTERVAL = 2_147_483_647;
|
||||
|
||||
export function normalizeInterval(interval: number | undefined): number {
|
||||
if (interval === undefined || !Number.isFinite(interval)) return DEFAULT_INTERVAL;
|
||||
return Math.min(Math.max(1, Math.round(interval)), MAX_TIMER_INTERVAL);
|
||||
}
|
||||
|
||||
type AnimationSubscriber = {
|
||||
callback: (currentTime: number) => void;
|
||||
interval: number;
|
||||
startTime: number;
|
||||
nextDueTime: number;
|
||||
cancelled: boolean;
|
||||
};
|
||||
|
||||
export interface AnimationScheduler {
|
||||
subscribe(
|
||||
callback: (currentTime: number) => void,
|
||||
interval: number,
|
||||
): { startTime: number; unsubscribe: () => void };
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createAnimationScheduler(): AnimationScheduler {
|
||||
const subscribers = new Set<AnimationSubscriber>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let scheduledDueTime = Number.POSITIVE_INFINITY;
|
||||
let isDispatching = false;
|
||||
const pending: Array<() => void> = [];
|
||||
|
||||
function clearTimer() {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
scheduledDueTime = Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
clearTimer();
|
||||
let earliest = Number.POSITIVE_INFINITY;
|
||||
for (const s of subscribers) {
|
||||
if (!s.cancelled) earliest = Math.min(earliest, s.nextDueTime);
|
||||
}
|
||||
if (earliest === Number.POSITIVE_INFINITY) return;
|
||||
scheduledDueTime = earliest;
|
||||
const delay = Math.max(0, earliest - performance.now());
|
||||
timer = setTimeout(onTick, delay);
|
||||
}
|
||||
|
||||
function onTick() {
|
||||
timer = undefined;
|
||||
scheduledDueTime = Number.POSITIVE_INFINITY;
|
||||
const now = performance.now();
|
||||
isDispatching = true;
|
||||
for (const s of subscribers) {
|
||||
if (s.cancelled || now < s.nextDueTime) continue;
|
||||
s.callback(now);
|
||||
if (s.cancelled) continue;
|
||||
const elapsedFrames = Math.floor((now - s.startTime) / s.interval) + 1;
|
||||
s.nextDueTime = s.startTime + elapsedFrames * s.interval;
|
||||
}
|
||||
isDispatching = false;
|
||||
if (pending.length > 0) {
|
||||
for (const op of pending.splice(0)) op();
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
function subscribe(callback: (currentTime: number) => void, intervalRaw: number) {
|
||||
const interval = normalizeInterval(intervalRaw);
|
||||
const startTime = performance.now();
|
||||
const sub: AnimationSubscriber = {
|
||||
callback,
|
||||
interval,
|
||||
startTime,
|
||||
nextDueTime: startTime + interval,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
subscribers.add(sub);
|
||||
if (timer === undefined || sub.nextDueTime < scheduledDueTime) schedule();
|
||||
};
|
||||
if (isDispatching) pending.push(add);
|
||||
else add();
|
||||
|
||||
return {
|
||||
startTime,
|
||||
unsubscribe() {
|
||||
sub.cancelled = true;
|
||||
const remove = () => {
|
||||
subscribers.delete(sub);
|
||||
if (subscribers.size === 0) clearTimer();
|
||||
else schedule();
|
||||
};
|
||||
if (isDispatching) pending.push(remove);
|
||||
else remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
clearTimer();
|
||||
subscribers.clear();
|
||||
pending.length = 0;
|
||||
}
|
||||
|
||||
return { subscribe, dispose };
|
||||
}
|
||||
|
||||
export function createNoOpAnimationScheduler(): AnimationScheduler {
|
||||
return {
|
||||
subscribe() {
|
||||
return { startTime: 0, unsubscribe() {} };
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user