From 50a0b2394f2830dee8b37c2d32ebabe17eff6c83 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Sun, 16 Jun 2024 05:46:28 +0200 Subject: [PATCH 1/4] Add basic support for invariants --- packages/core/src/StateNode.ts | 2 + packages/core/src/stateUtils.ts | 10 +++- packages/core/src/types.ts | 2 + packages/core/test/invariant.test.ts | 74 ++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/invariant.test.ts diff --git a/packages/core/src/StateNode.ts b/packages/core/src/StateNode.ts index b001022f6a..0a548eae48 100644 --- a/packages/core/src/StateNode.ts +++ b/packages/core/src/StateNode.ts @@ -153,6 +153,7 @@ export class StateNode< public tags: string[] = []; public transitions!: Map[]>; public always?: Array>; + public invariant?: ({ context }: { context: TContext }) => void; constructor( /** @@ -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 */ diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 0b3052c939..d01d22e66c 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -22,7 +22,6 @@ import { AnyMachineSnapshot, AnyStateNode, AnyTransitionDefinition, - DelayExpr, DelayedTransitionDefinition, EventObject, HistoryValue, @@ -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 @@ -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); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ac13c45f59..3326b1ff8b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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< diff --git a/packages/core/test/invariant.test.ts b/packages/core/test/invariant.test.ts new file mode 100644 index 0000000000..3f3dd48e73 --- /dev/null +++ b/packages/core/test/invariant.test.ts @@ -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'); + }); +}); From e062b687682ff902d28c553c7ea4b7c5784d031c Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 18 Jun 2024 08:57:24 +0200 Subject: [PATCH 2/4] Add tests --- packages/core/test/invariant.test.ts | 147 +++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/packages/core/test/invariant.test.ts b/packages/core/test/invariant.test.ts index 3f3dd48e73..5e7df8e8e9 100644 --- a/packages/core/test/invariant.test.ts +++ b/packages/core/test/invariant.test.ts @@ -71,4 +71,151 @@ describe('state invariants', () => { 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('interacts correctly with parallel states', () => { + 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'); + }); }); From 3c1edb48fd76f8be5448a4c859d65e1b8e2b905e Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 18 Jun 2024 10:12:44 +0200 Subject: [PATCH 3/4] Types --- packages/core/test/invariant.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/invariant.test.ts b/packages/core/test/invariant.test.ts index 5e7df8e8e9..9c64c8f7ad 100644 --- a/packages/core/test/invariant.test.ts +++ b/packages/core/test/invariant.test.ts @@ -171,14 +171,14 @@ describe('state invariants', () => { on: { updateAge: { actions: assign({ - user: (x) => ({ ...x.context.user, age: -3 }) + user: (x) => ({ ...x.context.user!, age: -3 }) }) } } }, b: { invariant: (x) => { - if (x.context.user.age < 0) { + if (x.context.user!.age < 0) { throw new Error('User age cannot be negative'); } }, From 879ae529e2f354af0b8e773d6cf4c96d78de1b50 Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Tue, 18 Jun 2024 17:58:33 +0200 Subject: [PATCH 4/4] Rename test --- packages/core/test/invariant.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/invariant.test.ts b/packages/core/test/invariant.test.ts index 9c64c8f7ad..52f9f4dcd2 100644 --- a/packages/core/test/invariant.test.ts +++ b/packages/core/test/invariant.test.ts @@ -144,7 +144,7 @@ describe('state invariants', () => { expect(actor.getSnapshot().value).toEqual('idle'); }); - it('interacts correctly with parallel states', () => { + it('parallel regions check for state invariants', () => { const spy = jest.fn(); const machine = createMachine({