From 9468c34f35860d8fe4b59b4b2b2a9cda9d5f5cb8 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Thu, 28 May 2026 14:19:52 +0800 Subject: [PATCH] feat(runtime): add shared animation scheduler --- .../unit/animation-scheduler.test.ts | 160 ++++++++++++++++++ packages/runtime/src/animation-scheduler.ts | 120 +++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 packages/runtime-tests/unit/animation-scheduler.test.ts create mode 100644 packages/runtime/src/animation-scheduler.ts diff --git a/packages/runtime-tests/unit/animation-scheduler.test.ts b/packages/runtime-tests/unit/animation-scheduler.test.ts new file mode 100644 index 0000000..b537813 --- /dev/null +++ b/packages/runtime-tests/unit/animation-scheduler.test.ts @@ -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(); + }); +}); diff --git a/packages/runtime/src/animation-scheduler.ts b/packages/runtime/src/animation-scheduler.ts new file mode 100644 index 0000000..070f482 --- /dev/null +++ b/packages/runtime/src/animation-scheduler.ts @@ -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(); + let timer: ReturnType | 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() {}, + }; +}