diff --git a/.changeset/wild-apes-play.md b/.changeset/wild-apes-play.md new file mode 100644 index 0000000000..e918d2431e --- /dev/null +++ b/.changeset/wild-apes-play.md @@ -0,0 +1,5 @@ +--- +'xstate': major +--- + +`exit` actions of all states are no longer called when the machine gets stopped externally. Note that they are still called when the machine reaches its final state. diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 80430a3807..17af160089 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -1577,7 +1577,7 @@ export function macrostep( // Handle stop event if (event.type === XSTATE_STOP) { - nextState = stopStep(nextState, event, actorCtx); + nextState = stopChildren(nextState, event, actorCtx); states.push(nextState); return { @@ -1649,26 +1649,6 @@ function stopChildren( ); } -function stopStep( - nextState: AnyState, - event: AnyEventObject, - actorCtx: AnyActorContext -) { - const exitActions = nextState.configuration - .sort((a, b) => b.order - a.order) - .flatMap((stateNode) => stateNode.exit); - - nextState = resolveActionsAndContext( - nextState, - event, - actorCtx, - exitActions, - [] - ); - - return stopChildren(nextState, event, actorCtx); -} - function selectTransitions( event: AnyEventObject, nextState: AnyState diff --git a/packages/core/test/actions.test.ts b/packages/core/test/actions.test.ts index ced62b4e78..35b7919353 100644 --- a/packages/core/test/actions.test.ts +++ b/packages/core/test/actions.test.ts @@ -1385,239 +1385,25 @@ describe('entry/exit actions', () => { }); describe('when stopped', () => { - it('exit actions should be called when stopping a machine', () => { - let exitCalled = false; - let childExitCalled = false; - - const machine = createMachine({ - exit: () => { - exitCalled = true; - }, - initial: 'a', - states: { - a: { - exit: () => { - childExitCalled = true; - } - } - } - }); - - const service = createActor(machine).start(); - service.stop(); - - expect(exitCalled).toBeTruthy(); - expect(childExitCalled).toBeTruthy(); - }); - - it('should call each exit handler only once when the service gets stopped', () => { - const machine = createMachine({ - initial: 'a', - states: { - a: { - initial: 'a1', - states: { - a1: {} - } - } - } - }); - - const flushTracked = trackEntries(machine); - - const service = createActor(machine).start(); - - flushTracked(); - service.stop(); - - expect(flushTracked()).toEqual([ - 'exit: a.a1', - 'exit: a', - 'exit: __root__' - ]); - }); + it('exit actions should not be called when stopping a machine', () => { + const rootSpy = jest.fn(); + const childSpy = jest.fn(); - it('should call exit actions in reversed document order when the service gets stopped', () => { const machine = createMachine({ + exit: rootSpy, initial: 'a', states: { a: { - on: { - EV: { - // just a noop action to ensure that a transition is selected when we send an event - actions: () => {} - } - } - } - } - }); - - const flushTracked = trackEntries(machine); - - const service = createActor(machine).start(); - - // it's important to send an event here that results in a transition that computes new `state.configuration` - // and that could impact the order in which exit actions are called - service.send({ type: 'EV' }); - flushTracked(); - service.stop(); - - expect(flushTracked()).toEqual(['exit: a', 'exit: __root__']); - }); - - it('should call exit actions of parallel states in reversed document order when the service gets stopped after earlier region transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'child_a', - states: { - child_a: { - on: { - EV: { - // just a noop action to ensure that a transition is selected when we send an event - actions: () => {} - } - } - } - } - }, - b: { - initial: 'child_b', - states: { - child_b: {} - } - } - } - }); - - const flushTracked = trackEntries(machine); - - const service = createActor(machine).start(); - - // it's important to send an event here that results in a transition as that computes new `state.configuration` - // and that could impact the order in which exit actions are called - service.send({ type: 'EV' }); - flushTracked(); - service.stop(); - - expect(flushTracked()).toEqual([ - 'exit: b.child_b', - 'exit: b', - 'exit: a.child_a', - 'exit: a', - 'exit: __root__' - ]); - }); - - it('should call exit actions of parallel states in reversed document order when the service gets stopped after later region transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'child_a', - states: { - child_a: {} - } - }, - b: { - initial: 'child_b', - states: { - child_b: { - on: { - EV: { - // just a noop action to ensure that a transition is selected when we send an event - actions: () => {} - } - } - } - } - } - } - }); - - const flushTracked = trackEntries(machine); - - const service = createActor(machine).start(); - - // it's important to send an event here that results in a transition as that computes new `state.configuration` - // and that could impact the order in which exit actions are called - service.send({ type: 'EV' }); - flushTracked(); - service.stop(); - - expect(flushTracked()).toEqual([ - 'exit: b.child_b', - 'exit: b', - 'exit: a.child_a', - 'exit: a', - 'exit: __root__' - ]); - }); - - it('should call exit actions of parallel states in reversed document order when the service gets stopped after multiple regions transition', () => { - const machine = createMachine({ - type: 'parallel', - states: { - a: { - initial: 'child_a', - states: { - child_a: { - on: { - EV: { - // just a noop action to ensure that a transition is selected when we send an event - actions: () => {} - } - } - } - } - }, - b: { - initial: 'child_b', - states: { - child_b: { - on: { - EV: { - // just a noop action to ensure that a transition is selected when we send an event - actions: () => {} - } - } - } - } + exit: childSpy } } }); - const flushTracked = trackEntries(machine); - - const service = createActor(machine).start(); - // it's important to send an event here that results in a transition as that computes new `state.configuration` - // and that could impact the order in which exit actions are called - service.send({ type: 'EV' }); - flushTracked(); - service.stop(); - - expect(flushTracked()).toEqual([ - 'exit: b.child_b', - 'exit: b', - 'exit: a.child_a', - 'exit: a', - 'exit: __root__' - ]); - }); - - it('an exit action executed when an interpreter gets stopped should receive `xstate.stop` event', () => { - let receivedEvent; - const machine = createMachine({ - exit: ({ event }) => { - receivedEvent = event; - } - }); - const service = createActor(machine).start(); service.stop(); - expect(receivedEvent).toEqual({ type: 'xstate.stop' }); + expect(rootSpy).not.toHaveBeenCalled(); + expect(childSpy).not.toHaveBeenCalled(); }); it('an exit action executed when an interpreter reaches its final state should be called with the last received event', () => { @@ -1757,16 +1543,14 @@ describe('entry/exit actions', () => { expect(eventReceived).toBe(true); }); - it('sent events from exit handlers of a stopped child should be received by its children', () => { - let eventReceived = false; + it('sent events from exit handlers of a stopped child should not be received by its children', () => { + const spy = jest.fn(); const grandchild = createMachine({ id: 'grandchild', on: { STOPPED: { - actions: () => { - eventReceived = true; - } + actions: spy } } }); @@ -1799,10 +1583,10 @@ describe('entry/exit actions', () => { const interpreter = createActor(parent).start(); interpreter.send({ type: 'NEXT' }); - expect(eventReceived).toBe(true); + expect(spy).not.toHaveBeenCalled(); }); - it('sent events from exit handlers of a done child should be received by its children ', () => { + it('sent events from exit handlers of a done child should be received by its children', () => { const spy = jest.fn(); const grandchild = createMachine({ @@ -1873,8 +1657,8 @@ describe('entry/exit actions', () => { interpreter.stop(); }); - it('should execute referenced custom actions correctly when stopping an interpreter', () => { - let called = false; + it('should note execute referenced custom actions correctly when stopping an interpreter', () => { + const spy = jest.fn(); const parent = createMachine( { id: 'parent', @@ -1883,9 +1667,7 @@ describe('entry/exit actions', () => { }, { actions: { - referencedAction: () => { - called = true; - } + referencedAction: spy } } ); @@ -1893,10 +1675,10 @@ describe('entry/exit actions', () => { const interpreter = createActor(parent).start(); interpreter.stop(); - expect(called).toBe(true); + expect(spy).not.toHaveBeenCalled(); }); - it('should execute builtin actions correctly when stopping an interpreter', () => { + it('should not execute builtin actions when stopping an interpreter', () => { const machine = createMachine( { context: { @@ -1927,10 +1709,7 @@ describe('entry/exit actions', () => { const interpreter = createActor(machine).start(); interpreter.stop(); - expect(interpreter.getSnapshot().context.executedAssigns).toEqual([ - 'referenced', - 'inline' - ]); + expect(interpreter.getSnapshot().context.executedAssigns).toEqual([]); }); it('should clear all scheduled events when the interpreter gets stopped', () => { @@ -1992,10 +1771,10 @@ describe('entry/exit actions', () => { service.send({ type: 'INITIALIZE_SYNC_SEQUENCE' }); - expect(exitActions).toEqual(['foo action', 'bar action']); + expect(exitActions).toEqual(['foo action']); }); - it('should execute exit actions of the settled state of the last initiated microstep after executing all actions from that microstep', () => { + it('should not execute exit actions of the settled state of the last initiated microstep after executing all actions from that microstep', () => { const executedActions: string[] = []; const machine = createMachine({ initial: 'foo', @@ -2033,8 +1812,7 @@ describe('entry/exit actions', () => { expect(executedActions).toEqual([ 'foo exit action', - 'foo transition action', - 'bar exit action' + 'foo transition action' ]); }); }); diff --git a/packages/core/test/final.test.ts b/packages/core/test/final.test.ts index 5bcd9fa4d2..76b472b7ee 100644 --- a/packages/core/test/final.test.ts +++ b/packages/core/test/final.test.ts @@ -4,6 +4,7 @@ import { assign, AnyActorRef } from '../src/index.ts'; +import { trackEntries } from './utils.ts'; describe('final states', () => { it('status of a machine with a root state being final should be done', () => { @@ -825,4 +826,205 @@ describe('final states', () => { expect(spy).toBeCalledTimes(1); }); + + it('should call exit actions in reversed document order when the machines reaches its final state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const flushTracked = trackEntries(machine); + + const actorRef = createActor(machine).start(); + flushTracked(); + + // it's important to send an event here that results in a transition that computes new `state.configuration` + // and that could impact the order in which exit actions are called + actorRef.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([ + // result of the transition + 'exit: a', + 'enter: b', + // result of reaching final states + 'exit: b', + 'exit: __root__' + ]); + }); + + it('should call exit actions of parallel states in reversed document order when the machines reaches its final state after earlier region transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'child_a1', + states: { + child_a1: { + on: { + EV2: 'child_a2' + } + }, + child_a2: { + type: 'final' + } + } + }, + b: { + initial: 'child_b1', + states: { + child_b1: { + on: { + EV1: 'child_b2' + } + }, + child_b2: { + type: 'final' + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actorRef = createActor(machine).start(); + + // it's important to send an event here that results in a transition as that computes new `state.configuration` + // and that could impact the order in which exit actions are called + actorRef.send({ type: 'EV1' }); + flushTracked(); + actorRef.send({ type: 'EV2' }); + + expect(flushTracked()).toEqual([ + // result of the transition + 'exit: a.child_a1', + 'enter: a.child_a2', + // result of reaching final states + 'exit: b.child_b2', + 'exit: b', + 'exit: a.child_a2', + 'exit: a', + 'exit: __root__' + ]); + }); + + it('should call exit actions of parallel states in reversed document order when the machines reaches its final state after later region transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'child_a1', + states: { + child_a1: { + on: { + EV2: 'child_a2' + } + }, + child_a2: { + type: 'final' + } + } + }, + b: { + initial: 'child_b1', + states: { + child_b1: { + on: { + EV1: 'child_b2' + } + }, + child_b2: { + type: 'final' + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actorRef = createActor(machine).start(); + // it's important to send an event here that results in a transition as that computes new `state.configuration` + // and that could impact the order in which exit actions are called + actorRef.send({ type: 'EV1' }); + flushTracked(); + actorRef.send({ type: 'EV2' }); + + expect(flushTracked()).toEqual([ + // result of the transition + 'exit: a.child_a1', + 'enter: a.child_a2', + // result of reaching final states + 'exit: b.child_b2', + 'exit: b', + 'exit: a.child_a2', + 'exit: a', + 'exit: __root__' + ]); + }); + + it('should call exit actions of parallel states in reversed document order when the machines reaches its final state after multiple regions transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'child_a1', + states: { + child_a1: { + on: { + EV: 'child_a2' + } + }, + child_a2: { + type: 'final' + } + } + }, + b: { + initial: 'child_b1', + states: { + child_b1: { + on: { + EV: 'child_b2' + } + }, + child_b2: { + type: 'final' + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actorRef = createActor(machine).start(); + flushTracked(); + // it's important to send an event here that results in a transition as that computes new `state.configuration` + // and that could impact the order in which exit actions are called + actorRef.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([ + // result of the transition + 'exit: b.child_b1', + 'exit: a.child_a1', + 'enter: a.child_a2', + 'enter: b.child_b2', + // result of reaching final states + 'exit: b.child_b2', + 'exit: b', + 'exit: a.child_a2', + 'exit: a', + 'exit: __root__' + ]); + }); }); diff --git a/packages/core/test/rehydration.test.ts b/packages/core/test/rehydration.test.ts index 1f4b573e59..94ad58cf38 100644 --- a/packages/core/test/rehydration.test.ts +++ b/packages/core/test/rehydration.test.ts @@ -23,7 +23,7 @@ describe('rehydration', () => { expect(service.getSnapshot().hasTag('foo')).toBe(true); }); - it('should call exit actions when machine gets stopped immediately', () => { + it('should not call exit actions when machine gets stopped immediately', () => { const actual: string[] = []; const machine = createMachine({ exit: () => actual.push('root'), @@ -39,12 +39,11 @@ describe('rehydration', () => { const persistedState = JSON.stringify(actorRef.getPersistedState()); actorRef.stop(); - actual.length = 0; createActor(machine, { state: JSON.parse(persistedState) }) .start() .stop(); - expect(actual).toEqual(['a', 'root']); + expect(actual).toEqual([]); }); it('should get correct result back from `can` immediately', () => { @@ -92,7 +91,7 @@ describe('rehydration', () => { expect(service.getSnapshot().hasTag('foo')).toBe(true); }); - it('should call exit actions when machine gets stopped immediately', () => { + it('should not call exit actions when machine gets stopped immediately', () => { const actual: string[] = []; const machine = createMachine({ exit: () => actual.push('root'), @@ -113,7 +112,7 @@ describe('rehydration', () => { .start() .stop(); - expect(actual).toEqual(['active', 'root']); + expect(actual).toEqual([]); }); });