Skip to content

Commit

Permalink
Fixed a runtime crash related to machines with their root state's typ…
Browse files Browse the repository at this point in the history
…e being final (#4361)

* Fixed a runtime crash related to machines with their root state's type being final

* add more tests
  • Loading branch information
Andarist committed Oct 17, 2023
1 parent 03ac5c0 commit 1a00b5a
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 65 deletions.
5 changes: 5 additions & 0 deletions .changeset/dry-geese-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'xstate': patch
---

Fixed a runtime crash related to machines with their root state's type being final (`createMachine({ type: 'final' })`).
128 changes: 63 additions & 65 deletions packages/core/src/stateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import {
StateValueMap,
TransitionDefinition,
TODO,
AnyActorRef,
UnknownAction,
ParameterizedObject,
ActionFunction,
Expand All @@ -61,34 +60,6 @@ type AnyConfiguration = Configuration<any, any>;

type AdjList = Map<AnyStateNode, Array<AnyStateNode>>;

function getOutput<TContext extends MachineContext, TEvent extends EventObject>(
configuration: StateNode<TContext, TEvent>[],
context: TContext,
event: TEvent,
self: AnyActorRef
) {
const { machine } = configuration[0];
const { root } = machine;

if (!root.output) {
return undefined;
}

const finalChildStateNode = configuration.find(
(stateNode) =>
stateNode.type === 'final' && stateNode.parent === machine.root
)!;

const doneStateEvent = createDoneStateEvent(
finalChildStateNode.id,
finalChildStateNode.output
? resolveOutput(finalChildStateNode.output, context, event, self)
: undefined
);

return resolveOutput(root.output, context, doneStateEvent, self);
}

export const isAtomicStateNode = (stateNode: StateNode<any, any>) =>
stateNode.type === 'atomic' || stateNode.type === 'final';

Expand Down Expand Up @@ -232,7 +203,7 @@ export function isInFinalState(
);
}

return false;
return stateNode.type === 'final';
}

export const isStateId = (str: string) => str[0] === STATE_IDENTIFIER;
Expand Down Expand Up @@ -1101,7 +1072,7 @@ function microstepProcedure(
);

