diff --git a/.changeset/famous-icons-flash.md b/.changeset/famous-icons-flash.md new file mode 100644 index 0000000000..644463e5ce --- /dev/null +++ b/.changeset/famous-icons-flash.md @@ -0,0 +1,5 @@ +--- +'xstate': minor +--- + +allow to provide errorListeners into interpreter diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index f69b9aac65..0df7bcfe01 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, @@ -58,6 +59,7 @@ import { toObserver, isActor, isBehavior, + isSCXMLErrorEvent, interopSymbols } from './utils'; import { Scheduler } from './scheduler'; @@ -88,6 +90,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; @@ -155,6 +158,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; @@ -308,6 +312,7 @@ export class Interpreter< this.stop(); } } + /* * 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. @@ -338,7 +343,7 @@ export class Interpreter< nextListenerOrObserver?: | ((state: State) => void) | Observer>, - _?: (error: any) => void, // TODO: error listener + errorListener?: (error: any) => void, completeListener?: () => void ): Subscription { if (!nextListenerOrObserver) { @@ -368,11 +373,16 @@ export class Interpreter< this.onDone(resolvedCompleteListener); } + if (errorListener) { + this.onError(errorListener); + } + return { unsubscribe: () => { listener && this.listeners.delete(listener); resolvedCompleteListener && this.doneListeners.delete(resolvedCompleteListener); + errorListener && this.errorListeners.delete(errorListener); } }; } @@ -417,6 +427,31 @@ export class Interpreter< this.stopListeners.add(listener); return this; } + + /** + * Adds an error listener that is notified with an `Error` whenever an + * error occurs during execution. + * + * @param listener The error 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 { + if (this.machine.strict) { + this.stop(); + throw errorEvent.data; + } + } + } + /** * Adds a state listener that is notified when the statechart has reached its final state. * @param listener The state listener @@ -439,6 +474,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; } @@ -505,6 +541,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 @@ -556,6 +595,13 @@ export class Interpreter< const _event = toSCXMLEvent(toEventObject(event as Event, payload)); + if (_event.name.startsWith('error')) { + if (this.parent) { + this.parent.send(_event); + return this._state!; + } + } + if (this.status === InterpreterStatus.Stopped) { // do nothing if (!IS_PRODUCTION) { @@ -716,6 +762,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( @@ -1051,11 +1104,16 @@ 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.errorListeners.size && !this.parent) { + reportUnhandledExceptionOnInvocation(errorData, error, id); + } if (this.devTools) { this.devTools.send(errorEvent, this.state); } + if (this.machine.strict) { + 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 // break existing code so enforce it on strict mode only especially so diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f623c8b9f5..3e8bd8eaa2 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -932,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 6c6de868e4..0744fe0f69 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; @@ -576,6 +578,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> diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index e8f3462f89..355dc25275 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -1751,6 +1751,184 @@ 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', + strict: true, + 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.interval.failure:invocation[0]' + ); + expect(error.data).toBeInstanceOf(Error); + intervalService.stop(); + done(); + } + ); + }); + + it('should handle child errors', (done) => { + const childMachine = createMachine( + { + id: 'child', + context, + initial: 'active', + strict: true, + 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.child.failure:invocation[0]' + ); + expect(error.data).toBeInstanceOf(Error); + intervalService.stop(); + done(); + } + ); + }); + + it('should handle grandchild errors', (done) => { + const childMachine = createMachine( + { + id: 'child', + context, + initial: 'active', + strict: true, + 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.child.failure:invocation[0]' + ); + expect(error.data).toBeInstanceOf(Error); + intervalService.stop(); + done(); + } + ); + }); + }); }); describe('services', () => {