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

feat(store): implement NGXS unhandled error handler #2137

Merged
merged 20 commits into from
Mar 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion docs/concepts/actions/monitoring-unhandled-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const appConfig: ApplicationConfig = {
Ignored actions can be also expanded in lazy modules. The `@ngxs/store` exposes the `NgxsUnhandledActionsLogger` for these purposes:

```ts
import { inject } from '@angular/core';
import { inject, ENVIRONMENT_INITIALIZER } from '@angular/core';
import { NgxsUnhandledActionsLogger } from '@ngxs/store';

declare const ngDevMode: boolean;
Expand Down
20 changes: 0 additions & 20 deletions docs/concepts/state/error-handling.md

This file was deleted.

67 changes: 56 additions & 11 deletions docs/concepts/store/error-handling.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
# Error Handling

## Handling errors after dispatching an action
## How NGXS handles errors

If an unhandled exception is thrown inside an action, the error will be propagated to the `ErrorHandler` and you can also catch it by subscribing to the `dispatch` Observable. If you subscribe to the `dispatch` Observable the error will be caught twice, once in the ErrorHandler and on your `dispatch` handle.
If an unhandled exception is thrown within an action, the error will be passed to the `ErrorHandler`. To manually catch the error, you just need to subscribe to the `dispatch` observable and include an `error` callback. By subscribing and providing an `error` callback, NGXS won't pass the error to its unhandled error handler.

NGXS configures the RxJS [`onUnhandledError`](https://rxjs.dev/api/index/interface/GlobalConfig#onUnhandledError) callback. This property is accessible in RxJS versions 7 and above, which is why NGXS mandates a minimum RxJS version of 7.

The RxJS `onUnhandledError` callback triggers whenever an unhandled error occurs within an observable and no `error` callback has been supplied.

:warning: If you configure `onUnhandledError` after NGXS has loaded, ensure to store the existing implementation in a local variable. You'll need to invoke it when the error shouldn't be handled by your customized error strategy:

```ts
import { config } from 'rxjs';

const existingHandler = config.onUnhandledError;
config.onUnhandledError = function (error: any) {
if (shouldWeHandleThis(error)) {
// Do something with this error
} else {
existingHandler.call(this, error);
}
};
```

### Handling errors after dispatching an action

Given the following code:

```ts
class AppState {
Expand All @@ -16,19 +39,41 @@ class AppState {
```ts
class AppComponent {
unhandled() {
this.store
.dispatch(new UnhandledError())
.pipe(
catchError(err => {
console.log('unhandled error on dispatch subscription');
return of('');
})
)
.subscribe();
this.store.dispatch(new UnhandledError()).subscribe({
error: error => {
console.log('unhandled error on dispatch subscription: ', error);
}
});
}
}
```

It is recommended to handle errors within `@Action` and update the state to reflect the error, which you can later select to display where required.

You can play around with error handling in the following [stackblitz](https://stackblitz.com/edit/ngxs-error-handling)

## Custom unhandled error handler

NGXS provides the `NgxsUnhandledErrorHandler` class, which you can override with your custom implementation to manage unhandled errors according to your requirements:

```ts
import { NgxsUnhandledErrorHandler, NgxsUnhandledErrorContext } from '@ngxs/store';

@Injectable()
export class MyCustomNgxsUnhandledErrorHandler {
handleError(error: any, unhandledErrorContext: NgxsUnhandledErrorContext): void {
// Do something with these parameters
}
}

export const appConfig: ApplicationConfig = {
providers: [
{
provide: NgxsUnhandledErrorHandler,
useClass: MyCustomNgxsUnhandledErrorHandler
}
]
};
```

Note that the second parameter, `NgxsUnhandledErrorContext`, contains an object with an `action` property. This property holds the action that triggered the error while being processed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@
"prettier": "3.2.5",
"rollup": "^4",
"rollup-plugin-dts": "^6.1.0",
"rxjs": "6.6.7",
"rxjs": "^7.4.0",
"serve": "^14.2.0",
"start-server-and-test": "^1.11.0",
"ts-jest": "29.1.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"sideEffects": false,
"peerDependencies": {
"@angular/core": ">=12.0.0 <18.0.0",
"rxjs": ">=6.5.5"
"rxjs": ">=7.0.0"
},
"schematics": "./schematics/collection.json"
}
16 changes: 9 additions & 7 deletions packages/store/src/internal/dispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { Injectable } from '@angular/core';
import { Injectable, NgZone } from '@angular/core';
import { EMPTY, forkJoin, Observable, of, Subject, throwError } from 'rxjs';
import { exhaustMap, filter, map, shareReplay, take } from 'rxjs/operators';

import { getActionTypeFromInstance } from '@ngxs/store/plugins';
import { ɵPlainObject, ɵStateStream } from '@ngxs/store/internals';

import { compose } from '../utils/compose';
import { InternalErrorReporter, ngxsErrorHandler } from './error-handler';
import { ActionContext, ActionStatus, InternalActions } from '../actions-stream';
import { PluginManager } from '../plugin-manager';
import { InternalNgxsExecutionStrategy } from '../execution/internal-ngxs-execution-strategy';
import { leaveNgxs } from '../operators/leave-ngxs';
import { fallbackSubscriber } from './fallback-subscriber';

/**
* Internal Action result stream that is emitted when an action is completed.
Expand All @@ -23,12 +24,12 @@ export class InternalDispatchedActionResults extends Subject<ActionContext> {}
@Injectable({ providedIn: 'root' })
export class InternalDispatcher {
constructor(
private _ngZone: NgZone,
private _actions: InternalActions,
private _actionResults: InternalDispatchedActionResults,
private _pluginManager: PluginManager,
private _stateStream: ɵStateStream,
private _ngxsExecutionStrategy: InternalNgxsExecutionStrategy,
private _internalErrorReporter: InternalErrorReporter
private _ngxsExecutionStrategy: InternalNgxsExecutionStrategy
) {}

/**
Expand All @@ -40,7 +41,8 @@ export class InternalDispatcher {
);

return result.pipe(
ngxsErrorHandler(this._internalErrorReporter, this._ngxsExecutionStrategy)
fallbackSubscriber(this._ngZone),
leaveNgxs(this._ngxsExecutionStrategy)
);
}

Expand All @@ -63,7 +65,7 @@ export class InternalDispatcher {
const error = new Error(
`This action doesn't have a type property: ${action.constructor.name}`
);
return throwError(error);
return throwError(() => error);
}
}

Expand Down Expand Up @@ -106,7 +108,7 @@ export class InternalDispatcher {
// state, as its result is utilized by plugins.
return of(this._stateStream.getValue());
case ActionStatus.Errored:
return throwError(ctx.error);
return throwError(() => ctx.error);
default:
return EMPTY;
}
Expand Down
69 changes: 0 additions & 69 deletions packages/store/src/internal/error-handler.ts

This file was deleted.

33 changes: 33 additions & 0 deletions packages/store/src/internal/fallback-subscriber.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NgZone } from '@angular/core';
import { Observable, Subscription } from 'rxjs';

import { executeUnhandledCallback } from './unhandled-rxjs-error-callback';

export function fallbackSubscriber<T>(ngZone: NgZone) {
return (source: Observable<T>) => {
let subscription: Subscription | null = source.subscribe({
error: error => {
ngZone.runOutsideAngular(() => {
// This is necessary to schedule a microtask to ensure that synchronous
// errors are not reported before the real subscriber arrives. If an error
// is thrown synchronously in any action, it will be reported to the error
// handler regardless. Since RxJS reports unhandled errors asynchronously,
// implementing a microtask ensures that we are also safe in this scenario.
queueMicrotask(() => {
if (subscription) {
executeUnhandledCallback(error);
}
});
});
}
});

return new Observable<T>(subscriber => {
// Now that there is a real subscriber, we can unsubscribe our pro-active subscription
subscription?.unsubscribe();
subscription = null;

return source.subscribe(subscriber);
});
};
}
22 changes: 2 additions & 20 deletions packages/store/src/internal/lifecycle-state-manager.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
import { Injectable, OnDestroy } from '@angular/core';
import { ɵNgxsAppBootstrappedState } from '@ngxs/store/internals';
import { getValue, InitState, UpdateState } from '@ngxs/store/plugins';
import { EMPTY, ReplaySubject } from 'rxjs';
import {
catchError,
filter,
mergeMap,
pairwise,
startWith,
takeUntil,
tap
} from 'rxjs/operators';
import { ReplaySubject } from 'rxjs';
import { filter, mergeMap, pairwise, startWith, takeUntil, tap } from 'rxjs/operators';

import { Store } from '../store';
import { InternalErrorReporter } from './error-handler';
import { StateContextFactory } from './state-context-factory';
import { InternalStateOperations } from './state-operations';
import { MappedStore, StatesAndDefaults } from './internals';
Expand All @@ -30,7 +21,6 @@ export class LifecycleStateManager implements OnDestroy {

constructor(
private _store: Store,
private _internalErrorReporter: InternalErrorReporter,
private _internalStateOperations: InternalStateOperations,
private _stateContextFactory: StateContextFactory,
private _appBootstrappedState: ɵNgxsAppBootstrappedState
Expand Down Expand Up @@ -68,14 +58,6 @@ export class LifecycleStateManager implements OnDestroy {
tap(() => this._invokeInitOnStates(results!.states)),
mergeMap(() => this._appBootstrappedState),
filter(appBootstrapped => !!appBootstrapped),
catchError(error => {
// The `SafeSubscriber` (which is used by most RxJS operators) re-throws
// errors asynchronously (`setTimeout(() => { throw error })`). This might
// break existing user's code or unit tests. We catch the error manually to
// be backward compatible with the old behavior.
this._internalErrorReporter.reportErrorSafely(error);
return EMPTY;
}),
takeUntil(this._destroy$)
)
.subscribe(() => this._invokeBootstrapOnStates(results!.states));
Expand Down
23 changes: 18 additions & 5 deletions packages/store/src/internal/state-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import { StateContextFactory } from '../internal/state-context-factory';
import { ensureStateNameIsUnique, ensureStatesAreDecorated } from '../utils/store-validators';
import { ensureStateClassIsInjectable } from '../ivy/ivy-enabled-in-dev-mode';
import { NgxsUnhandledActionsLogger } from '../dev-features/ngxs-unhandled-actions-logger';
import { NgxsUnhandledErrorHandler } from '../ngxs-unhandled-error-handler';
import { assignUnhandledCallback } from './unhandled-rxjs-error-callback';

const NG_DEV_MODE = typeof ngDevMode !== 'undefined' && ngDevMode;

Expand Down Expand Up @@ -95,6 +97,8 @@ export class StateFactory implements OnDestroy {

private _propGetter = inject(ɵPROP_GETTER);

private _ngxsUnhandledErrorHandler: NgxsUnhandledErrorHandler = null!;

constructor(
private _injector: Injector,
private _config: NgxsConfig,
Expand Down Expand Up @@ -253,13 +257,22 @@ export class StateFactory implements OnDestroy {
filter((ctx: ActionContext) => ctx.status === ActionStatus.Dispatched),
mergeMap(ctx => {
dispatched$.next(ctx);
const action = ctx.action;
const action: any = ctx.action;
return this.invokeActions(dispatched$, action!).pipe(
map(() => <ActionContext>{ action, status: ActionStatus.Successful }),
defaultIfEmpty(<ActionContext>{ action, status: ActionStatus.Canceled }),
catchError(error =>
of(<ActionContext>{ action, status: ActionStatus.Errored, error })
)
catchError(error => {
const ngxsUnhandledErrorHandler = (this._ngxsUnhandledErrorHandler ||=
this._injector.get(NgxsUnhandledErrorHandler));
const handleableError = assignUnhandledCallback(error, () =>
ngxsUnhandledErrorHandler.handleError(error, { action })
);
return of(<ActionContext>{
action,
status: ActionStatus.Errored,
error: handleableError
});
})
);
})
)
Expand Down Expand Up @@ -310,7 +323,7 @@ export class StateFactory implements OnDestroy {
if (ɵisPromise(value)) {
return from(value);
}
if (isObservable<any>(value)) {
if (isObservable(value)) {
return value;
}
return of(value);
Expand Down
Loading
Loading