// Enter states
nextState = enterStates(
const enterStatesResult = enterStates(
nextState,
event,
actorCtx,
Expand All @@ -1111,11 +1082,11 @@ function microstepProcedure(
historyValue,
isInitial
);
const [, done, machineOutput] = enterStatesResult;
nextState = enterStatesResult[0];

const nextConfiguration = [...mutConfiguration];

const done = isInFinalState(mutConfiguration, currentState.machine.root);

if (done) {
nextState = resolveActionsAndContext(
nextState,
Expand All @@ -1128,19 +1099,16 @@ function microstepProcedure(
}

try {
const output = done
? getOutput(nextConfiguration, nextState.context, event, actorCtx.self)
: undefined;

internalQueue.push(...nextState._internalQueue);

return cloneState(currentState, {
configuration: nextConfiguration,
historyValue,
_internalQueue: internalQueue,
context: nextState.context,
// TODO: why this one is using currentState and why do we need to check done here
status: done ? 'done' : currentState.status,
output,
output: machineOutput,
children: nextState.children
});
} catch (e) {
Expand Down Expand Up @@ -1175,6 +1143,9 @@ function enterStates(
statesForDefaultEntry.add(currentState.machine.root);
}

let done = false;
let machineOutput: unknown;

for (const stateNodeToEnter of [...statesToEnter].sort(
(a, b) => a.order - b.order
)) {
Expand Down Expand Up @@ -1202,42 +1173,69 @@ function enterStates(
);

if (stateNodeToEnter.type === 'final') {
const parent = stateNodeToEnter.parent!;
const parent = stateNodeToEnter.parent;
let ancestorMarker: typeof parent | undefined = parent?.parent;

if (ancestorMarker) {
internalQueue.push(
createDoneStateEvent(
parent!.id,
stateNodeToEnter.output
? resolveOutput(
stateNodeToEnter.output,
nextState.context,
event,
actorCtx.self
)
: undefined
)
);
while (ancestorMarker) {
if (
ancestorMarker.type === 'parallel' &&
isInFinalState(mutConfiguration, ancestorMarker)
) {
internalQueue.push(createDoneStateEvent(ancestorMarker.id));
ancestorMarker = ancestorMarker.parent;
continue;
}
break;
}
}
if (ancestorMarker) {
continue;
}

done = true;
const root = currentState.configuration[0].machine.root;

if (!parent.parent) {
if (!root.output) {
continue;
}

internalQueue.push(
createDoneStateEvent(
parent!.id,
stateNodeToEnter.output
? resolveOutput(
stateNodeToEnter.output,
nextState.context,
event,
actorCtx.self
)
: undefined
)
const doneStateEvent = createDoneStateEvent(
stateNodeToEnter.id,
stateNodeToEnter.output && stateNodeToEnter.parent
? resolveOutput(
stateNodeToEnter.output,
nextState.context,
event,
actorCtx.self
)
: undefined
);

let ancestorMarker: typeof parent | undefined = parent.parent;
while (ancestorMarker) {
if (
ancestorMarker.type === 'parallel' &&
isInFinalState(mutConfiguration, ancestorMarker)
) {
internalQueue.push(createDoneStateEvent(ancestorMarker.id));
ancestorMarker = ancestorMarker.parent;
continue;
}
break;
}
machineOutput = resolveOutput(
root.output,
nextState.context,
doneStateEvent,
actorCtx.self
);
continue;
}
}

return nextState;
return [nextState, done, machineOutput] as const;
}

function computeEntrySet(
Expand Down
91 changes: 91 additions & 0 deletions packages/core/test/final.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ import {
} from '../src/index.ts';

describe('final states', () => {
it('status of a machine with a root state being final should be done', () => {
const machine = createMachine({ type: 'final' });
const actorRef = createActor(machine).start();

expect(actorRef.getSnapshot().status).toBe('done');
});
it('output of a machine with a root state being final should be called with a "xstate.done.state.ROOT_ID" event', () => {
const spy = jest.fn();
const machine = createMachine({
type: 'final',
output: ({ event }) => {
spy(event);
}
});
createActor(machine, { input: 42 }).start();

expect(spy.mock.calls).toMatchInlineSnapshot(`
[
[
{
"output": undefined,
"type": "xstate.done.state.(machine)",
},
],
]
`);
});
it('should emit the "xstate.done.state.*" event when all nested states are in their final states', () => {
const onDoneSpy = jest.fn();

Expand Down Expand Up @@ -483,4 +510,68 @@ describe('final states', () => {

expect(actorRef.getSnapshot().status).toBe('done');
});

it('should reach a final state when a parallel state reaches its final state and transitions to a top-level final state in response to that', () => {
const machine = createMachine({
initial: 'a',
states: {
a: {
type: 'parallel',
onDone: 'b',
states: {
a1: {
type: 'parallel',
states: {
a1a: { type: 'final' },
a1b: { type: 'final' }
}
},
a2: {
initial: 'a2a',
states: { a2a: { type: 'final' } }
}
}
},
b: {
type: 'final'
}
}
});

const actorRef = createActor(machine).start();

expect(actorRef.getSnapshot().status).toEqual('done');
});

it('should reach a final state when a parallel state nested in a parallel state reaches its final state and transitions to a top-level final state in response to that', () => {
const machine = createMachine({
initial: 'a',
states: {
a: {
type: 'parallel',
onDone: 'b',
states: {
a1: {
type: 'parallel',
states: {
a1a: { type: 'final' },
a1b: { type: 'final' }
}
},
a2: {
initial: 'a2a',
states: { a2a: { type: 'final' } }
}
}
},
b: {
type: 'final'
}
}
});

const actorRef = createActor(machine).start();

expect(actorRef.getSnapshot().status).toEqual('done');
});
});

0 comments on commit 1a00b5a

Please sign in to comment.