From 0fc1bcc2674eb0a755fcd64b576f244f25d7df41 Mon Sep 17 00:00:00 2001 From: Brandon Date: Tue, 8 Aug 2017 20:43:59 -0500 Subject: [PATCH] feat(RouterStore): Add serializer for router state snapshot (#188) This adds a serializer that can be customized for returning the router state snapshot. By default, the entire RouterStateSnapshot is returned. Documentation has been updated with example usage. ```ts import { StoreModule } from '@ngrx/store'; import { StoreRouterConnectingModule, routerReducer, RouterStateSerializer } from '@ngrx/router-store'; export interface RouterStateUrl { url: string; } export class CustomSerializer implements RouterStateSerializer { serialize(routerState: RouterStateSnapshot): RouterStateUrl { const url = routerState ? routerState.url : ''; // Only return an object including the URL // instead of the entire snapshot return { url }; } } @NgModule({ imports: [ StoreModule.forRoot({ routerReducer: routerReducer }), RouterModule.forRoot([ // routes ]), StoreRouterConnectingModule ], providers: [ { provide: RouterStateSerializer, useClass: CustomSerializer } ] }) export class AppModule { } ``` Closes #97, #104, #237 --- docs/router-store/README.md | 7 ++- docs/router-store/api.md | 46 +++++++++++++++ example-app/app/app.module.ts | 14 ++++- example-app/app/reducers/index.ts | 3 + example-app/app/shared/utils.ts | 24 ++++++++ modules/router-store/spec/integration.spec.ts | 57 ++++++++++++++++++- modules/router-store/src/index.ts | 5 ++ .../router-store/src/router_store_module.ts | 40 +++++++++---- modules/router-store/src/serializer.ts | 13 +++++ 9 files changed, 191 insertions(+), 18 deletions(-) create mode 100644 docs/router-store/api.md create mode 100644 example-app/app/shared/utils.ts create mode 100644 modules/router-store/src/serializer.ts diff --git a/docs/router-store/README.md b/docs/router-store/README.md index 0380d93c67..18c731954c 100644 --- a/docs/router-store/README.md +++ b/docs/router-store/README.md @@ -15,8 +15,8 @@ Install @ngrx/router-store from npm: During the navigation, before any guards or resolvers run, the router will dispatch a ROUTER_NAVIGATION action, which has the following signature: ```ts -export type RouterNavigationPayload = { - routerState: RouterStateSnapshot, +export type RouterNavigationPayload = { + routerState: T, event: RoutesRecognized } ``` @@ -46,3 +46,6 @@ import { App } from './app.component'; }) export class AppModule { } ``` + +## API Documentation +- [Custom Router State Serializer](./api.md#custom-router-state-serializer) \ No newline at end of file diff --git a/docs/router-store/api.md b/docs/router-store/api.md new file mode 100644 index 0000000000..b9eca6d52e --- /dev/null +++ b/docs/router-store/api.md @@ -0,0 +1,46 @@ +# API + +## Custom Router State Serializer + +During each navigation cycle, a `RouterNavigationAction` is dispatched with a snapshot of the state in its payload, the `RouterStateSnapshot`. The `RouterStateSnapshot` is a large complex structure, containing many pieces of information about the current state and what's rendered by the router. This can cause performance +issues when used with the Store Devtools. In most cases, you may only need a piece of information from the `RouterStateSnapshot`. In order to pair down the `RouterStateSnapshot` provided during navigation, you provide a custom serializer for the snapshot to only return what you need to be added to the payload and store. + +To use the time-traveling debugging in the Devtools, you must return an object containing the `url` when using the `routerReducer`. + +```ts +import { StoreModule } from '@ngrx/store'; +import { + StoreRouterConnectingModule, + routerReducer, + RouterStateSerializer, + RouterStateSnapshotType +} from '@ngrx/router-store'; + +export interface RouterStateUrl { + url: string; +} + +export class CustomSerializer implements RouterStateSerializer { + serialize(routerState: RouterStateSnapshot): RouterStateUrl { + const { url } = routerState; + + // Only return an object including the URL + // instead of the entire snapshot + return { url }; + } +} + +@NgModule({ + imports: [ + StoreModule.forRoot({ routerReducer: routerReducer }), + RouterModule.forRoot([ + // routes + ]), + StoreRouterConnectingModule + ], + providers: [ + { provide: RouterStateSerializer, useClass: CustomSerializer } + ] +}) +export class AppModule { } +``` \ No newline at end of file diff --git a/example-app/app/app.module.ts b/example-app/app/app.module.ts index 6eac29b6d2..4eac09b73b 100644 --- a/example-app/app/app.module.ts +++ b/example-app/app/app.module.ts @@ -8,7 +8,10 @@ import { HttpModule } from '@angular/http'; import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; import { DBModule } from '@ngrx/db'; -import { StoreRouterConnectingModule } from '@ngrx/router-store'; +import { + StoreRouterConnectingModule, + RouterStateSerializer, +} from '@ngrx/router-store'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { CoreModule } from './core/core.module'; @@ -17,6 +20,7 @@ import { AuthModule } from './auth/auth.module'; import { routes } from './routes'; import { reducers, metaReducers } from './reducers'; import { schema } from './db'; +import { CustomRouterStateSerializer } from './shared/utils'; import { AppComponent } from './core/containers/app'; import { environment } from '../environments/environment'; @@ -74,6 +78,14 @@ import { environment } from '../environments/environment'; AuthModule.forRoot(), ], + providers: [ + /** + * The `RouterStateSnapshot` provided by the `Router` is a large complex structure. + * A custom RouterStateSerializer is used to parse the `RouterStateSnapshot` provided + * by `@ngrx/router-store` to include only the desired pieces of the snapshot. + */ + { provide: RouterStateSerializer, useClass: CustomRouterStateSerializer }, + ], bootstrap: [AppComponent], }) export class AppModule {} diff --git a/example-app/app/reducers/index.ts b/example-app/app/reducers/index.ts index 1f04960303..0833cd6774 100644 --- a/example-app/app/reducers/index.ts +++ b/example-app/app/reducers/index.ts @@ -5,6 +5,7 @@ import { ActionReducer, } from '@ngrx/store'; import { environment } from '../../environments/environment'; +import * as fromRouter from '@ngrx/router-store'; /** * Every reducer module's default export is the reducer function itself. In @@ -21,6 +22,7 @@ import * as fromLayout from '../core/reducers/layout'; */ export interface State { layout: fromLayout.State; + routerReducer: fromRouter.RouterReducerState; } /** @@ -30,6 +32,7 @@ export interface State { */ export const reducers: ActionReducerMap = { layout: fromLayout.reducer, + routerReducer: fromRouter.routerReducer, }; // console.log all actions diff --git a/example-app/app/shared/utils.ts b/example-app/app/shared/utils.ts new file mode 100644 index 0000000000..758011bce2 --- /dev/null +++ b/example-app/app/shared/utils.ts @@ -0,0 +1,24 @@ +import { RouterStateSerializer } from '@ngrx/router-store'; +import { RouterStateSnapshot } from '@angular/router'; + +/** + * The RouterStateSerializer takes the current RouterStateSnapshot + * and returns any pertinent information needed. The snapshot contains + * all information about the state of the router at the given point in time. + * The entire snapshot is complex and not always needed. In this case, you only + * need the URL from the snapshot in the store. Other items could be + * returned such as route parameters, query parameters and static route data. + */ + +export interface RouterStateUrl { + url: string; +} + +export class CustomRouterStateSerializer + implements RouterStateSerializer { + serialize(routerState: RouterStateSnapshot): RouterStateUrl { + const { url } = routerState; + + return { url }; + } +} diff --git a/modules/router-store/spec/integration.spec.ts b/modules/router-store/spec/integration.spec.ts index 4a16632a26..c0c53184a0 100644 --- a/modules/router-store/spec/integration.spec.ts +++ b/modules/router-store/spec/integration.spec.ts @@ -1,6 +1,6 @@ -import { Component } from '@angular/core'; +import { Component, Provider } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { NavigationEnd, Router } from '@angular/router'; +import { NavigationEnd, Router, RouterStateSnapshot } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { Store, StoreModule } from '@ngrx/store'; import { @@ -10,6 +10,7 @@ import { RouterAction, routerReducer, StoreRouterConnectingModule, + RouterStateSerializer, } from '../src/index'; import 'rxjs/add/operator/filter'; import 'rxjs/add/operator/first'; @@ -315,11 +316,60 @@ describe('integration spec', () => { ]); done(); }); + + it('should support a custom RouterStateSnapshot serializer ', done => { + const reducer = (state: any, action: RouterAction) => { + const r = routerReducer(state, action); + return r && r.state + ? { url: r.state.url, navigationId: r.navigationId } + : null; + }; + + class CustomSerializer implements RouterStateSerializer<{ url: string }> { + serialize(routerState: RouterStateSnapshot) { + const url = `${routerState.url}-custom`; + + return { url }; + } + } + + const providers = [ + { provide: RouterStateSerializer, useClass: CustomSerializer }, + ]; + + createTestModule({ reducers: { routerReducer, reducer }, providers }); + + const router = TestBed.get(Router); + const store = TestBed.get(Store); + const log = logOfRouterAndStore(router, store); + + router + .navigateByUrl('/') + .then(() => { + log.splice(0); + return router.navigateByUrl('next'); + }) + .then(() => { + expect(log).toEqual([ + { type: 'router', event: 'NavigationStart', url: '/next' }, + { type: 'router', event: 'RoutesRecognized', url: '/next' }, + { type: 'store', state: { url: '/next-custom', navigationId: 2 } }, + { type: 'router', event: 'NavigationEnd', url: '/next' }, + ]); + log.splice(0); + done(); + }); + }); }); }); function createTestModule( - opts: { reducers?: any; canActivate?: Function; canLoad?: Function } = {} + opts: { + reducers?: any; + canActivate?: Function; + canLoad?: Function; + providers?: Provider[]; + } = {} ) { @Component({ selector: 'test-app', @@ -361,6 +411,7 @@ function createTestModule( provide: 'CanLoadNext', useValue: opts.canLoad || (() => true), }, + opts.providers || [], ], }); diff --git a/modules/router-store/src/index.ts b/modules/router-store/src/index.ts index bcb9e2e004..ef9b177f2e 100644 --- a/modules/router-store/src/index.ts +++ b/modules/router-store/src/index.ts @@ -13,3 +13,8 @@ export { RouterNavigationPayload, StoreRouterConnectingModule, } from './router_store_module'; + +export { + RouterStateSerializer, + DefaultRouterStateSerializer, +} from './serializer'; diff --git a/modules/router-store/src/router_store_module.ts b/modules/router-store/src/router_store_module.ts index 1ee0481841..000ab7608a 100644 --- a/modules/router-store/src/router_store_module.ts +++ b/modules/router-store/src/router_store_module.ts @@ -8,7 +8,10 @@ import { } from '@angular/router'; import { Store } from '@ngrx/store'; import { of } from 'rxjs/observable/of'; - +import { + DefaultRouterStateSerializer, + RouterStateSerializer, +} from './serializer'; /** * An action dispatched when the router navigates. */ @@ -17,17 +20,17 @@ export const ROUTER_NAVIGATION = 'ROUTER_NAVIGATION'; /** * Payload of ROUTER_NAVIGATION. */ -export type RouterNavigationPayload = { - routerState: RouterStateSnapshot; +export type RouterNavigationPayload = { + routerState: T; event: RoutesRecognized; }; /** * An action dispatched when the router navigates. */ -export type RouterNavigationAction = { +export type RouterNavigationAction = { type: typeof ROUTER_NAVIGATION; - payload: RouterNavigationPayload; + payload: RouterNavigationPayload; }; /** @@ -78,7 +81,7 @@ export type RouterErrorAction = { * An union type of router actions. */ export type RouterAction = - | RouterNavigationAction + | RouterNavigationAction | RouterCancelAction | RouterErrorAction; @@ -133,7 +136,7 @@ export function routerReducer( * declarations: [AppCmp, SimpleCmp], * imports: [ * BrowserModule, - * StoreModule.provideStore(mapOfReducers), + * StoreModule.forRoot(mapOfReducers), * RouterModule.forRoot([ * { path: '', component: SimpleCmp }, * { path: 'next', component: SimpleCmp } @@ -146,16 +149,24 @@ export function routerReducer( * } * ``` */ -@NgModule({}) +@NgModule({ + providers: [ + { provide: RouterStateSerializer, useClass: DefaultRouterStateSerializer }, + ], +}) export class StoreRouterConnectingModule { - private routerState: RouterStateSnapshot | null = null; + private routerState: RouterStateSnapshot; private storeState: any; private lastRoutesRecognized: RoutesRecognized; private dispatchTriggeredByRouter: boolean = false; // used only in dev mode in combination with routerReducer private navigationTriggeredByDispatch: boolean = false; // used only in dev mode in combination with routerReducer - constructor(private store: Store, private router: Router) { + constructor( + private store: Store, + private router: Router, + private serializer: RouterStateSerializer + ) { this.setUpBeforePreactivationHook(); this.setUpStoreStateListener(); this.setUpStateRollbackEvents(); @@ -165,7 +176,7 @@ export class StoreRouterConnectingModule { (this.router).hooks.beforePreactivation = ( routerState: RouterStateSnapshot ) => { - this.routerState = routerState; + this.routerState = this.serializer.serialize(routerState); if (this.shouldDispatchRouterNavigation()) this.dispatchRouterNavigation(); return of(true); @@ -214,7 +225,12 @@ export class StoreRouterConnectingModule { private dispatchRouterNavigation(): void { this.dispatchRouterAction(ROUTER_NAVIGATION, { routerState: this.routerState, - event: this.lastRoutesRecognized, + event: { + id: this.lastRoutesRecognized.id, + url: this.lastRoutesRecognized.url, + urlAfterRedirects: this.lastRoutesRecognized.urlAfterRedirects, + state: this.serializer.serialize(this.routerState), + } as RoutesRecognized, }); } diff --git a/modules/router-store/src/serializer.ts b/modules/router-store/src/serializer.ts new file mode 100644 index 0000000000..ddeecad0b9 --- /dev/null +++ b/modules/router-store/src/serializer.ts @@ -0,0 +1,13 @@ +import { InjectionToken } from '@angular/core'; +import { RouterStateSnapshot } from '@angular/router'; + +export abstract class RouterStateSerializer { + abstract serialize(routerState: RouterStateSnapshot): T; +} + +export class DefaultRouterStateSerializer + implements RouterStateSerializer { + serialize(routerState: RouterStateSnapshot) { + return routerState; + } +}