From 0dabaa7be7e11b7567def5e1b747b4077cd8703f Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Sat, 17 Aug 2024 16:08:52 -0700 Subject: [PATCH] fix(core): errored initial snapshot throws sync w/o observers This modifies the error-handling logic for when an error is thrown during the creation of an Actor's initial snapshot (during `start()`). _If_ the actor has _no_ observers, the error will now be thrown synchronously out of `start()` instead of to the global error handler. Example use case: ```js const machine = createMachine({ context: () => { throw new Error('egad!'); } }); const actor = createActor(machine); try { await toPromise(actor.start()); } catch (err) { err.message === 'egad!' // true } ``` Note that this _does not impact child actors_. Fixes: #4928 --- packages/core/src/createActor.ts | 19 +++++++++++++++ packages/core/test/errors.test.ts | 40 ++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 3ad250edcc..234362e989 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -491,7 +491,26 @@ export class Actor // TODO: rethink cleanup of observers, mailbox, etc return this; case 'error': + // in this case, creation of the initial snapshot caused an error. + + // **if the actor has no observer when start() is called**, this error would otherwise be + // thrown to the global error handler. to prevent this--and allow start() to be wrapped in try/catch--we + // create a temporary observer that receives the error (via _reportError()) and rethrows it from this call stack. + let err: unknown; + let errorTrapSub: Subscription | undefined; + if (!this.observers.size) { + errorTrapSub = this.subscribe({ + error: (error) => { + // we cannot throw here because it would be caught elsewhere and rethrown as unhandled + err = error; + } + }); + } this._error((this._snapshot as any).error); + errorTrapSub?.unsubscribe(); + if (err) { + throw err; + } return this; } diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts index 0a6cb0d1a1..200cade4f3 100644 --- a/packages/core/test/errors.test.ts +++ b/packages/core/test/errors.test.ts @@ -5,7 +5,8 @@ import { createMachine, fromCallback, fromPromise, - fromTransition + fromTransition, + toPromise } from '../src'; const cleanups: (() => void)[] = []; @@ -892,4 +893,41 @@ describe('error handling', () => { error_thrown_in_guard_when_transitioning] `); }); + + it('error thrown when resolving the initial context should rethrow synchronously', (done) => { + const machine = createMachine({ + context: () => { + throw new Error('oh no'); + } + }); + + const actor = createActor(machine); + + installGlobalOnErrorHandler(() => { + done.fail(); + }); + + expect(() => actor.start()).toThrowErrorMatchingInlineSnapshot(`"oh no"`); + + setTimeout(() => { + done(); + }, 10); + }); + + it('error thrown when resolving the initial context should reject when wrapped in a Promise', async () => { + const machine = createMachine({ + context: () => { + throw new Error('oh no'); + } + }); + + const actor = createActor(machine); + + try { + await toPromise(actor.start()); + fail(); + } catch (err) { + expect((err as Error).message).toEqual('oh no'); + } + }); });