From 4699f370eb0e34fb711a1a9c7f2dd7f724b7e764 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 27 Oct 2026 19:20:52 -0400 Subject: [PATCH] fix(mosaic): resume machine actors after stop instead of resetting them Ports the machine fix from #10341 without its React 19.2 test project, and makes a few tests independent of render and effect counts. --- .changeset/mosaic-machine-resume.md | 2 + .../src/machine/__tests__/machine.test.ts | 235 +++++++++++++++++- .../machine/__tests__/use-machine.test.tsx | 28 ++- .../__tests__/wizard-migration.test.tsx | 8 +- packages/mosaic/src/machine/create-actor.ts | 137 ++++++---- packages/mosaic/src/machine/types.ts | 11 +- packages/mosaic/src/machine/use-machine.ts | 7 +- .../__tests__/use-layout-animation.test.tsx | 25 +- .../hooks/__tests__/use-transition.test.ts | 17 +- 9 files changed, 390 insertions(+), 80 deletions(-) create mode 100644 .changeset/mosaic-machine-resume.md diff --git a/.changeset/mosaic-machine-resume.md b/.changeset/mosaic-machine-resume.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-machine-resume.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/machine/__tests__/machine.test.ts b/packages/mosaic/src/machine/__tests__/machine.test.ts index 396e498b662..216780b340c 100644 --- a/packages/mosaic/src/machine/__tests__/machine.test.ts +++ b/packages/mosaic/src/machine/__tests__/machine.test.ts @@ -659,12 +659,221 @@ describe('actor restart — StrictMode start/stop/start cycle', () => { // Stopped mid-invoke (e.g. Suspense remount) then restarted. actor.stop(); actor.start(); - expect(actor.getSnapshot().status).toBe('active'); + expect(actor.getSnapshot()).toMatchObject({ value: 'deleting', status: 'active' }); // The invoke must not fire a second time. expect(invokeCount).toBe(1); }); }); +describe('actor restart — stop/start resumes the current state', () => { + it('stays in a state without an invoke', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE', value: 'Acme' }); + + actor.stop(); + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ value: 'confirming', status: 'active' }); + expect(actor.getSnapshot().context.confirmValue).toBe('Acme'); + actor.send({ type: 'CANCEL' }); + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('applies an invoke that resolved while stopped once started again', async () => { + const gate = deferred(); + const actor = createActor(createLoaderMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'FETCH' }); + + actor.stop(); + gate.resolve('loaded'); + await tick(); + expect(actor.getSnapshot()).toMatchObject({ value: 'loading', status: 'stopped' }); + + actor.start(); + expect(actor.getSnapshot()).toMatchObject({ value: 'success', status: 'done' }); + expect(actor.getSnapshot().context.data).toBe('loaded'); + }); + + it('applies an invoke that rejected while stopped once started again', async () => { + const gate = deferred(); + const actor = createActor(createLoaderMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'FETCH' }); + + actor.stop(); + gate.reject('boom'); + await tick(); + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ value: 'failure', status: 'active' }); + expect(actor.getSnapshot().context.error).toBe('boom'); + }); + + it('handles an invoke that settles after the restart exactly once', async () => { + const gate = deferred(); + const seen: string[] = []; + const actor = createActor(createLoaderMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'FETCH' }); + + actor.stop(); + actor.start(); + actor.subscribe(snapshot => seen.push(snapshot.value)); + gate.resolve('late'); + await tick(); + + expect(actor.getSnapshot().value).toBe('success'); + expect(actor.getSnapshot().context.data).toBe('late'); + expect(seen).toEqual(['success']); + }); + + it('runs entry actions and the invoke once across a StrictMode start/stop/start', async () => { + const entry = vi.fn(); + const src = vi.fn(() => Promise.resolve('ok')); + const machine = createMachine<{ out: string }, { type: 'never' }>({ + initial: 'working', + context: { out: '' }, + states: { + working: { + entry, + invoke: { + src, + onDone: { + target: 'done', + actions: assign<{ out: string }, DoneInvokeEvent>((_, event) => ({ out: event.output })), + }, + }, + }, + done: { type: 'final' }, + }, + }); + const actor = createActor(machine); + + actor.start(); + actor.stop(); + actor.start(); + await tick(); + + expect(entry).toHaveBeenCalledTimes(1); + expect(src).toHaveBeenCalledTimes(1); + expect(actor.getSnapshot()).toMatchObject({ value: 'done', status: 'done', context: { out: 'ok' } }); + }); + + it('never notifies or schedules work once stopped for good', async () => { + vi.useFakeTimers(); + try { + const gate = deferred(); + const machine = createMachine, { type: 'never' }>({ + initial: 'working', + states: { + working: { invoke: { src: () => gate.promise, onDone: 'done' }, after: { 1000: 'done' } }, + done: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + const listener = vi.fn(); + actor.subscribe(listener); + + actor.stop(); + listener.mockClear(); + expect(vi.getTimerCount()).toBe(0); + + gate.resolve('ignored'); + await vi.runAllTimersAsync(); + + expect(listener).not.toHaveBeenCalled(); + expect(actor.getSnapshot()).toMatchObject({ value: 'working', status: 'stopped' }); + } finally { + vi.useRealTimers(); + } + }); + + it('queues events sent while stopped and applies them in order on start', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + actor.stop(); + const listener = vi.fn(); + actor.subscribe(listener); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE', value: 'Acme' }); + expect(actor.getSnapshot()).toMatchObject({ value: 'idle', status: 'stopped' }); + expect(listener).not.toHaveBeenCalled(); + + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ value: 'confirming', status: 'active' }); + expect(actor.getSnapshot().context.confirmValue).toBe('Acme'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('applies an invoke result and events in the order they arrived while stopped', async () => { + const gate = deferred(); + const actor = createActor(createDeleteOrgMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE', value: 'Acme Inc' }); + actor.send({ type: 'CONFIRM' }); + + actor.stop(); + gate.reject('boom'); + await tick(); + actor.send({ type: 'TYPE', value: 'retry' }); + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ + value: 'confirming', + context: { error: 'boom', confirmValue: 'retry' }, + }); + }); + + it('drops an invoke result when an event queued before it leaves the invoking state', async () => { + const gate = deferred(); + const machine = createMachine, { type: 'GO' } | { type: 'CANCEL' }>({ + initial: 'idle', + states: { + idle: { on: { GO: 'working' } }, + working: { invoke: { src: () => gate.promise, onDone: 'done' }, on: { CANCEL: 'idle' } }, + done: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'GO' }); + + actor.stop(); + actor.send({ type: 'CANCEL' }); + gate.resolve('late'); + await tick(); + actor.start(); + + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('ignores send before the first start', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.send({ type: 'OPEN' }); + actor.start(); + + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('keeps a teleported actor inert across a restart', () => { + const src = vi.fn(() => Promise.resolve()); + const actor = mockActor(createDeleteOrgMachine(src), { value: 'deleting', context: { error: 'shown' } }); + + actor.stop(); + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ value: 'deleting', status: 'active', context: { error: 'shown' } }); + expect(src).not.toHaveBeenCalled(); + }); +}); + describe('createActor — after (delayed transitions)', () => { beforeEach(() => { vi.useFakeTimers(); @@ -847,6 +1056,30 @@ describe('createActor — after (delayed transitions)', () => { vi.advanceTimersByTime(60_000); expect(actor.getSnapshot().value).toBe('expired'); }); + + it('does not fire while stopped and re-arms the timer of the current state on start', () => { + const machine = createMachine, { type: 'SEND' }>({ + initial: 'idle', + states: { + idle: { on: { SEND: 'codeSent' } }, + codeSent: { after: { 60_000: 'expired' } }, + expired: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'SEND' }); + + actor.stop(); + vi.advanceTimersByTime(120_000); + expect(actor.getSnapshot()).toMatchObject({ value: 'codeSent', status: 'stopped' }); + + actor.start(); + vi.advanceTimersByTime(59_999); + expect(actor.getSnapshot().value).toBe('codeSent'); + vi.advanceTimersByTime(1); + expect(actor.getSnapshot().value).toBe('expired'); + }); }); describe('createActor — inline transition functions (TransitionFn)', () => { diff --git a/packages/mosaic/src/machine/__tests__/use-machine.test.tsx b/packages/mosaic/src/machine/__tests__/use-machine.test.tsx index 92cb41b5887..7d4c553cb8f 100644 --- a/packages/mosaic/src/machine/__tests__/use-machine.test.tsx +++ b/packages/mosaic/src/machine/__tests__/use-machine.test.tsx @@ -137,15 +137,15 @@ describe('useSelector — re-renders only on the selected slice', () => { } render(); - expect(renders).toHaveBeenCalledTimes(1); + renders.mockClear(); // Unrelated change → selected slice (`a`) is unchanged → no re-render. act(() => actor.send({ type: 'INC_B' })); - expect(renders).toHaveBeenCalledTimes(1); + expect(renders).not.toHaveBeenCalled(); // Selected change → re-render. act(() => actor.send({ type: 'INC_A' })); - expect(renders).toHaveBeenCalledTimes(2); + expect(renders).toHaveBeenCalled(); expect(screen.getByText('1')).toBeInTheDocument(); }); @@ -166,11 +166,11 @@ describe('useSelector — re-renders only on the selected slice', () => { } render(); - expect(renders).toHaveBeenCalledTimes(1); + renders.mockClear(); act(() => actor.send({ type: 'INC_A' })); act(() => actor.send({ type: 'INC_A' })); - expect(renders).toHaveBeenCalledTimes(1); + expect(renders).not.toHaveBeenCalled(); // Still shows the stale (first) value because the equality fn suppressed updates. expect(screen.getByText('0')).toBeInTheDocument(); }); @@ -246,6 +246,24 @@ describe('useMachine — onDone', () => { expect(onDone).toHaveBeenCalledTimes(1); }); + it('calls onDone once when the actor restarts in a final state', () => { + const onDone = vi.fn(); + const machine = createMachine({ + initial: 'done', + context: {}, + states: { done: { type: 'final' } }, + }); + + function Comp() { + const [snapshot] = useMachine(machine, { onDone }); + return {snapshot.value}; + } + + render(, { reactStrictMode: true }); + + expect(onDone).toHaveBeenCalledTimes(1); + }); + it('always invokes the latest onDone prop, not a stale closure', async () => { const gate = deferred(); const staleCallback = vi.fn(); diff --git a/packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx b/packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx index 0e354b54479..2aac4f67c96 100644 --- a/packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx +++ b/packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx @@ -497,16 +497,16 @@ describe('Wizard AFTER — useSelector scopes re-renders to the step slice', () } render(); - expect(renders).toHaveBeenCalledTimes(1); expect(screen.getByTestId('current')).toHaveTextContent('intro'); + renders.mockClear(); // A GOTO to the current step is a no-op → no notify → no re-render. act(() => actor.send({ type: 'GOTO', step: 'intro' })); - expect(renders).toHaveBeenCalledTimes(1); + expect(renders).not.toHaveBeenCalled(); - // A real navigation changes the selected slice → exactly one re-render. + // A real navigation changes the selected slice → re-render. act(() => actor.send({ type: 'NEXT' })); - expect(renders).toHaveBeenCalledTimes(2); + expect(renders).toHaveBeenCalled(); expect(screen.getByTestId('current')).toHaveTextContent('details'); }); }); diff --git a/packages/mosaic/src/machine/create-actor.ts b/packages/mosaic/src/machine/create-actor.ts index d527868354d..c10655d07db 100644 --- a/packages/mosaic/src/machine/create-actor.ts +++ b/packages/mosaic/src/machine/create-actor.ts @@ -21,6 +21,12 @@ import { AFTER, ASSIGN, INIT, INVOKE_DONE, INVOKE_ERROR, RECHECK } from './types const INIT_EVENT: AnyEventObject = { type: INIT }; +interface AfterTimer { + id: ReturnType; + delay: number; + transitionConfig: unknown; +} + // Collapse toArray into single helper: returns empty array, original if array, or wrapped value const toArr = (v: T | T[] | undefined): T[] => (!v ? [] : Array.isArray(v) ? v : [v]); @@ -70,14 +76,17 @@ export function createActor // A teleported actor is already "started" and inert: start() must not re-run // entry/always/invoke for the state it was dropped into. let started = teleport !== undefined; + let initialized = started; let status: Snapshot['status'] = stateOf(value).type === 'final' ? 'done' : 'active'; - // Bumped whenever we leave an invoking state (or stop), so a stale promise - // resolving after the fact is ignored — no transition, no setState-after-stop. + // Bumped whenever we leave an invoking state, so a stale promise resolving + // after the fact is ignored — no transition. let invocationToken = 0; + let receivedWhileStopped: (() => boolean)[] = []; - // Pending `after` timer IDs — cleared when the state is exited or the actor stops. - let afterTimers: ReturnType[] = []; + // Pending `after` timers — cleared when the state is exited, paused while the actor is stopped. + let afterTimers: AfterTimer[] = []; + let pausedAfterTimers: AfterTimer[] = []; // The snapshot is cached and only replaced on an actual change, so // getSnapshot() is referentially stable for useSyncExternalStore. @@ -168,44 +177,39 @@ export function createActor return; } const token = ++invocationToken; + const settle = (transitionConfig: unknown, settledEvent: AnyEventObject) => { + if (token !== invocationToken) { + return; + } + if (status === 'stopped') { + receivedWhileStopped.push( + () => token === invocationToken && takeInvokeTransition(transitionConfig, settledEvent), + ); + return; + } + if (status === 'active' && takeInvokeTransition(transitionConfig, settledEvent)) { + commit(); + } + }; // SAFETY: startInvoke is called with the actor's internal EventObject, but // InvokeConfig.src is typed to accept (context, TEvent | DoneInvokeEvent | ErrorInvokeEvent). // The cast suppresses that mismatch; src implementations receive the INIT event // on state entry and typically ignore it. The runtime views events through an // event-agnostic lens (line 57) for this reason. Promise.resolve(invoke.src(context, event as never)).then( - (output: unknown) => { - if (status !== 'active' || token !== invocationToken) { - return; - } - const doneEvent = { type: INVOKE_DONE, output }; - const transition = pickTransition(normalizeTransition(invoke.onDone, doneEvent), doneEvent); - if (!transition) { - return; - } - if (takeTransition(transition, doneEvent)) { - commit(); - } - }, - (error: unknown) => { - if (status !== 'active' || token !== invocationToken) { - return; - } - const errorEvent = { type: INVOKE_ERROR, error }; - const transition = pickTransition(normalizeTransition(invoke.onError, errorEvent), errorEvent); - if (!transition) { - return; - } - if (takeTransition(transition, errorEvent)) { - commit(); - } - }, + (output: unknown) => settle(invoke.onDone, { type: INVOKE_DONE, output }), + (error: unknown) => settle(invoke.onError, { type: INVOKE_ERROR, error }), ); } + function takeInvokeTransition(transitionConfig: unknown, event: EventObject): boolean { + const transition = pickTransition(normalizeTransition(transitionConfig, event), event); + return transition !== undefined && takeTransition(transition, event); + } + function clearAfterTimers(): void { - for (const id of afterTimers) { - clearTimeout(id); + for (const timer of afterTimers) { + clearTimeout(timer.id); } afterTimers = []; } @@ -216,25 +220,53 @@ export function createActor return; } for (const [delayStr, raw] of Object.entries(afterConfig)) { - const delay = Number(delayStr); - const id = setTimeout(() => { - afterTimers = afterTimers.filter(t => t !== id); + scheduleAfterTimer(Number(delayStr), raw); + } + } + + function scheduleAfterTimer(delay: number, transitionConfig: unknown): void { + const timer: AfterTimer = { + delay, + transitionConfig, + id: setTimeout(() => { + afterTimers = afterTimers.filter(t => t !== timer); if (status !== 'active') { return; } const afterEvent: AfterEvent = { type: AFTER, delay }; - const transition = pickTransition(normalizeTransition(raw, afterEvent), afterEvent); + const transition = pickTransition(normalizeTransition(transitionConfig, afterEvent), afterEvent); if (!transition) { return; } if (takeTransition(transition, afterEvent)) { commit(); } - }, delay); - afterTimers.push(id); + }, delay), + }; + afterTimers.push(timer); + } + + function resume(): void { + status = stateOf(value).type === 'final' ? 'done' : 'active'; + for (const timer of pausedAfterTimers) { + scheduleAfterTimer(timer.delay, timer.transitionConfig); + } + pausedAfterTimers = []; + const received = receivedWhileStopped; + receivedWhileStopped = []; + for (const apply of received) { + apply(); } } + function takeEventTransition(event: EventObject): boolean { + if (status !== 'active') { + return false; + } + const transition = pickTransition(normalizeTransition(stateOf(value).on?.[event.type], event), event); + return transition !== undefined && takeTransition(transition, event); + } + /** Entry side of a state: entry actions, then immediate/invoke resolution. */ function enterState(event: EventObject): void { const stateConfig = stateOf(value); @@ -273,12 +305,16 @@ export function createActor return actor; } started = true; - status = 'active'; - // Reset state and context so a restart (e.g. after StrictMode stop/start) - // begins from idle rather than re-entering and re-invoking a mid-flight state. - context = { ...machine.context, ...liveContextPatch }; - value = resolveInitial(); - enterState(INIT_EVENT); + if (initialized) { + resume(); + } else { + initialized = true; + status = 'active'; + // The first start seats on the initial state resolved from the latest injected context. + context = { ...machine.context, ...liveContextPatch }; + value = resolveInitial(); + enterState(INIT_EVENT); + } commit(); return actor; }, @@ -289,7 +325,7 @@ export function createActor } started = false; // allow restart (e.g. StrictMode effect cleanup + remount) status = 'stopped'; - invocationToken++; // abandon any in-flight invoke + pausedAfterTimers = afterTimers; clearAfterTimers(); snapshot = { value, context, status }; notifyListeners(); @@ -297,16 +333,13 @@ export function createActor }, send(event) { - if (!started || status !== 'active') { + if (initialized && status === 'stopped') { + receivedWhileStopped.push(() => takeEventTransition(event)); return; } - const transition = pickTransition(normalizeTransition(stateOf(value).on?.[event.type], event), event); - if (!transition) { - return; - } // event not handled in this state → ignored - if (takeTransition(transition, event)) { + if (started && takeEventTransition(event)) { commit(); - } // entry-blocked → no commit, no notify + } }, getSnapshot() { diff --git a/packages/mosaic/src/machine/types.ts b/packages/mosaic/src/machine/types.ts index 5ae3a960161..c4bb4384124 100644 --- a/packages/mosaic/src/machine/types.ts +++ b/packages/mosaic/src/machine/types.ts @@ -211,11 +211,16 @@ export type SnapshotListener = (snapshot: Snapshot) => void; export type Unsubscribe = () => void; export interface Actor { - /** Run entry actions / immediate transitions / invokes of the initial state. */ + /** + * Run entry actions / immediate transitions / invokes of the initial state. + * After a `stop()`, resume in the current state instead: re-arm its `after` + * timers, then apply the invoke results and events that arrived while stopped, + * in arrival order. + */ start: () => Actor; - /** Stop the actor and abandon any in-flight invoke. */ + /** Stop the actor, pausing its `after` timers and holding in-flight invoke results and sent events for a later `start()`. */ stop: () => void; - /** Send an event. Ignored once the actor is `done` or `stopped`. */ + /** Send an event. Ignored before the first `start()` and once the actor is `done`; queued while `stopped`. */ send: (event: TEvent) => void; getSnapshot: () => Snapshot; /** Subscribe to snapshots; returns an unsubscribe fn (usable with `useSyncExternalStore`). */ diff --git a/packages/mosaic/src/machine/use-machine.ts b/packages/mosaic/src/machine/use-machine.ts index 2a52ad61a3a..19bb181176c 100644 --- a/packages/mosaic/src/machine/use-machine.ts +++ b/packages/mosaic/src/machine/use-machine.ts @@ -43,6 +43,7 @@ export function useMachine( actorRef.current = createActor(machine, options); } const actor = actorRef.current; + const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); useEffect(() => { actor.start(); @@ -58,12 +59,12 @@ export function useMachine( } }); - const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); - const onDoneRef = useRef(options?.onDone); onDoneRef.current = options?.onDone; + const doneReportedRef = useRef(false); useEffect(() => { - if (snapshot.status === 'done') { + if (snapshot.status === 'done' && !doneReportedRef.current) { + doneReportedRef.current = true; onDoneRef.current?.(); } }, [snapshot.status]); diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-layout-animation.test.tsx b/packages/mosaic/src/primitives/hooks/__tests__/use-layout-animation.test.tsx index 6d30292d74b..499387a1b46 100644 --- a/packages/mosaic/src/primitives/hooks/__tests__/use-layout-animation.test.tsx +++ b/packages/mosaic/src/primitives/hooks/__tests__/use-layout-animation.test.tsx @@ -1,17 +1,25 @@ import { render } from '@testing-library/react'; import { useRef } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createLayoutAnimator, LAYOUT_ITEM_SELECTOR } from '../../utils/layout-animator'; import { useLayoutAnimation } from '../use-layout-animation'; -const dispose = vi.fn(); +const disposers: Array> = []; vi.mock('../../utils/layout-animator', async importOriginal => ({ ...(await importOriginal()), - createLayoutAnimator: vi.fn(() => dispose), + createLayoutAnimator: vi.fn(() => { + const dispose = vi.fn(); + disposers.push(dispose); + return dispose; + }), })); +function liveAnimators() { + return disposers.filter(dispose => dispose.mock.calls.length === 0).length; +} + function List() { const ref = useRef(null); const { itemProps } = useLayoutAnimation(ref); @@ -23,15 +31,22 @@ function List() { } describe('useLayoutAnimation', () => { + beforeEach(() => { + disposers.length = 0; + }); + it('animates the referenced element until unmount', () => { const { container, unmount } = render(); expect(createLayoutAnimator).toHaveBeenCalledWith(container.firstChild); - expect(dispose).not.toHaveBeenCalled(); + expect(liveAnimators()).toBe(1); unmount(); - expect(dispose).toHaveBeenCalledTimes(1); + expect(liveAnimators()).toBe(0); + for (const dispose of disposers) { + expect(dispose).toHaveBeenCalledTimes(1); + } }); it('returns props that mark an element as a layout item', () => { diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts b/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts index 30949591ecf..ccf3e195f23 100644 --- a/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts +++ b/packages/mosaic/src/primitives/hooks/__tests__/use-transition.test.ts @@ -5,20 +5,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useTransition } from '../use-transition'; describe('useTransition', () => { - let rafCallbacks: Array; + let rafCallbacks: Map; + let nextRafId: number; let originalRaf: typeof requestAnimationFrame; let originalCaf: typeof cancelAnimationFrame; beforeEach(() => { - rafCallbacks = []; + rafCallbacks = new Map(); + nextRafId = 0; originalRaf = globalThis.requestAnimationFrame; originalCaf = globalThis.cancelAnimationFrame; globalThis.requestAnimationFrame = vi.fn(cb => { - rafCallbacks.push(cb); - return rafCallbacks.length; + const id = ++nextRafId; + rafCallbacks.set(id, cb); + return id; }); globalThis.cancelAnimationFrame = vi.fn(id => { - rafCallbacks[id - 1] = () => {}; + rafCallbacks.delete(id); }); }); @@ -28,8 +31,8 @@ describe('useTransition', () => { }); function flushRaf() { - const cbs = [...rafCallbacks]; - rafCallbacks = []; + const cbs = [...rafCallbacks.values()]; + rafCallbacks.clear(); cbs.forEach(cb => cb(performance.now())); }