Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core] Add basic support for state invariant #4944

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/StateNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export class StateNode<
public tags: string[] = [];
public transitions!: Map<string, TransitionDefinition<TContext, TEvent>[]>;
public always?: Array<TransitionDefinition<TContext, TEvent>>;
public invariant?: ({ context }: { context: TContext }) => void;

constructor(
/**
Expand Down Expand Up @@ -227,6 +228,7 @@ export class StateNode<
this.output =
this.type === 'final' || !this.parent ? this.config.output : undefined;
this.tags = toArray(config.tags).slice();
this.invariant = config.invariant;
}

/** @internal */
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/stateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
AnyMachineSnapshot,
AnyStateNode,
AnyTransitionDefinition,
DelayExpr,
DelayedTransitionDefinition,
EventObject,
HistoryValue,
Expand Down Expand Up @@ -1690,6 +1689,8 @@ export function macrostep(
);
addMicrostate(nextSnapshot, event, []);

// No need to check invariant since the state is the same

return {
snapshot: nextSnapshot,
microstates
Expand Down Expand Up @@ -1763,6 +1764,13 @@ export function macrostep(
addMicrostate(nextSnapshot, nextEvent, enabledTransitions);
}

// Check invariants
for (const sn of nextSnapshot._nodes) {
if (sn.invariant) {
sn.invariant({ context: nextSnapshot.context });
}
}

if (nextSnapshot.status !== 'active') {
stopChildren(nextSnapshot, nextEvent, actorScope);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,8 @@ export interface StateNodeConfig<
* A default target for a history state
*/
target?: string;

invariant?: ({ context }: { context: TContext }) => void;
}

export type AnyStateNodeConfig = StateNodeConfig<
Expand Down
74 changes: 74 additions & 0 deletions packages/core/test/invariant.test.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need such tests:

  • show the behavior of those invariants for transitions within states that already have invariant, the current tests only show what's the behavior when entering such states
  • show that invariant doesn't create a problems when we exit a state that has some invariant requirement (like, the invariant requires context.user but an exit action within the exited state resets the user to null)
  • show the interactions with entry, always and parallel

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added most of these

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { assign, createActor, createMachine } from '../src';

describe('state invariants', () => {
it('throws an error and does not transition if the invariant throws', () => {
const machine = createMachine({
initial: 'idle',
states: {
idle: {
on: {
loadUser: {
target: 'userLoaded'
}
}
},
userLoaded: {
invariant: (x) => {
if (!x.context.user) {
throw new Error('User not loaded');
}
}
}
}
});
const spy = jest.fn();

const actor = createActor(machine);
actor.subscribe({
error: spy
});
actor.start();

actor.send({ type: 'loadUser' });

expect(spy).toHaveBeenCalledWith(new Error('User not loaded'));

expect(actor.getSnapshot().value).toEqual('idle');
});

it('transitions as normal if the invariant does not fail', () => {
const machine = createMachine({
initial: 'idle',
states: {
idle: {
on: {
loadUser: {
target: 'userLoaded',
actions: assign({ user: () => ({ name: 'David' }) })
}
}
},
userLoaded: {
invariant: (x) => {
if (!x.context.user) {
throw new Error('User not loaded');
}
}
}
}
});
const spy = jest.fn();

const actor = createActor(machine);
actor.subscribe({
error: spy
});
actor.start();

actor.send({ type: 'loadUser' });

expect(spy).not.toHaveBeenCalled();

expect(actor.getSnapshot().value).toEqual('userLoaded');
});
});
Loading