From 63d923857592437dc174518ba02e061082f629cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Wed, 29 Nov 2023 19:06:19 +0100 Subject: [PATCH] Further errors-related adjustments (#4492) * Further errors-related adjuments * tweak tests and comments * add changeset * Update .changeset/olive-months-shave.md Co-authored-by: David Khourshid --------- Co-authored-by: David Khourshid --- .changeset/olive-months-shave.md | 5 + README.md | 2 +- packages/core/src/StateMachine.ts | 24 +-- packages/core/src/actions/spawnChild.ts | 7 +- packages/core/src/interpreter.ts | 110 +++++++++--- packages/core/src/spawn.ts | 7 +- packages/core/src/stateUtils.ts | 23 ++- packages/core/src/types.ts | 3 +- packages/core/test/actions.test.ts | 44 +++-- packages/core/test/actorLogic.test.ts | 33 ++-- packages/core/test/errors.test.ts | 206 ++++++++++++++++++++++- packages/core/test/input.test.ts | 18 +- packages/core/test/interpreter.test.ts | 9 +- packages/core/test/machine.test.ts | 73 ++++---- packages/core/test/rehydration.test.ts | 68 ++++++++ packages/xstate-solid/src/createSpawn.ts | 2 +- 16 files changed, 479 insertions(+), 155 deletions(-) create mode 100644 .changeset/olive-months-shave.md diff --git a/.changeset/olive-months-shave.md b/.changeset/olive-months-shave.md new file mode 100644 index 0000000000..7e1b545da8 --- /dev/null +++ b/.changeset/olive-months-shave.md @@ -0,0 +1,5 @@ +--- +'xstate': major +--- + +All errors caught while executing the actor should now consistently include the error in its `snapshot.error` and should be reported to the closest `error` listener. diff --git a/README.md b/README.md index 2133945e0d..a8ec399104 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ const toggleMachine = createMachine({ inactive: { on: { TOGGLE: 'active' } }, active: { entry: assign({ count: ({ context }) => context.count + 1 }), - on: { TOGGLE: 'inactive' } + on: { TOGGLE: 'inactive' } } } }); diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index 46ec53d58d..bd1c73e96e 100644 --- a/packages/core/src/StateMachine.ts +++ b/packages/core/src/StateMachine.ts @@ -2,7 +2,6 @@ import { assign } from './actions.ts'; import { createInitEvent } from './eventUtils.ts'; import { STATE_DELIMITER } from './constants.ts'; import { - cloneMachineSnapshot, createMachineSnapshot, getPersistedState, MachineSnapshot @@ -53,11 +52,7 @@ import type { HistoryValue, StateSchema } from './types.ts'; -import { - getAllOwnEventDescriptors, - isErrorActorEvent, - resolveReferencedActor -} from './utils.ts'; +import { resolveReferencedActor } from './utils.ts'; import { $$ACTOR_TYPE, createActor } from './interpreter.ts'; import isDevelopment from '#is-development'; @@ -292,22 +287,7 @@ export class StateMachine< TOutput, TResolvedTypesMeta > { - // TODO: handle error events in a better way - if ( - isErrorActorEvent(event) && - !getAllOwnEventDescriptors(snapshot).some( - (nextEvent) => nextEvent === event.type - ) - ) { - return cloneMachineSnapshot(snapshot, { - status: 'error', - error: event.data - }); - } - - const { state: nextState } = macrostep(snapshot, event, actorScope); - - return nextState as typeof snapshot; + return macrostep(snapshot, event, actorScope).state as typeof snapshot; } /** diff --git a/packages/core/src/actions/spawnChild.ts b/packages/core/src/actions/spawnChild.ts index af36152824..976eaca202 100644 --- a/packages/core/src/actions/spawnChild.ts +++ b/packages/core/src/actions/spawnChild.ts @@ -105,12 +105,7 @@ function executeSpawn( if (actorRef._processingStatus === ProcessingStatus.Stopped) { return; } - try { - actorRef.start?.(); - } catch (err) { - (actorScope.self as AnyActor).send(createErrorActorEvent(id, err)); - return; - } + actorRef.start(); }); } diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index d6aeeaec9b..c5fa809d08 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -206,11 +206,22 @@ export class Actor } private _initState(persistedState?: Snapshot) { - this._state = persistedState - ? this.logic.restoreState - ? this.logic.restoreState(persistedState, this._actorScope) - : persistedState - : this.logic.getInitialState(this._actorScope, this.options?.input); + try { + this._state = persistedState + ? this.logic.restoreState + ? this.logic.restoreState(persistedState, this._actorScope) + : persistedState + : this.logic.getInitialState(this._actorScope, this.options?.input); + } catch (err) { + // if we get here then it means that we assign a value to this._state that is not of the correct type + // we can't get the true `TSnapshot & { status: 'error'; }`, it's impossible + // so right now this is a lie of sorts + this._state = { + status: 'error', + output: undefined, + error: err + } as any; + } } // array of functions to defer @@ -224,19 +235,48 @@ export class Actor let deferredFn: (typeof this._deferred)[number] | undefined; while ((deferredFn = this._deferred.shift())) { - deferredFn(); - } - - for (const observer of this.observers) { try { - observer.next?.(snapshot); + deferredFn(); } catch (err) { - reportUnhandledError(err); + // this error can only be caught when executing *initial* actions + // it's the only time when we call actions provided by the user through those deferreds + // when the actor is already running we always execute them synchronously while transitioning + // no "builtin deferred" should actually throw an error since they are either safe + // or the control flow is passed through the mailbox and errors should be caught by the `_process` used by the mailbox + this._deferred.length = 0; + this._state = { + ...(snapshot as any), + status: 'error', + error: err + }; } } switch ((this._state as any).status) { + case 'active': + for (const observer of this.observers) { + try { + observer.next?.(snapshot); + } catch (err) { + reportUnhandledError(err); + } + } + break; case 'done': + // next observers are meant to be notified about done snapshots + // this can be seen as something that is different from how observable work + // but with observables `complete` callback is called without any arguments + // it's more ergonomic for XState to treat a done snapshot as a "next" value + // and the completion event as something that is separate, + // something that merely follows emitting that done snapshot + for (const observer of this.observers) { + try { + observer.next?.(snapshot); + } catch (err) { + reportUnhandledError(err); + } + } + this._stopProcedure(); this._complete(); this._doneEvent = createDoneActorEvent( @@ -249,15 +289,7 @@ export class Actor break; case 'error': - this._stopProcedure(); this._error((this._state as any).error); - if (this._parent) { - this.system._relay( - this, - this._parent, - createErrorActorEvent(this.id, (this._state as any).error) - ); - } break; } this.system._sendInspectionEvent({ @@ -365,7 +397,7 @@ export class Actor this.subscribe({ next: (snapshot: Snapshot) => { if (snapshot.status === 'active') { - this._parent!.send({ + this.system._relay(this, this._parent!, { type: `xstate.snapshot.${this.id}`, snapshot }); @@ -401,19 +433,23 @@ export class Actor this._state, initEvent as unknown as EventFromLogic ); - // fallthrough - case 'error': // TODO: rethink cleanup of observers, mailbox, etc return this; + case 'error': + this._error((this._state as any).error); + return this; } if (this.logic.start) { try { this.logic.start(this._state, this._actorScope); } catch (err) { - this._stopProcedure(); + this._state = { + ...(this._state as any), + status: 'error', + error: err + }; this._error(err); - this._parent?.send(createErrorActorEvent(this.id, err)); return this; } } @@ -433,7 +469,6 @@ export class Actor } private _process(event: EventFromLogic) { - // TODO: reexamine what happens when an action (or a guard or smth) throws let nextState; let caughtError; try { @@ -446,9 +481,12 @@ export class Actor if (caughtError) { const { err } = caughtError; - this._stopProcedure(); + this._state = { + ...(this._state as any), + status: 'error', + error: err + }; this._error(err); - this._parent?.send(createErrorActorEvent(this.id, err)); return; } @@ -492,7 +530,7 @@ export class Actor } this.observers.clear(); } - private _error(err: unknown): void { + private _reportError(err: unknown): void { if (!this.observers.size) { if (!this._parent) { reportUnhandledError(err); @@ -515,6 +553,22 @@ export class Actor reportUnhandledError(err); } } + private _error(err: unknown): void { + this._stopProcedure(); + this._reportError(err); + if (this._parent) { + this.system._relay( + this, + this._parent, + createErrorActorEvent(this.id, err) + ); + } + } + // TODO: atm children don't belong entirely to the actor so + // in a way - it's not even super aware of them + // so we can't stop them from here but we really should! + // right now, they are being stopped within the machine's transition + // but that could throw and leave us with "orphaned" active actors private _stopProcedure(): this { if (this._processingStatus !== ProcessingStatus.Running) { // Actor already stopped; do nothing diff --git a/packages/core/src/spawn.ts b/packages/core/src/spawn.ts index fdc8e53054..194a142d91 100644 --- a/packages/core/src/spawn.ts +++ b/packages/core/src/spawn.ts @@ -111,12 +111,7 @@ export function createSpawner( if (actorRef._processingStatus === ProcessingStatus.Stopped) { return; } - try { - actorRef.start?.(); - } catch (err) { - actorScope.self.send(createErrorActorEvent(actorRef.id, err)); - return; - } + actorRef.start(); }); return actorRef; }; diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 52de1ca0ca..15bd269a54 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -45,7 +45,8 @@ import { normalizeTarget, toArray, toStatePath, - toTransitionConfigArray + toTransitionConfigArray, + isErrorActorEvent } from './utils.ts'; import { ProcessingStatus } from './interpreter.ts'; @@ -1643,7 +1644,25 @@ export function macrostep( // Assume the state is at rest (no raised events) // Determine the next state based on the next microstep if (nextEvent.type !== XSTATE_INIT) { - const transitions = selectTransitions(nextEvent, nextState); + const currentEvent = nextEvent; + const isErr = isErrorActorEvent(currentEvent); + + const transitions = selectTransitions(currentEvent, nextState); + + if (isErr && !transitions.length) { + // TODO: we should likely only allow transitions selected by very explicit descriptors + // `*` shouldn't be matched, likely `xstate.error.*` shouldnt be either + // similarly `xstate.error.actor.*` and `xstate.error.actor.todo.*` have to be considered too + nextState = cloneMachineSnapshot(state, { + status: 'error', + error: currentEvent.data + }); + states.push(nextState); + return { + state: nextState, + microstates: states + }; + } nextState = microstep( transitions, state, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5b541161fd..5013b97872 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1918,8 +1918,7 @@ export interface ActorRef< /** @internal */ _send: (event: TEvent) => void; send: (event: TEvent) => void; - // TODO: should this be optional? - start?: () => void; + start: () => void; getSnapshot: () => TSnapshot; getPersistedState: () => Snapshot; stop: () => void; diff --git a/packages/core/test/actions.test.ts b/packages/core/test/actions.test.ts index fe4a8556c8..0072c8f323 100644 --- a/packages/core/test/actions.test.ts +++ b/packages/core/test/actions.test.ts @@ -3185,7 +3185,7 @@ describe('sendTo', () => { service.send({ type: 'EVENT', value: 'foo' }); }); - it('should throw if given a string', () => { + it('should error if given a string', () => { const machine = createMachine({ invoke: { id: 'child', @@ -3194,11 +3194,21 @@ describe('sendTo', () => { entry: sendTo('child', 'a string') }); - expect(() => { - createActor(machine).start(); - }).toThrowErrorMatchingInlineSnapshot( - `"Only event objects may be used with sendTo; use sendTo({ type: "a string" }) instead"` - ); + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Only event objects may be used with sendTo; use sendTo({ type: "a string" }) instead], + ], + ] + `); }); }); @@ -3410,7 +3420,7 @@ describe('raise', () => { }, 10); }); - it('should throw if given a string', () => { + it('should error if given a string', () => { const machine = createMachine({ entry: raise( // @ts-ignore @@ -3418,11 +3428,21 @@ describe('raise', () => { ) }); - expect(() => { - createActor(machine).start(); - }).toThrowErrorMatchingInlineSnapshot( - `"Only event objects may be used with raise; use raise({ type: "a string" }) instead"` - ); + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Only event objects may be used with raise; use raise({ type: "a string" }) instead], + ], + ] + `); }); }); diff --git a/packages/core/test/actorLogic.test.ts b/packages/core/test/actorLogic.test.ts index 94857d79d4..ca7bc76c3a 100644 --- a/packages/core/test/actorLogic.test.ts +++ b/packages/core/test/actorLogic.test.ts @@ -185,13 +185,13 @@ describe('promise logic (fromPromise)', () => { createdPromises++; return Promise.reject(createdPromises); }); - const actor = createActor(promiseLogic); - actor.subscribe({ error: function preventUnhandledErrorListener() {} }); - actor.start(); + const actorRef = createActor(promiseLogic); + actorRef.subscribe({ error: function preventUnhandledErrorListener() {} }); + actorRef.start(); await new Promise((res) => setTimeout(res, 5)); - const rejectedPersistedState = actor.getPersistedState(); + const rejectedPersistedState = actorRef.getPersistedState(); expect(rejectedPersistedState).toMatchInlineSnapshot(` { "error": 1, @@ -202,9 +202,11 @@ describe('promise logic (fromPromise)', () => { `); expect(createdPromises).toBe(1); - createActor(promiseLogic, { + const actorRef2 = createActor(promiseLogic, { state: rejectedPersistedState - }).start(); + }); + actorRef2.subscribe({ error: function preventUnhandledErrorListener() {} }); + actorRef2.start(); expect(createdPromises).toBe(1); }); @@ -347,19 +349,24 @@ describe('observable logic (fromObservable)', () => { expect(spy).toHaveBeenCalledWith(42); }); - it('should reject (observer .error)', (done) => { - const actor = createActor(fromObservable(() => throwError(() => 'Error'))); + it('should reject (observer .error)', () => { + const actor = createActor( + fromObservable(() => throwError(() => 'Observable error.')) + ); const spy = jest.fn(); actor.subscribe({ - next: (snapshot) => spy(snapshot.error), - error: () => { - done(); - } + error: spy }); actor.start(); - expect(spy).toHaveBeenCalledWith('Error'); + expect(spy).toMatchMockCallsInlineSnapshot(` + [ + [ + "Observable error.", + ], + ] + `); }); it('should complete (observer .complete)', () => { diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts index 20953ffd94..1f70e124db 100644 --- a/packages/core/test/errors.test.ts +++ b/packages/core/test/errors.test.ts @@ -1,5 +1,12 @@ -import { createMachine, fromCallback, fromPromise, createActor } from '../src'; import { sleep } from '@xstate-repo/jest-utils'; +import { + assign, + createActor, + createMachine, + fromCallback, + fromPromise, + fromTransition +} from '../src'; const cleanups: (() => void)[] = []; function installGlobalOnErrorHandler(handler: (ev: ErrorEvent) => void) { @@ -346,7 +353,7 @@ describe('error handling', () => { const actorRef = createActor(machine); const childActorRef = Object.values(actorRef.getSnapshot().children)[0]; childActorRef.subscribe({ - error: () => {} + error: function preventUnhandledErrorListener() {} }); childActorRef.subscribe(() => {}); actorRef.start(); @@ -379,10 +386,10 @@ describe('error handling', () => { const actorRef = createActor(machine); const childActorRef = Object.values(actorRef.getSnapshot().children)[0]; childActorRef.subscribe({ - error: () => {} + error: function preventUnhandledErrorListener() {} }); childActorRef.subscribe({ - error: () => {} + error: function preventUnhandledErrorListener() {} }); actorRef.start(); @@ -412,7 +419,7 @@ describe('error handling', () => { const actorRef = createActor(machine); const childActorRef = Object.values(actorRef.getSnapshot().children)[0]; childActorRef.subscribe({ - error: () => {} + error: function preventUnhandledErrorListener() {} }); childActorRef.subscribe({}); actorRef.start(); @@ -644,7 +651,7 @@ describe('error handling', () => { const actorRef = createActor(machine); actorRef.subscribe({ - error: () => {} + error: function preventUnhandledErrorListener() {} }); actorRef.subscribe(() => {}); actorRef.start(); @@ -698,4 +705,191 @@ describe('error handling', () => { } }); }); + + it('error thrown in initial custom entry action should error the actor', () => { + const machine = createMachine({ + entry: () => { + throw new Error('error_thrown_in_initial_entry_action'); + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: error_thrown_in_initial_entry_action]` + ); + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: error_thrown_in_initial_entry_action], + ], + ] + `); + }); + + it('error thrown when resolving initial builtin entry action should error the actor immediately', () => { + const machine = createMachine({ + entry: assign(() => { + throw new Error('error_thrown_when_resolving_initial_entry_action'); + }) + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: error_thrown_when_resolving_initial_entry_action]` + ); + + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: error_thrown_when_resolving_initial_entry_action], + ], + ] + `); + }); + + it('error thrown by a custom entry action when transitioning should error the actor', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + entry: () => { + throw new Error( + 'error_thrown_in_a_custom_entry_action_when_transitioning' + ); + } + } + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + actorRef.send({ type: 'NEXT' }); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: error_thrown_in_a_custom_entry_action_when_transitioning]` + ); + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: error_thrown_in_a_custom_entry_action_when_transitioning], + ], + ] + `); + }); + + it(`shouldn't execute deferred initial actions that come after an action that errors`, () => { + const spy = jest.fn(); + + const machine = createMachine({ + entry: [ + () => { + throw new Error('error_thrown_in_initial_entry_action'); + }, + spy + ] + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ error: function preventUnhandledErrorListener() {} }); + actorRef.start(); + + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('should error the parent on errored initial state of a child', async () => { + const immediateFailure = fromTransition((_) => undefined, undefined); + immediateFailure.getInitialState = () => ({ + status: 'error', + output: undefined, + error: 'immediate error!', + context: undefined + }); + + const machine = createMachine( + { + invoke: { + src: 'failure' + } + }, + { + actors: { + failure: immediateFailure + } + } + ); + + const actorRef = createActor(machine); + actorRef.subscribe({ error: function preventUnhandledErrorListener() {} }); + actorRef.start(); + + const snapshot = actorRef.getSnapshot(); + + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toBe('immediate error!'); + }); + + it('should error when a guard throws when transitioning', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: { + guard: () => { + throw new Error('error_thrown_in_guard_when_transitioning'); + }, + target: 'b' + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: spy + }); + actorRef.start(); + actorRef.send({ type: 'NEXT' }); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot(` + [Error: Unable to evaluate guard in transition for event 'NEXT' in state node '(machine).a': + error_thrown_in_guard_when_transitioning] + `); + }); }); diff --git a/packages/core/test/input.test.ts b/packages/core/test/input.test.ts index e668ddb2f9..d9fb6b8496 100644 --- a/packages/core/test/input.test.ts +++ b/packages/core/test/input.test.ts @@ -41,7 +41,7 @@ describe('input', () => { createActor(machine, { input: { greeting: 'hello' } }).start(); }); - it('should throw if input is expected but not provided', () => { + it('should error if input is expected but not provided', () => { const machine = createMachine({ types: {} as { input: { greeting: string }; @@ -52,21 +52,9 @@ describe('input', () => { } }); - expect(() => { - createActor(machine).start(); - }).toThrowError(/Cannot read properties of undefined/); - }); - - it('should not throw if input is not expected and not provided', () => { - const machine = createMachine({ - context: () => { - return { count: 42 }; - } - }); + const snapshot = createActor(machine).getSnapshot(); - expect(() => { - createActor(machine).start(); - }).not.toThrowError(); + expect(snapshot.status).toBe('error'); }); it('should be a type error if input is not expected yet provided', () => { diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 51017574fb..be0cfed8a3 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -722,10 +722,11 @@ describe('interpreter', () => { } }; - expect(() => { - createActor(createMachine(invalidMachine)).start(); - }).toThrowErrorMatchingInlineSnapshot( - `"Initial state node "create" not found on parent state node #fetchMachine"` + const snapshot = createActor(createMachine(invalidMachine)).getSnapshot(); + + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: Initial state node "create" not found on parent state node #fetchMachine]` ); }); diff --git a/packages/core/test/machine.test.ts b/packages/core/test/machine.test.ts index b221512b13..b28f389351 100644 --- a/packages/core/test/machine.test.ts +++ b/packages/core/test/machine.test.ts @@ -95,62 +95,61 @@ describe('machine', () => { }); describe('machine.provide', () => { - it('should override guards and actions', () => { + it('should override an action', () => { + const originalEntry = jest.fn(); + const overridenEntry = jest.fn(); + const machine = createMachine( { - initial: 'foo', - context: { - foo: 'bar' - }, - states: { - foo: { - entry: 'entryAction', - on: { - EVENT: { - target: 'bar', - guard: 'someCondition' - } - } - }, - bar: {} - } + entry: 'entryAction' }, { actions: { - entryAction: () => { - throw new Error('original entry'); - } - }, - guards: { - someCondition: () => false + entryAction: originalEntry } } ); - let shouldThrow = true; const differentMachine = machine.provide({ actions: { - entryAction: () => { - if (shouldThrow) { - throw new Error('new entry'); + entryAction: overridenEntry + } + }); + + createActor(differentMachine).start(); + + expect(originalEntry).toHaveBeenCalledTimes(0); + expect(overridenEntry).toHaveBeenCalledTimes(1); + }); + + it('should override a guard', () => { + const originalGuard = jest.fn().mockImplementation(() => true); + const overridenGuard = jest.fn().mockImplementation(() => true); + + const machine = createMachine( + { + on: { + EVENT: { + guard: 'someCondition', + actions: () => {} } } }, - guards: { someCondition: () => true } - }); + { + guards: { + someCondition: originalGuard + } + } + ); - expect(createActor(differentMachine).getSnapshot().context).toEqual({ - foo: 'bar' + const differentMachine = machine.provide({ + guards: { someCondition: overridenGuard } }); - expect(() => { - createActor(differentMachine).start(); - }).toThrowErrorMatchingInlineSnapshot(`"new entry"`); - - shouldThrow = false; const actorRef = createActor(differentMachine).start(); actorRef.send({ type: 'EVENT' }); - expect(actorRef.getSnapshot().value).toEqual('bar'); + expect(originalGuard).toHaveBeenCalledTimes(0); + expect(overridenGuard).toHaveBeenCalledTimes(1); }); it('should not override context if not defined', () => { diff --git a/packages/core/test/rehydration.test.ts b/packages/core/test/rehydration.test.ts index 29dce7f77d..4978cb1973 100644 --- a/packages/core/test/rehydration.test.ts +++ b/packages/core/test/rehydration.test.ts @@ -7,6 +7,7 @@ import { assign, sendTo } from '../src/index.ts'; +import { sleep } from '@xstate-repo/jest-utils'; describe('rehydration', () => { describe('using persisted state', () => { @@ -324,6 +325,73 @@ describe('rehydration', () => { expect(spy).toHaveBeenCalled(); }); + it('should error on a rehydrated error state', async () => { + const machine = createMachine( + { + invoke: { + src: 'failure' + } + }, + { + actors: { + failure: fromPromise(() => Promise.reject(new Error('failure'))) + } + } + ); + + const actorRef = createActor(machine); + actorRef.subscribe({ error: function preventUnhandledErrorListener() {} }); + actorRef.start(); + + // wait a macrotask for the microtask related to the promise to be processed + await sleep(0); + + const persistedState = actorRef.getPersistedState(); + + const spy = jest.fn(); + const actorRef2 = createActor(machine, { state: persistedState }); + actorRef2.subscribe({ + error: spy + }); + actorRef2.start(); + + expect(spy).toHaveBeenCalled(); + }); + + it(`shouldn't re-notify the parent about the error when rehydrating`, async () => { + const spy = jest.fn(); + + const machine = createMachine( + { + invoke: { + src: 'failure', + onError: { + actions: spy + } + } + }, + { + actors: { + failure: fromPromise(() => Promise.reject(new Error('failure'))) + } + } + ); + + const actorRef = createActor(machine); + actorRef.start(); + + // wait a macrotask for the microtask related to the promise to be processed + await sleep(0); + + const persistedState = actorRef.getPersistedState(); + spy.mockClear(); + + const actorRef2 = createActor(machine, { state: persistedState }); + actorRef2.start(); + + expect(spy).not.toHaveBeenCalled(); + }); + it('should continue syncing snapshots', () => { const subject = new BehaviorSubject(0); const subjectLogic = fromObservable(() => subject); diff --git a/packages/xstate-solid/src/createSpawn.ts b/packages/xstate-solid/src/createSpawn.ts index 5e04e146bb..81e7e28d55 100644 --- a/packages/xstate-solid/src/createSpawn.ts +++ b/packages/xstate-solid/src/createSpawn.ts @@ -16,7 +16,7 @@ export function createSpawn< const actorRef = createActor(logic); if (!isServer) { - actorRef.start?.(); + actorRef.start(); onCleanup(() => actorRef!.stop?.()); }