Skip to content

Commit

Permalink
feat(store): warn on unhandled actions (#1870)
Browse files Browse the repository at this point in the history
Co-authored-by: Mark Whitfeld <[email protected]>
  • Loading branch information
arturovt and markwhitfeld authored Dec 9, 2022
1 parent 7c829a7 commit 1bdb8c0
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Feature: Expose `ActionContext` and `ActionStatus` [#1766](https://github.com/ngxs/store/pull/1766)
- Feature: `ofAction*` methods should have strong types [#1808](https://github.com/ngxs/store/pull/1808)
- Feature: Improve contextual type inference for state operators [#1806](https://github.com/ngxs/store/pull/1806) [#1947](https://github.com/ngxs/store/pull/1947)
- Feature: Enable warning on unhandled actions [#1870](https://github.com/ngxs/store/pull/1870)
- Feature: Router Plugin - Provide more actions and navigation timing option [#1932](https://github.com/ngxs/store/pull/1932)
- Feature: Storage Plugin - Allow providing namespace for keys [#1841](https://github.com/ngxs/store/pull/1841)
- Feature: Storage Plugin - Enable providing storage engine individually [#1935](https://github.com/ngxs/store/pull/1935)
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [Life-cycle](advanced/life-cycle.md)
- [Mapped Sub States](advanced/mapped-sub-states.md)
- [Meta Reducers](advanced/meta-reducer.md)
- [Monitoring Unhandled Actions](advanced/monitoring-unhandled-actions.md)
- [Optimizing Selectors](advanced/optimizing-selectors.md)
- [Options](advanced/options.md)
- [Shared State](advanced/shared-state.md)
Expand Down
50 changes: 50 additions & 0 deletions docs/advanced/monitoring-unhandled-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Monitoring Unhandled Actions

We can know if we have dispatched some actions which haven't been handled by any of the NGXS states. This is useful to monitor if we dispatch actions at the right time. For instance, dispatched actions might be coming from the WebSocket, but the action handler is located within the feature state that has not been registered yet. This will let us know that we should either register the state earlier or do anything else from the code perspective because actions are not being handled.

This may be enabled by importing the `NgxsDevelopmentModule`.

## Ignoring Certain Actions

We can ignore specific actions that should not be logged if they have never been handled. E.g., if we're using the `@ngxs/router-plugin` and don't care about router actions like `RouterNavigation`, then we may add it to the `ignore` array:

```ts
import { NgxsDevelopmentModule } from '@ngxs/store';
import { RouterNavigation, RouterCancel } from '@ngxs/router-plugin';

@NgModule({
imports: [
NgxsDevelopmentModule.forRoot({
warnOnUnhandledActions: {
ignore: [RouterNavigation, RouterCancel]
}
})
]
})
export class AppModule {}
```

It's best to import this module only in development mode. This may be achieved using environment imports. See [dynamic plugins](../recipes/dynamic-plugins.md).

Ignored actions can be also expanded in lazy modules. The `@ngxs/store` exposes the `NgxsUnhandledActionsLogger` for these purposes:

```ts
import { Injector } from '@angular/core';
import { NgxsUnhandledActionsLogger } from '@ngxs/store';

declare const ngDevMode: boolean;

@NgModule({
imports: [NgxsModule.forFeature([LazyState])]
})
export class LazyModule {
constructor(injector: Injector) {
if (ngDevMode) {
const unhandledActionsLogger = injector.get(NgxsUnhandledActionsLogger);
unhandledActionsLogger.ignoreActions(LazyAction);
}
}
}
```

The `ngDevMode` is a specific variable provided by Angular in development mode and by Angular CLI (to Terser) in production mode. This allows tree-shaking `NgxsUnhandledActionsLogger` stuff since the `NgxsDevelopmentModule` is imported only in development mode. It's never functional in production mode.
17 changes: 17 additions & 0 deletions packages/store/src/dev-features/ngxs-development.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ModuleWithProviders, NgModule } from '@angular/core';

import { NgxsDevelopmentOptions, NGXS_DEVELOPMENT_OPTIONS } from './symbols';
import { NgxsUnhandledActionsLogger } from './ngxs-unhandled-actions-logger';

@NgModule()
export class NgxsDevelopmentModule {
static forRoot(options: NgxsDevelopmentOptions): ModuleWithProviders<NgxsDevelopmentModule> {
return {
ngModule: NgxsDevelopmentModule,
providers: [
NgxsUnhandledActionsLogger,
{ provide: NGXS_DEVELOPMENT_OPTIONS, useValue: options }
]
};
}
}
48 changes: 48 additions & 0 deletions packages/store/src/dev-features/ngxs-unhandled-actions-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Inject, Injectable } from '@angular/core';

import { ActionType } from '../actions/symbols';
import { getActionTypeFromInstance } from '../utils/utils';
import { InitState, UpdateState } from '../actions/actions';
import { NgxsDevelopmentOptions, NGXS_DEVELOPMENT_OPTIONS } from './symbols';

@Injectable()
export class NgxsUnhandledActionsLogger {
/**
* These actions should be ignored by default; the user can increase this
* list in the future via the `ignoreActions` method.
*/
private _ignoredActions = new Set<string>([InitState.type, UpdateState.type]);

constructor(@Inject(NGXS_DEVELOPMENT_OPTIONS) options: NgxsDevelopmentOptions) {
this.ignoreActions(...options.warnOnUnhandledActions.ignore);
}

/**
* Adds actions to the internal list of actions that should be ignored.
*/
ignoreActions(...actions: ActionType[]): void {
for (const action of actions) {
this._ignoredActions.add(action.type);
}
}

/** @internal */
warn(action: any): void {
const actionShouldBeIgnored = Array.from(this._ignoredActions).some(
type => type === getActionTypeFromInstance(action)
);

if (actionShouldBeIgnored) {
return;
}

action =
action.constructor && action.constructor.name !== 'Object'
? action.constructor.name
: action.type;

console.warn(
`The ${action} action has been dispatched but hasn't been handled. This may happen if the state with an action handler for this action is not registered.`
);
}
}
19 changes: 19 additions & 0 deletions packages/store/src/dev-features/symbols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { InjectionToken } from '@angular/core';

import { ActionType } from '../actions/symbols';

export interface NgxsDevelopmentOptions {
warnOnUnhandledActions: {
ignore: ActionType[];
};
}

export const NGXS_DEVELOPMENT_OPTIONS = new InjectionToken<NgxsDevelopmentOptions>(
'NGXS_DEVELOPMENT_OPTIONS',
{
providedIn: 'root',
factory: () => ({
warnOnUnhandledActions: { ignore: [] }
})
}
);
25 changes: 21 additions & 4 deletions packages/store/src/internal/state-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { InternalDispatchedActionResults } from '../internal/dispatcher';
import { StateContextFactory } from '../internal/state-context-factory';
import { StoreValidators } from '../utils/store-validators';
import { ensureStateClassIsInjectable } from '../ivy/ivy-enabled-in-dev-mode';
import { NgxsUnhandledActionsLogger } from '../dev-features/ngxs-unhandled-actions-logger';

/**
* State factory class
Expand Down Expand Up @@ -138,10 +139,8 @@ export class StateFactory implements OnDestroy {
}

ngOnDestroy(): void {
// I'm using non-null assertion here since `_actionsSubscrition` will
// be 100% defined. This is because `ngOnDestroy()` cannot be invoked
// on the `StateFactory` until its initialized :) An it's initialized
// for the first time along with the `NgxsRootModule`.
// This is being non-null asserted since `_actionsSubscrition` is
// initialized within the constructor.
this._actionsSubscription!.unsubscribe();
}

Expand Down Expand Up @@ -247,6 +246,10 @@ export class StateFactory implements OnDestroy {
const type = getActionTypeFromInstance(action)!;
const results = [];

// Determines whether the dispatched action has been handled, this is assigned
// to `true` within the below `for` loop if any `actionMetas` has been found.
let actionHasBeenHandled = false;

for (const metadata of this.states) {
const actionMetas = metadata.actions[type];

Expand Down Expand Up @@ -296,10 +299,24 @@ export class StateFactory implements OnDestroy {
} catch (e) {
results.push(throwError(e));
}

actionHasBeenHandled = true;
}
}
}

// The `NgxsUnhandledActionsLogger` is a tree-shakable class which functions
// only during development.
if ((typeof ngDevMode === 'undefined' || ngDevMode) && !actionHasBeenHandled) {
const unhandledActionsLogger = this._injector.get(NgxsUnhandledActionsLogger, null);
// The `NgxsUnhandledActionsLogger` will not be resolved by the injector if the
// `NgxsDevelopmentModule` is not provided. It's enough to check whether the `injector.get`
// didn't return `null` so we may ensure the module has been imported.
if (unhandledActionsLogger) {
unhandledActionsLogger.warn(action);
}
}

if (!results.length) {
results.push(of({}));
}
Expand Down
Empty file.
4 changes: 4 additions & 0 deletions packages/store/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,7 @@ export { NgxsExecutionStrategy } from './execution/symbols';
export { ActionType, ActionOptions } from './actions/symbols';
export { NoopNgxsExecutionStrategy } from './execution/noop-ngxs-execution-strategy';
export { StateToken } from './state-token/state-token';

export { NgxsDevelopmentOptions } from './dev-features/symbols';
export { NgxsDevelopmentModule } from './dev-features/ngxs-development.module';
export { NgxsUnhandledActionsLogger } from './dev-features/ngxs-unhandled-actions-logger';
92 changes: 92 additions & 0 deletions packages/store/tests/unhandled-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { TestBed } from '@angular/core/testing';

import { Store, NgxsModule, NgxsDevelopmentModule, NgxsUnhandledActionsLogger } from '..';

describe('Unhandled actions warnings', () => {
class FireAndForget {
static readonly type = 'Fire & forget';
}

const plainObjectAction = { type: 'Fire & Forget' };

const testSetup = (warnOnUnhandledActions = true) => {
TestBed.configureTestingModule({
imports: [
NgxsModule.forRoot([]),
warnOnUnhandledActions
? NgxsDevelopmentModule.forRoot({
warnOnUnhandledActions: { ignore: [] }
})
: []
]
});

const store = TestBed.inject(Store);
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
const unhandledActionsLogger = TestBed.inject(NgxsUnhandledActionsLogger, null);
return { store, warnSpy, unhandledActionsLogger };
};

it('should not warn on unhandled actions if the module is not provided', () => {
// Arrange
const { store, warnSpy } = testSetup(/* warnOnUnhandledActions */ false);
// Act
store.dispatch(new FireAndForget());
// Assert
try {
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});

describe('warn on unhandled actions', () => {
it('should warn when the action is a class', () => {
// Arrange
const { store, warnSpy } = testSetup();
const action = new FireAndForget();
// Act
store.dispatch(action);
// Assert
try {
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
`The FireAndForget action has been dispatched but hasn't been handled. This may happen if the state with an action handler for this action is not registered.`
);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when the action is an object', () => {
// Arrange
const { store, warnSpy } = testSetup();
// Act
store.dispatch(plainObjectAction);
// Assert
try {
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
`The Fire & Forget action has been dispatched but hasn't been handled. This may happen if the state with an action handler for this action is not registered.`
);
} finally {
warnSpy.mockRestore();
}
});

it('should be possible to add custom actions which should be ignored', () => {
// Arrange
const { store, warnSpy, unhandledActionsLogger } = testSetup();
unhandledActionsLogger!.ignoreActions(FireAndForget, plainObjectAction);
// Act
store.dispatch(new FireAndForget());
store.dispatch(plainObjectAction);
// Assert
try {
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
});
});

0 comments on commit 1bdb8c0

Please sign in to comment.