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 all commits
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 @@ -144,6 +144,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(
/** The raw config used to create the machine. */
Expand Down Expand Up @@ -216,6 +217,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 @@ -1027,6 +1027,8 @@ export interface StateNodeConfig<

/** A default target for a history state */
target?: string;

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

export type AnyStateNodeConfig = StateNodeConfig<
Expand Down
221 changes: 221 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,221 @@
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');
});

it('throws an error and does not transition if the invariant fails on a transition within the state', () => {
const machine = createMachine({
initial: 'userLoaded',
states: {
userLoaded: {
initial: 'active',
states: {
active: {
on: {
deactivate: 'inactive'
}
},
inactive: {
entry: assign({ user: null })
}
},
invariant: (x) => {
if (!x.context.user) {
throw new Error('User not loaded');
}
},
entry: assign({ user: { name: 'David' } })
}
}
});
const spy = jest.fn();

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

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

expect(spy).toHaveBeenCalledWith(new Error('User not loaded'));
expect(actor.getSnapshot().value).toEqual({ userLoaded: 'active' });
});

it('does not throw an error when exiting a state with an invariant if the exit action clears the context', () => {
const machine = createMachine({
initial: 'userLoaded',
states: {
userLoaded: {
invariant: (x) => {
if (!x.context.user) {
throw new Error('User not loaded');
}
},
entry: assign({ user: { name: 'David' } }),
exit: assign({ user: null }),
on: {
logout: 'idle'
}
},
idle: {}
}
});
const spy = jest.fn();

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

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

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

it('parallel regions check for state invariants', () => {
const spy = jest.fn();

const machine = createMachine({
initial: 'p',
types: {
context: {} as { user: { name: string; age: number } | null }
},
context: {
user: {
name: 'David',
age: 30
}
},
states: {
p: {
type: 'parallel',
states: {
a: {
invariant: (x) => {
if (!x.context.user) {
throw new Error('User not loaded');
}
},
on: {
updateAge: {
actions: assign({
user: (x) => ({ ...x.context.user!, age: -3 })
})
}
}
},
b: {
invariant: (x) => {
if (x.context.user!.age < 0) {
throw new Error('User age cannot be negative');
}
},
on: {
deleteUser: {
actions: assign({
user: () => null
})
}
}
}
}
}
}
});

const actor = createActor(machine);

actor.subscribe({
error: spy
});

actor.start();

expect(actor.getSnapshot().value).toEqual({
p: {
a: {},
b: {}
}
});

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

expect(spy).toHaveBeenCalledWith(new Error('User age cannot be negative'));

expect(actor.getSnapshot().status).toEqual('error');
});
});