Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[core] feat(interpreter): allow to provide errorListeners #2841

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/famous-icons-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'xstate': minor
---

allow to provide errorListeners into interpreter
50 changes: 48 additions & 2 deletions packages/core/src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export class Interpreter<
private contextListeners: Set<ContextListener<TContext>> = new Set();
private stopListeners: Set<Listener> = new Set();
private doneListeners: Set<EventListener> = new Set();
private errorListeners: Set<EventListener> = new Set();
private eventListeners: Set<EventListener> = new Set();
private sendListeners: Set<EventListener> = new Set();
private logger: (...args: any[]) => void;
Expand Down Expand Up @@ -313,6 +314,15 @@ export class Interpreter<
this.stop();
}
}

private sendError(errorEvent: Event<TEvent> | SCXML.Event<TEvent>): 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.
Expand Down Expand Up @@ -343,7 +353,7 @@ export class Interpreter<
nextListenerOrObserver?:
| ((state: State<TContext, TEvent, any, TTypestate>) => void)
| Observer<State<TContext, TEvent, any, TTypestate>>,
_?: (error: any) => void, // TODO: error listener
errorListener?: (error: any) => void,
completeListener?: () => void
): Subscription {
if (!nextListenerOrObserver) {
Expand All @@ -352,6 +362,7 @@ export class Interpreter<

let listener: (state: State<TContext, TEvent, any, TTypestate>) => void;
let resolvedCompleteListener = completeListener;
let resolvedErrorListener = errorListener;

if (typeof nextListenerOrObserver === 'function') {
listener = nextListenerOrObserver;
Expand All @@ -373,11 +384,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);
}
};
}
Expand Down Expand Up @@ -432,6 +449,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<DoneEvent>
): Interpreter<TContext, TStateSchema, TEvent, TTypestate> {
this.errorListeners.add(listener);
return this;
}
/**
* Removes a listener.
* @param listener The listener to remove
Expand All @@ -444,6 +471,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;
}
Expand Down Expand Up @@ -510,6 +538,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
Expand Down Expand Up @@ -559,6 +590,14 @@ export class Interpreter<
return this.state;
}

if (((event as EventObject)?.type || '').includes('error')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anything - this should be checked on the _event (and on the appropriate field). It might be "too soon" to check this as the event might not be an EventObject, it actually might be already a SCXML.Event.

This should also check if the type/name of the event starts with error. and not if the type contains an error substring.

also, a nit: optional chaining here and defaulting this to an empty string seems unnecessary:

  • the event parameter is required
  • all events should have valid types/names

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you, I updated it

if (this.parent) {
this.parent.sendError(event as any);
} else {
this.sendError(event as any);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one sounds like it's going to send this event somewhere (like some kind of an actor or something), but it's more like notifying current subscribers.

I also do not (overall) liking how this might make more errors to be swallowed. In general, the error story in XState is currently under-specified so it's somewhat hard to answer "how this should behave now?". I would love to hear out your thoughts about this RFC: statelyai/rfcs#4 . Going through that could also show you my perspective better.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty nice, could you please guide me on the proper changes when you get some chance?

}
}

const _event = toSCXMLEvent(toEventObject(event as Event<TEvent>, payload));

if (this.status === InterpreterStatus.Stopped) {
Expand Down Expand Up @@ -1056,10 +1095,17 @@ 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(errorEvent);
} else if (!this.errorListeners.size) {
reportUnhandledExceptionOnInvocation(errorData, error, id);
}
if (this.devTools) {
this.devTools.send(errorEvent, this.state);
}

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
Expand Down
169 changes: 169 additions & 0 deletions packages/core/test/interpreter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1751,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<typeof context>(
{
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<typeof context>(
{
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<typeof context>(
{
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', () => {
Expand Down