diff --git a/.changeset/lucky-horses-pay.md b/.changeset/lucky-horses-pay.md new file mode 100644 index 0000000000..7d88bf0086 --- /dev/null +++ b/.changeset/lucky-horses-pay.md @@ -0,0 +1,5 @@ +--- +'xstate': patch +--- + +Fix an issue where `clearTimeout(undefined)` was sometimes being called, which can cause errors for some clock implementations. See https://github.com/statelyai/xstate/issues/5001 for details. diff --git a/packages/core/src/system.ts b/packages/core/src/system.ts index 15a89e631f..763c5c5e1e 100644 --- a/packages/core/src/system.ts +++ b/packages/core/src/system.ts @@ -139,7 +139,9 @@ export function createSystem( delete timerMap[scheduledEventId]; delete system._snapshot._scheduledEvents[scheduledEventId]; - clock.clearTimeout(timeout); + if (timeout !== undefined) { + clock.clearTimeout(timeout); + } }, cancelAll: (actorRef) => { for (const scheduledEventId in system._snapshot._scheduledEvents) { diff --git a/packages/core/test/actions.test.ts b/packages/core/test/actions.test.ts index ad41d7860a..af26f1f944 100644 --- a/packages/core/test/actions.test.ts +++ b/packages/core/test/actions.test.ts @@ -3384,7 +3384,7 @@ describe('raise', () => { }); describe('cancel', () => { - it('should be possible to cancel a raised delayed event', () => { + it('should be possible to cancel a raised delayed event', async () => { const machine = createMachine({ initial: 'a', states: { @@ -3405,11 +3405,18 @@ describe('cancel', () => { const actor = createActor(machine).start(); + // This should raise the 'RAISED' event after 1ms + actor.send({ type: 'NEXT' }); + + // This should cancel the 'RAISED' event actor.send({ type: 'CANCEL' }); - setTimeout(() => { - expect(actor.getSnapshot().value).toBe('a'); - }, 10); + await new Promise((res) => { + setTimeout(() => { + expect(actor.getSnapshot().value).toBe('a'); + res(); + }, 10); + }); }); it('should cancel only the delayed event in the machine that scheduled it when canceling the event with the same ID in the machine that sent it first', async () => { @@ -3507,6 +3514,31 @@ describe('cancel', () => { expect(fooSpy).toHaveBeenCalledTimes(1); expect(barSpy).not.toHaveBeenCalled(); }); + + it('should not try to clear an undefined timeout when canceling an unscheduled timer', async () => { + const spy = jest.fn(); + + const machine = createMachine({ + on: { + FOO: { + actions: cancel('foo') + } + } + }); + + const actorRef = createActor(machine, { + clock: { + setTimeout, + clearTimeout: spy + } + }).start(); + + actorRef.send({ + type: 'FOO' + }); + + expect(spy.mock.calls.length).toBe(0); + }); }); describe('assign action order', () => { diff --git a/packages/core/test/after.test.ts b/packages/core/test/after.test.ts index 5bc4aebfad..73c4357b2c 100644 --- a/packages/core/test/after.test.ts +++ b/packages/core/test/after.test.ts @@ -1,4 +1,5 @@ -import { createMachine, createActor } from '../src/index.ts'; +import { sleep } from '@xstate-repo/jest-utils'; +import { createMachine, createActor, cancel } from '../src/index.ts'; const lightMachine = createMachine({ id: 'light', @@ -43,6 +44,35 @@ describe('delayed transitions', () => { expect(actorRef.getSnapshot().value).toBe('yellow'); }); + it('should not try to clear an undefined timeout when exiting source state of a delayed transition', async () => { + // https://github.com/statelyai/xstate/issues/5001 + const spy = jest.fn(); + + const machine = createMachine({ + initial: 'green', + states: { + green: { + after: { + 1: 'yellow' + } + }, + yellow: {} + } + }); + + const actorRef = createActor(machine, { + clock: { + setTimeout, + clearTimeout: spy + } + }).start(); + + // when the after transition gets executed it tries to clear its own timer when exiting its source state + await sleep(5); + expect(actorRef.getSnapshot().value).toBe('yellow'); + expect(spy.mock.calls.length).toBe(0); + }); + it('should format transitions properly', () => { const greenNode = lightMachine.states.green;