From 123ae979dc08b700b14985535845292a36a30fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Sat, 27 Nov 2021 23:14:26 -0500 Subject: [PATCH 01/15] feat(interpreter): allow to provide errorListeners --- packages/core/src/interpreter.ts | 34 +++++++++++++++++++- packages/core/test/interpreter.test.ts | 43 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index c1b4caf315..ad2c915f5c 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -160,6 +160,7 @@ export class Interpreter< private contextListeners: Set> = new Set(); private stopListeners: Set = new Set(); private doneListeners: Set = new Set(); + private errorListeners: Set = new Set(); private eventListeners: Set = new Set(); private sendListeners: Set = new Set(); private logger: (...args: any[]) => void; @@ -313,6 +314,13 @@ export class Interpreter< this.stop(); } } + + private sendError(error: Error): void { + for (const listener of this.errorListeners) { + listener(doneInvoke(this.id, error)); + } + } + /* * Adds a listener that is notified whenever a state transition happens. The listener is called with * the next state and the event object that caused the state transition. @@ -343,7 +351,7 @@ export class Interpreter< nextListenerOrObserver?: | ((state: State) => void) | Observer>, - _?: (error: any) => void, // TODO: error listener + errorListener?: (error: any) => void, completeListener?: () => void ): Subscription { if (!nextListenerOrObserver) { @@ -352,6 +360,7 @@ export class Interpreter< let listener: (state: State) => void; let resolvedCompleteListener = completeListener; + let resolvedErrorListener = errorListener; if (typeof nextListenerOrObserver === 'function') { listener = nextListenerOrObserver; @@ -373,11 +382,17 @@ export class Interpreter< this.onDone(resolvedCompleteListener); } + if (resolvedErrorListener) { + this.onError(resolvedErrorListener); + } + return { unsubscribe: () => { listener && this.listeners.delete(listener); resolvedCompleteListener && this.doneListeners.delete(resolvedCompleteListener); + resolvedErrorListener && + this.errorListeners.delete(resolvedErrorListener); } }; } @@ -432,6 +447,16 @@ export class Interpreter< this.doneListeners.add(listener); return this; } + /** + * Adds a state listener that is notified when the statechart has reached an error. + * @param listener The state listener + */ + public onError( + listener: EventListener + ): Interpreter { + this.errorListeners.add(listener); + return this; + } /** * Removes a listener. * @param listener The listener to remove @@ -444,6 +469,7 @@ export class Interpreter< this.sendListeners.delete(listener); this.stopListeners.delete(listener); this.doneListeners.delete(listener); + this.errorListeners.delete(listener); this.contextListeners.delete(listener); return this; } @@ -510,6 +536,9 @@ export class Interpreter< for (const listener of this.doneListeners) { this.doneListeners.delete(listener); } + for (const listener of this.errorListeners) { + this.errorListeners.delete(listener); + } if (!this.initialized) { // Interpreter already stopped; do nothing @@ -1057,6 +1086,9 @@ export class Interpreter< if (this.devTools) { this.devTools.send(errorEvent, this.state); } + if (this.errorListeners.size) { + this.sendError(error); + } if (this.machine.strict) { // it would be better to always stop the state machine if unhandled // exception/promise rejection happens but because we don't want to diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index e8f3462f89..5bb95fc268 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1689,6 +1689,49 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" ); }); + it('should be subscribable to errorListener', (done) => { + const failureMachine = Machine( + { + id: 'interval', + context, + initial: 'active', + states: { + active: { + after: { + 10: { + target: 'failure' + } + } + }, + failure: { + invoke: { + src: 'failure' + } + } + } + }, + { + services: { + failure: async () => { + throw new Error('error'); + } + } + } + ); + + const intervalService = interpret(failureMachine).start(); + + expect(isObservable(intervalService)).toBeTruthy(); + + intervalService.subscribe( + () => {}, + (error) => { + expect(error.data).toBeInstanceOf(Error); + done(); + } + ); + }); + it('should be interoperable with RxJS, etc. via Symbol.observable', (done) => { let count = 0; const intervalService = interpret(intervalMachine).start(); From f5027b029b7f14a362fbdb512fe03ec38dd5f138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Sat, 27 Nov 2021 23:22:53 -0500 Subject: [PATCH 02/15] chore: add a changeset --- .changeset/famous-icons-flash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/famous-icons-flash.md diff --git a/.changeset/famous-icons-flash.md b/.changeset/famous-icons-flash.md new file mode 100644 index 0000000000..cf22980fbd --- /dev/null +++ b/.changeset/famous-icons-flash.md @@ -0,0 +1,5 @@ +--- +'xstate': major +--- + +allow to provide errorListeners into interpreter From c0a2a55a0ef3a95ab3ae122ac485873af232b553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Sun, 28 Nov 2021 15:49:40 -0500 Subject: [PATCH 03/15] refactor(interpreter): handle more error cases --- packages/core/src/interpreter.ts | 43 +++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index ad2c915f5c..dabc1e878e 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -315,9 +315,11 @@ export class Interpreter< } } - private sendError(error: Error): void { - for (const listener of this.errorListeners) { - listener(doneInvoke(this.id, error)); + private sendError(errorEvent: Event | SCXML.Event): void { + if (this.errorListeners.size) { + for (const listener of this.errorListeners) { + listener(errorEvent as EventObject); + } } } @@ -581,13 +583,18 @@ export class Interpreter< */ public send = ( event: SingleOrArray> | SCXML.Event, - payload?: EventData + payload?: EventData, + sendError = false ): State => { if (isArray(event)) { this.batch(event); return this.state; } + if (sendError) { + this.sendError(event as any); + } + const _event = toSCXMLEvent(toEventObject(event as Event, payload)); if (this.status === InterpreterStatus.Stopped) { @@ -817,10 +824,14 @@ export class Interpreter< }); } catch (err) { if (this.parent) { - this.parent.send({ - type: 'xstate.error', - data: err - } as EventObject); + this.parent.send( + { + type: 'xstate.error', + data: err + } as EventObject, + undefined, + true + ); } throw err; @@ -1084,11 +1095,11 @@ export class Interpreter< } catch (error) { reportUnhandledExceptionOnInvocation(errorData, error, id); if (this.devTools) { - this.devTools.send(errorEvent, this.state); - } - if (this.errorListeners.size) { - this.sendError(error); + this.devTools.send(errorEvent, this.state, true); } + + this.sendError(errorEvent); + if (this.machine.strict) { // it would be better to always stop the state machine if unhandled // exception/promise rejection happens but because we don't want to @@ -1166,7 +1177,7 @@ export class Interpreter< receivers.add(newListener); }); } catch (err) { - this.send(error(id, err) as any); + this.send(error(id, err) as any, undefined, true); } if (isPromiseLike(callbackStop)) { @@ -1216,7 +1227,11 @@ export class Interpreter< }, (err) => { this.removeChild(id); - this.send(toSCXMLEvent(error(id, err) as any, { origin: id })); + this.send( + toSCXMLEvent(error(id, err) as any, { origin: id }), + undefined, + true + ); }, () => { this.removeChild(id); From fbc8f50652c83e2fae3566012981a8e05db31120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Sun, 28 Nov 2021 17:08:34 -0500 Subject: [PATCH 04/15] chore(changeset): change major for minor --- .changeset/famous-icons-flash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/famous-icons-flash.md b/.changeset/famous-icons-flash.md index cf22980fbd..644463e5ce 100644 --- a/.changeset/famous-icons-flash.md +++ b/.changeset/famous-icons-flash.md @@ -1,5 +1,5 @@ --- -'xstate': major +'xstate': minor --- allow to provide errorListeners into interpreter From cf32294d8f3caa348e02ab96f678306025383022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Wed, 1 Dec 2021 23:59:20 -0500 Subject: [PATCH 05/15] refactor: handle case where there are child machines --- packages/core/src/interpreter.ts | 13 +++++- packages/core/test/interpreter.test.ts | 55 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index dabc1e878e..af9923161e 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -1093,7 +1093,18 @@ export class Interpreter< // Send "error.platform.id" to this (parent). this.send(toSCXMLEvent(errorEvent as any, { origin: id })); } catch (error) { - reportUnhandledExceptionOnInvocation(errorData, error, id); + if (this.parent) { + this.parent.send( + { + type: 'xstate.error', + data: error + } as EventObject, + undefined, + true + ); + } else { + reportUnhandledExceptionOnInvocation(errorData, error, id); + } if (this.devTools) { this.devTools.send(errorEvent, this.state, true); } diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 5bb95fc268..5f4d45fd20 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1732,6 +1732,61 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" ); }); + it('should be handle child errors with errorListener', (done) => { + const failureMachine = Machine( + { + id: 'failure', + context, + initial: 'active', + states: { + active: { + after: { + 100: { + target: 'failure' + } + } + }, + failure: { + invoke: { + src: 'failure' + } + } + } + }, + { + services: { + failure: async () => { + throw new Error('error'); + } + } + } + ); + + const parentMachine = Machine({ + initial: 'foo', + states: { + foo: { + invoke: { + id: 'child', + src: failureMachine + } + } + } + }); + + const intervalService = interpret(parentMachine).start(); + + expect(isObservable(intervalService)).toBeTruthy(); + + intervalService.subscribe( + () => {}, + (error) => { + expect(error.data).toBeInstanceOf(Error); + done(); + } + ); + }); + it('should be interoperable with RxJS, etc. via Symbol.observable', (done) => { let count = 0; const intervalService = interpret(intervalMachine).start(); From baa772fbf591d39b9d3401c25eefaa98d4adc65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Thu, 2 Dec 2021 00:30:54 -0500 Subject: [PATCH 06/15] refactor: handle case where there are grandchild machines --- packages/core/src/interpreter.ts | 8 ++- packages/core/test/interpreter.test.ts | 70 +++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index af9923161e..afcb8585c0 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -592,7 +592,11 @@ export class Interpreter< } if (sendError) { - this.sendError(event as any); + if (this.parent) { + this.parent.sendError(event as any); + } else { + this.sendError(event as any); + } } const _event = toSCXMLEvent(toEventObject(event as Event, payload)); @@ -1102,7 +1106,7 @@ export class Interpreter< undefined, true ); - } else { + } else if (!this.errorListeners.size) { reportUnhandledExceptionOnInvocation(errorData, error, id); } if (this.devTools) { diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 5f4d45fd20..451f711d5d 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1732,7 +1732,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" ); }); - it('should be handle child errors with errorListener', (done) => { + it('should handle child errors with errorListener', (done) => { const failureMachine = Machine( { id: 'failure', @@ -1787,6 +1787,74 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" ); }); + it('should handle grandchild errors with errorListener', (done) => { + const failureMachine = Machine( + { + id: 'failure', + context, + initial: 'active', + states: { + active: { + after: { + 100: { + target: 'failure' + } + } + }, + failure: { + invoke: { + src: 'failure' + } + } + } + }, + { + services: { + failure: async () => { + throw new Error('error'); + } + } + } + ); + + const parentMachine = Machine({ + initial: 'foo', + states: { + foo: { + invoke: { + id: 'child', + src: failureMachine + } + } + } + }); + + const grandparentMachine = Machine({ + id: 'grandparent', + initial: 'bar', + states: { + bar: { + invoke: { + id: 'parent', + src: parentMachine + } + } + } + }); + + const intervalService = interpret(grandparentMachine).start(); + + expect(isObservable(intervalService)).toBeTruthy(); + + intervalService.subscribe( + () => {}, + (error) => { + expect(error.data).toBeInstanceOf(Error); + done(); + } + ); + }); + it('should be interoperable with RxJS, etc. via Symbol.observable', (done) => { let count = 0; const intervalService = interpret(intervalMachine).start(); From 5b1372e1d6b99b4931d89a75cac09aec1a190a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Thu, 2 Dec 2021 22:20:53 -0500 Subject: [PATCH 07/15] refactor: just check the type error event --- packages/core/src/interpreter.ts | 37 +++++++++++--------------------- 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index afcb8585c0..0839a44515 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -583,15 +583,14 @@ export class Interpreter< */ public send = ( event: SingleOrArray> | SCXML.Event, - payload?: EventData, - sendError = false + payload?: EventData ): State => { if (isArray(event)) { this.batch(event); return this.state; } - if (sendError) { + if ((event as EventObject)?.type === 'xstate.error') { if (this.parent) { this.parent.sendError(event as any); } else { @@ -828,14 +827,10 @@ export class Interpreter< }); } catch (err) { if (this.parent) { - this.parent.send( - { - type: 'xstate.error', - data: err - } as EventObject, - undefined, - true - ); + this.parent.send({ + type: 'xstate.error', + data: err + } as EventObject); } throw err; @@ -1098,14 +1093,10 @@ export class Interpreter< this.send(toSCXMLEvent(errorEvent as any, { origin: id })); } catch (error) { if (this.parent) { - this.parent.send( - { - type: 'xstate.error', - data: error - } as EventObject, - undefined, - true - ); + this.parent.send({ + type: 'xstate.error', + data: error + } as EventObject); } else if (!this.errorListeners.size) { reportUnhandledExceptionOnInvocation(errorData, error, id); } @@ -1192,7 +1183,7 @@ export class Interpreter< receivers.add(newListener); }); } catch (err) { - this.send(error(id, err) as any, undefined, true); + this.send(error(id, err) as any); } if (isPromiseLike(callbackStop)) { @@ -1242,11 +1233,7 @@ export class Interpreter< }, (err) => { this.removeChild(id); - this.send( - toSCXMLEvent(error(id, err) as any, { origin: id }), - undefined, - true - ); + this.send(toSCXMLEvent(error(id, err) as any, { origin: id })); }, () => { this.removeChild(id); From a49c5865b08513bfa7bb478c71a43cedfa137302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Thu, 2 Dec 2021 22:25:55 -0500 Subject: [PATCH 08/15] chore: delete extra parameter in devTools send call --- packages/core/src/interpreter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index 0839a44515..728d3669e5 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -1101,7 +1101,7 @@ export class Interpreter< reportUnhandledExceptionOnInvocation(errorData, error, id); } if (this.devTools) { - this.devTools.send(errorEvent, this.state, true); + this.devTools.send(errorEvent, this.state); } this.sendError(errorEvent); From 77f034b407d316c3c7f7a0ba69af7b4749c2e1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Sat, 11 Dec 2021 16:02:54 -0500 Subject: [PATCH 09/15] refactor: consider send all error events to error listerner --- packages/core/src/interpreter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index 728d3669e5..4193072772 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -590,7 +590,7 @@ export class Interpreter< return this.state; } - if ((event as EventObject)?.type === 'xstate.error') { + if (((event as EventObject)?.type || '').includes('error')) { if (this.parent) { this.parent.sendError(event as any); } else { From b5b4dd01b0f63774969618159cd0749293906202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Sat, 11 Dec 2021 16:27:10 -0500 Subject: [PATCH 10/15] chore: use createMachine --- packages/core/test/interpreter.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 451f711d5d..f37f5fbc01 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1690,7 +1690,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" }); it('should be subscribable to errorListener', (done) => { - const failureMachine = Machine( + const failureMachine = createMachine( { id: 'interval', context, @@ -1726,6 +1726,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" intervalService.subscribe( () => {}, (error) => { + expect(error.type).toBe('xstate.error'); expect(error.data).toBeInstanceOf(Error); done(); } @@ -1733,7 +1734,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" }); it('should handle child errors with errorListener', (done) => { - const failureMachine = Machine( + const failureMachine = createMachine( { id: 'failure', context, @@ -1781,6 +1782,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" intervalService.subscribe( () => {}, (error) => { + expect(error.type).toBe('xstate.error'); expect(error.data).toBeInstanceOf(Error); done(); } @@ -1849,6 +1851,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" intervalService.subscribe( () => {}, (error) => { + expect(error.type).toBe('xstate.error'); expect(error.data).toBeInstanceOf(Error); done(); } From 42a307fa7a0cdda0008b93543b77e4212eeb13a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Sat, 11 Dec 2021 16:44:36 -0500 Subject: [PATCH 11/15] test: fix test case --- packages/core/test/interpreter.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index f37f5fbc01..6b251b7c93 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1726,7 +1726,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" intervalService.subscribe( () => {}, (error) => { - expect(error.type).toBe('xstate.error'); + expect(error.type).toBe('error.platform.failure'); expect(error.data).toBeInstanceOf(Error); done(); } @@ -1763,7 +1763,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" } ); - const parentMachine = Machine({ + const parentMachine = createMachine({ initial: 'foo', states: { foo: { @@ -1790,7 +1790,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" }); it('should handle grandchild errors with errorListener', (done) => { - const failureMachine = Machine( + const failureMachine = createMachine( { id: 'failure', context, @@ -1819,7 +1819,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" } ); - const parentMachine = Machine({ + const parentMachine = createMachine({ initial: 'foo', states: { foo: { @@ -1831,7 +1831,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" } }); - const grandparentMachine = Machine({ + const grandparentMachine = createMachine({ id: 'grandparent', initial: 'bar', states: { From f4d1d1bbd27f7c637f3d8e3948bd1be1e04abdbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Sat, 11 Dec 2021 21:23:16 -0500 Subject: [PATCH 12/15] refactor: send error as error.platform --- packages/core/src/interpreter.ts | 5 +- packages/core/test/interpreter.test.ts | 338 ++++++++++++------------- 2 files changed, 170 insertions(+), 173 deletions(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index 4193072772..f79bf67801 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -1093,10 +1093,7 @@ export class Interpreter< this.send(toSCXMLEvent(errorEvent as any, { origin: id })); } catch (error) { if (this.parent) { - this.parent.send({ - type: 'xstate.error', - data: error - } as EventObject); + this.parent.send(errorEvent); } else if (!this.errorListeners.size) { reportUnhandledExceptionOnInvocation(errorData, error, id); } diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 6b251b7c93..5232aa23e7 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1689,175 +1689,6 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" ); }); - it('should be subscribable to errorListener', (done) => { - const failureMachine = createMachine( - { - id: 'interval', - context, - initial: 'active', - states: { - active: { - after: { - 10: { - target: 'failure' - } - } - }, - failure: { - invoke: { - src: 'failure' - } - } - } - }, - { - services: { - failure: async () => { - throw new Error('error'); - } - } - } - ); - - const intervalService = interpret(failureMachine).start(); - - expect(isObservable(intervalService)).toBeTruthy(); - - intervalService.subscribe( - () => {}, - (error) => { - expect(error.type).toBe('error.platform.failure'); - expect(error.data).toBeInstanceOf(Error); - done(); - } - ); - }); - - it('should handle child errors with errorListener', (done) => { - const failureMachine = createMachine( - { - id: 'failure', - context, - initial: 'active', - states: { - active: { - after: { - 100: { - target: 'failure' - } - } - }, - failure: { - invoke: { - src: 'failure' - } - } - } - }, - { - services: { - failure: async () => { - throw new Error('error'); - } - } - } - ); - - const parentMachine = createMachine({ - initial: 'foo', - states: { - foo: { - invoke: { - id: 'child', - src: failureMachine - } - } - } - }); - - const intervalService = interpret(parentMachine).start(); - - expect(isObservable(intervalService)).toBeTruthy(); - - intervalService.subscribe( - () => {}, - (error) => { - expect(error.type).toBe('xstate.error'); - expect(error.data).toBeInstanceOf(Error); - done(); - } - ); - }); - - it('should handle grandchild errors with errorListener', (done) => { - const failureMachine = createMachine( - { - id: 'failure', - context, - initial: 'active', - states: { - active: { - after: { - 100: { - target: 'failure' - } - } - }, - failure: { - invoke: { - src: 'failure' - } - } - } - }, - { - services: { - failure: async () => { - throw new Error('error'); - } - } - } - ); - - const parentMachine = createMachine({ - initial: 'foo', - states: { - foo: { - invoke: { - id: 'child', - src: failureMachine - } - } - } - }); - - const grandparentMachine = createMachine({ - id: 'grandparent', - initial: 'bar', - states: { - bar: { - invoke: { - id: 'parent', - src: parentMachine - } - } - } - }); - - const intervalService = interpret(grandparentMachine).start(); - - expect(isObservable(intervalService)).toBeTruthy(); - - intervalService.subscribe( - () => {}, - (error) => { - expect(error.type).toBe('xstate.error'); - expect(error.data).toBeInstanceOf(Error); - done(); - } - ); - }); - it('should be interoperable with RxJS, etc. via Symbol.observable', (done) => { let count = 0; const intervalService = interpret(intervalMachine).start(); @@ -1920,6 +1751,175 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" service.send('INC'); service.send('INC'); }); + + describe('when errorListener is provided', () => { + it('should handle errors', (done) => { + const failureMachine = createMachine( + { + id: 'interval', + context, + initial: 'active', + states: { + active: { + after: { + 10: { + target: 'failure' + } + } + }, + failure: { + invoke: { + src: 'failure' + } + } + } + }, + { + services: { + failure: async () => { + throw new Error('error'); + } + } + } + ); + + const intervalService = interpret(failureMachine).start(); + + intervalService.subscribe( + () => {}, + (error) => { + expect(error.type).toBe('error.platform.failure'); + expect(error.data).toBeInstanceOf(Error); + intervalService.stop(); + done(); + } + ); + }); + + it('should handle child errors', (done) => { + const childMachine = createMachine( + { + id: 'child', + context, + initial: 'active', + states: { + active: { + after: { + 100: { + target: 'failure' + } + } + }, + failure: { + invoke: { + src: 'failure' + } + } + } + }, + { + services: { + failure: async () => { + throw new Error('error'); + } + } + } + ); + + const parentMachine = createMachine({ + initial: 'foo', + states: { + foo: { + invoke: { + id: 'child', + src: childMachine + } + } + } + }); + + const intervalService = interpret(parentMachine).start(); + + intervalService.subscribe( + () => {}, + (error) => { + expect(error.type).toBe('error.platform.failure'); + expect(error.data).toBeInstanceOf(Error); + intervalService.stop(); + done(); + } + ); + }); + + it('should handle grandchild errors', (done) => { + const childMachine = createMachine( + { + id: 'child', + context, + initial: 'active', + states: { + active: { + after: { + 100: { + target: 'failure' + } + } + }, + failure: { + invoke: { + src: 'failure' + } + } + } + }, + { + services: { + failure: async () => { + throw new Error('error'); + } + } + } + ); + + const parentMachine = createMachine({ + id: 'parent', + initial: 'foo', + states: { + foo: { + invoke: { + id: 'child', + src: childMachine + } + } + } + }); + + const grandparentMachine = createMachine({ + id: 'grandparent', + initial: 'bar', + states: { + bar: { + invoke: { + id: 'parent', + src: parentMachine + } + } + } + }); + + const intervalService = interpret(grandparentMachine).start(); + + intervalService.subscribe( + () => {}, + (error) => { + expect(error.type).toBe('error.platform.failure'); + expect(error.data).toBeInstanceOf(Error); + intervalService.stop(); + done(); + } + ); + }); + }); }); describe('services', () => { From 52037b0d71c3d4606f8ae4c1ae0d1469a8950896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Mon, 10 Jan 2022 20:25:56 -0500 Subject: [PATCH 13/15] refactor: handle error event --- packages/core/src/interpreter.ts | 76 ++++++++++++++++++-------------- packages/core/src/types.ts | 13 +++++- packages/core/src/utils.ts | 10 ++++- 3 files changed, 64 insertions(+), 35 deletions(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index 045abba2d6..83ac04d81c 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -30,6 +30,7 @@ import { AnyEventObject, AnyInterpreter, ActorRef, + SCXMLErrorEvent, ActorRefFrom, Behavior, StopActionObject, @@ -57,7 +58,8 @@ import { toInvokeSource, toObserver, isActor, - isBehavior + isBehavior, + isSCXMLErrorEvent } from './utils'; import { Scheduler } from './scheduler'; import { Actor, isSpawnedActor, createDeferredActor } from './Actor'; @@ -87,6 +89,7 @@ export type EventListener = ( ) => void; export type Listener = () => void; +export type ErrorListener = (error: any) => void; export interface Clock { setTimeout(fn: (...args: any[]) => void, timeout: number): any; @@ -315,14 +318,6 @@ export class Interpreter< } } - private sendError(errorEvent: Event | SCXML.Event): void { - if (this.errorListeners.size) { - for (const listener of this.errorListeners) { - listener(errorEvent as EventObject); - } - } - } - /* * Adds a listener that is notified whenever a state transition happens. The listener is called with * the next state and the event object that caused the state transition. @@ -362,7 +357,6 @@ export class Interpreter< let listener: (state: State) => void; let resolvedCompleteListener = completeListener; - let resolvedErrorListener = errorListener; if (typeof nextListenerOrObserver === 'function') { listener = nextListenerOrObserver; @@ -384,8 +378,8 @@ export class Interpreter< this.onDone(resolvedCompleteListener); } - if (resolvedErrorListener) { - this.onError(resolvedErrorListener); + if (errorListener) { + this.onError(errorListener); } return { @@ -393,8 +387,7 @@ export class Interpreter< listener && this.listeners.delete(listener); resolvedCompleteListener && this.doneListeners.delete(resolvedCompleteListener); - resolvedErrorListener && - this.errorListeners.delete(resolvedErrorListener); + errorListener && this.errorListeners.delete(errorListener); } }; } @@ -439,24 +432,37 @@ export class Interpreter< this.stopListeners.add(listener); return this; } + /** - * Adds a state listener that is notified when the statechart has reached its final state. - * @param listener The state listener + * Adds an error listener that is notified with an `Error` whenever an + * error occurs during execution. + * + * @param listener The error listener */ - public onDone( - listener: EventListener - ): Interpreter { - this.doneListeners.add(listener); + public onError(listener: ErrorListener): this { + this.errorListeners.add(listener); return this; } + + private handleErrorEvent(errorEvent: SCXMLErrorEvent): void { + if (this.errorListeners.size > 0) { + this.errorListeners.forEach((listener) => { + listener(errorEvent.data); + }); + } else { + this.stop(); + throw errorEvent.data; + } + } + /** - * Adds a state listener that is notified when the statechart has reached an error. + * Adds a state listener that is notified when the statechart has reached its final state. * @param listener The state listener */ - public onError( + public onDone( listener: EventListener ): Interpreter { - this.errorListeners.add(listener); + this.doneListeners.add(listener); return this; } /** @@ -590,16 +596,15 @@ export class Interpreter< return this.state; } - if (((event as EventObject)?.type || '').includes('error')) { + const _event = toSCXMLEvent(toEventObject(event as Event, payload)); + + if (_event.name.startsWith('error')) { if (this.parent) { - this.parent.sendError(event as any); - } else { - this.sendError(event as any); + this.parent.send(_event); + return this._state!; } } - const _event = toSCXMLEvent(toEventObject(event as Event, payload)); - if (this.status === InterpreterStatus.Stopped) { // do nothing if (!IS_PRODUCTION) { @@ -760,6 +765,13 @@ export class Interpreter< ): State { const _event = toSCXMLEvent(event); + if ( + isSCXMLErrorEvent(_event) && + !this.state.nextEvents.some((nextEvent) => nextEvent === _event.name) + ) { + this.handleErrorEvent(_event); + } + if ( _event.name.indexOf(actionTypes.errorPlatform) === 0 && !this.state.nextEvents.some( @@ -1095,16 +1107,14 @@ export class Interpreter< // Send "error.platform.id" to this (parent). this.send(toSCXMLEvent(errorEvent as any, { origin: id })); } catch (error) { - if (this.parent) { - this.parent.send(errorEvent); - } else if (!this.errorListeners.size) { + if (!this.errorListeners.size && !this.parent) { reportUnhandledExceptionOnInvocation(errorData, error, id); } if (this.devTools) { this.devTools.send(errorEvent, this.state); } - this.sendError(errorEvent); + this.send(errorEvent, { origin: id }); if (this.machine.strict) { // it would be better to always stop the state machine if unhandled diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ef70123877..7e8e224484 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -576,7 +576,10 @@ export interface StateNodeConfig< * * This is equivalent to defining a `[done(id)]` transition on this state node's `on` property. */ - onDone?: string | SingleOrArray> | undefined; + onDone?: + | string + | SingleOrArray> + | undefined; /** * The mapping (or array) of delays (in milliseconds) to their potential transition(s). * The delayed transitions are taken after the specified delay in an interpreter. @@ -929,6 +932,14 @@ export interface ErrorPlatformEvent extends EventObject { data: any; } +export interface SCXMLErrorEvent extends SCXML.Event { + name: + | ActionTypes.ErrorExecution + | ActionTypes.ErrorPlatform + | ActionTypes.ErrorCommunication; + data: any; +} + export interface DoneEventObject extends EventObject { data?: any; toString(): string; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 661a77c4ec..13faa0acce 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -24,7 +24,8 @@ import { GuardMeta, InvokeSourceDefinition, Observer, - Behavior + Behavior, + SCXMLErrorEvent } from './types'; import { STATE_DELIMITER, @@ -35,6 +36,7 @@ import { IS_PRODUCTION } from './environment'; import { StateNode } from './StateNode'; import { State } from './State'; import { Actor } from './Actor'; +import { errorExecution, errorPlatform } from './actionTypes'; export function keys(value: T): Array { return Object.keys(value) as Array; @@ -566,6 +568,12 @@ export function toEventObject( return event; } +export function isSCXMLErrorEvent( + event: SCXML.Event +): event is SCXMLErrorEvent { + return event.name === errorExecution || event.name.startsWith(errorPlatform); +} + export function toSCXMLEvent( event: Event | SCXML.Event, scxmlEvent?: Partial> From 243eebe18c446abdd7ce4f7dafe8f91d1891effe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Mon, 10 Jan 2022 21:11:04 -0500 Subject: [PATCH 14/15] refactor: respect strict mode --- packages/core/src/interpreter.ts | 10 ++++++---- packages/core/test/interpreter.test.ts | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index e1cb68ad8c..0387818f5e 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -445,8 +445,10 @@ export class Interpreter< listener(errorEvent.data); }); } else { - this.stop(); - throw errorEvent.data; + if (this.machine.strict) { + this.stop(); + throw errorEvent.data; + } } } @@ -1109,9 +1111,9 @@ export class Interpreter< this.devTools.send(errorEvent, this.state); } - this.send(errorEvent, { origin: id }); - if (this.machine.strict) { + this.send(errorEvent, { origin: id }); + // it would be better to always stop the state machine if unhandled // exception/promise rejection happens but because we don't want to // break existing code so enforce it on strict mode only especially so diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 5232aa23e7..355dc25275 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1759,6 +1759,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" id: 'interval', context, initial: 'active', + strict: true, states: { active: { after: { @@ -1788,7 +1789,9 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" intervalService.subscribe( () => {}, (error) => { - expect(error.type).toBe('error.platform.failure'); + expect(error.type).toBe( + 'error.platform.interval.failure:invocation[0]' + ); expect(error.data).toBeInstanceOf(Error); intervalService.stop(); done(); @@ -1802,6 +1805,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" id: 'child', context, initial: 'active', + strict: true, states: { active: { after: { @@ -1843,7 +1847,9 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" intervalService.subscribe( () => {}, (error) => { - expect(error.type).toBe('error.platform.failure'); + expect(error.type).toBe( + 'error.platform.child.failure:invocation[0]' + ); expect(error.data).toBeInstanceOf(Error); intervalService.stop(); done(); @@ -1857,6 +1863,7 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" id: 'child', context, initial: 'active', + strict: true, states: { active: { after: { @@ -1912,7 +1919,9 @@ Event: {\\"type\\":\\"SOME_EVENT\\"}" intervalService.subscribe( () => {}, (error) => { - expect(error.type).toBe('error.platform.failure'); + expect(error.type).toBe( + 'error.platform.child.failure:invocation[0]' + ); expect(error.data).toBeInstanceOf(Error); intervalService.stop(); done(); From 865f2cebaa623feb9054d72004d7c4fbd41d649e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?rogger=20andr=C3=A9=20valverde=20flores?= Date: Mon, 10 Jan 2022 21:25:31 -0500 Subject: [PATCH 15/15] chore: send state as payload in errorEvent --- packages/core/src/interpreter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index 0387818f5e..0df7bcfe01 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -1112,7 +1112,7 @@ export class Interpreter< } if (this.machine.strict) { - this.send(errorEvent, { origin: id }); + this.send(errorEvent, this.state); // it would be better to always stop the state machine if unhandled // exception/promise rejection happens but because we don't want to