From 858de40d3aeb809c7d8b7e03d976e7ed7129ebcc Mon Sep 17 00:00:00 2001
From: Brandon Roberts <robertsbt@gmail.com>
Date: Tue, 25 Jul 2017 23:24:17 -0500
Subject: [PATCH] feat(RouterStore): Added serializer for router state snapshot

This adds a serializer that can be customized for returning the router state. By default, the entire RouterStateSnapshot is returned. A custom serializer can be provided to parse the snapshot into a more managable structure.

Closes #104, #97
---
 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<T> = {
+  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<RouterStateUrl> {
+  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<State> = {
   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<RouterStateUrl> {
+  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<any>) => {
+        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<T> = {
+  routerState: T;
   event: RoutesRecognized;
 };
 
 /**
  * An action dispatched when the router navigates.
  */
-export type RouterNavigationAction = {
+export type RouterNavigationAction<T> = {
   type: typeof ROUTER_NAVIGATION;
-  payload: RouterNavigationPayload;
+  payload: RouterNavigationPayload<T>;
 };
 
 /**
@@ -78,7 +81,7 @@ export type RouterErrorAction<T> = {
  * An union type of router actions.
  */
 export type RouterAction<T> =
-  | RouterNavigationAction
+  | RouterNavigationAction<T>
   | RouterCancelAction<T>
   | RouterErrorAction<T>;
 
@@ -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<any>, private router: Router) {
+  constructor(
+    private store: Store<any>,
+    private router: Router,
+    private serializer: RouterStateSerializer<RouterStateSnapshot>
+  ) {
     this.setUpBeforePreactivationHook();
     this.setUpStoreStateListener();
     this.setUpStateRollbackEvents();
@@ -165,7 +176,7 @@ export class StoreRouterConnectingModule {
     (<any>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<T> {
+  abstract serialize(routerState: RouterStateSnapshot): T;
+}
+
+export class DefaultRouterStateSerializer
+  implements RouterStateSerializer<RouterStateSnapshot> {
+  serialize(routerState: RouterStateSnapshot) {
+    return routerState;
+  }
+}