├── .gitignore ├── spec ├── main.spec.ts ├── ngrx-store-router-module.spec.ts └── victor-store-router-module.spec.ts ├── webpack.test.config.js ├── tsconfig.json ├── karma.conf.js ├── package.json ├── src ├── ngrx-store-router-module.ts └── victor-store-router-module.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | node_modules 4 | *.log -------------------------------------------------------------------------------- /spec/main.spec.ts: -------------------------------------------------------------------------------- 1 | import './ngrx-store-router-module.spec'; 2 | import './victor-store-router-module.spec'; -------------------------------------------------------------------------------- /webpack.test.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | resolve: { 5 | extensions: ['.ts', '.js'] 6 | }, 7 | entry: './spec/main.spec.ts', 8 | output: { 9 | path: path.join(process.cwd(), 'dist'), 10 | publicPath: 'dist/', 11 | filename: "bundle.js" 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.ts$/, 17 | use: [ 18 | 'ts-loader' 19 | ] 20 | } 21 | ] 22 | } 23 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "declaration": false, 5 | "stripInternal": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "noEmitOnError": false, 11 | "outDir": "./release", 12 | "rootDir": ".", 13 | "lib": ["es2015", "dom"], 14 | "target": "es5", 15 | "skipLibCheck": true, 16 | "types": [ 17 | "node", 18 | "jasmine" 19 | ] 20 | }, 21 | "exclude": [ 22 | "node_modules" 23 | ], 24 | "angularCompilerOptions": { 25 | "strictMetadataEmit": true 26 | } 27 | } -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | module.exports = function(karma) { 3 | 'use strict'; 4 | 5 | karma.set({ 6 | basePath: __dirname, 7 | frameworks: ['jasmine'], 8 | files: [ 9 | 'node_modules/core-js/client/core.js', 10 | 'node_modules/reflect-metadata/Reflect.js', 11 | 12 | // Zone.js dependencies 13 | 'node_modules/zone.js/dist/zone.js', 14 | 'node_modules/zone.js/dist/long-stack-trace-zone.js', 15 | 'node_modules/zone.js/dist/proxy.js', 16 | 'node_modules/zone.js/dist/sync-test.js', 17 | 'node_modules/zone.js/dist/jasmine-patch.js', 18 | 'node_modules/zone.js/dist/async-test.js', 19 | 'node_modules/zone.js/dist/fake-async-test.js', 20 | 21 | { pattern: 'dist/bundle.js', watched: false } 22 | ], 23 | 24 | browsers: ['Chrome'], 25 | colors: true, 26 | logLevel: karma.LOG_INFO, 27 | singleRun: true 28 | }); 29 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ngrx/router-store-connector", 3 | "version": "0.0.1", 4 | "description": "Bindings to connect @angular/router to a redux-like store", 5 | "scripts": { 6 | "test": "rm -rf dist && webpack --config webpack.test.config.js && karma start" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "TBD" 11 | }, 12 | "authors": [ 13 | "Victor Savkin" 14 | ], 15 | "license": "MIT", 16 | "peerDependencies": { 17 | "rxjs": "^5.0.0-beta.12", 18 | "@angular/common": "^2.4.0", 19 | "@angular/core": "^2.4.0", 20 | "@angular/router": "^3.4.0", 21 | "@ngrx/core": "^1.2.0", 22 | "@ngrx/store": "^2.0.0" 23 | }, 24 | "devDependencies": { 25 | "@angular/common": "^2.0.0", 26 | "@angular/compiler": "^2.0.0", 27 | "@angular/platform-browser": "^2.0.0", 28 | "@angular/platform-browser-dynamic": "^2.0.0", 29 | "rxjs": "^5.0.0-beta.12", 30 | "@angular/core": "^2.4.0", 31 | "@angular/router": "^3.4.0", 32 | "@ngrx/core": "^1.2.0", 33 | "@ngrx/store": "^2.0.0", 34 | "@types/jasmine": "^2.2.33", 35 | "@types/node": "^6.0.38", 36 | "jasmine-core": "^2.5.2", 37 | "karma": "^1.4.0", 38 | "karma-chrome-launcher": "^2.0.0", 39 | "karma-jasmine": "^1.1.0", 40 | "ts-loader": "^2.0.0", 41 | "typescript": "^2.0.2", 42 | "webpack": "^2.2.0", 43 | "zone.js": "^0.7.6" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ngrx-store-router-module.ts: -------------------------------------------------------------------------------- 1 | import {Injectable, ModuleWithProviders, NgModule, Optional} from "@angular/core"; 2 | import { 3 | ActivatedRouteSnapshot, CanActivateChild, ExtraOptions, RouterModule, RouterStateSnapshot, 4 | Routes 5 | } from "@angular/router"; 6 | import {Store} from "@ngrx/store"; 7 | 8 | export const ROUTER_NAVIGATION = 'ROUTER_NAVIGATION'; 9 | 10 | @Injectable() 11 | export class CanActivateChild_Internal implements CanActivateChild { 12 | private lastState: RouterStateSnapshot = null; 13 | 14 | constructor(@Optional() private store: Store) { 15 | if (!store) { 16 | throw new Error("RouterConnectedToStoreModule can only be used in combination with StoreModule"); 17 | } 18 | } 19 | 20 | canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { 21 | if (this.lastState !== state) { 22 | this.lastState = state; 23 | this.store.dispatch({type: ROUTER_NAVIGATION, payload: state}); 24 | } 25 | return true; 26 | } 27 | } 28 | 29 | export function wrapRoutes(routes: Routes): Routes { 30 | return [{path: '', canActivateChild: [CanActivateChild_Internal], children: routes}]; 31 | } 32 | 33 | /** 34 | * Sets up the router module and wires it up to the store. 35 | * 36 | * Usage: 37 | * 38 | * ```typescript 39 | * @NgModule({ 40 | * declarations: [AppCmp, SimpleCmp], 41 | * imports: [ 42 | * BrowserModule, 43 | * StoreModule.provideStore({router: routerReducer}), 44 | * RouterConnectedToStoreModule.forRoot([ 45 | * { path: '', component: SimpleCmp }, 46 | * { path: 'next', component: SimpleCmp } 47 | * ], {useHash: true, initialNavigation: false}) 48 | * ], 49 | * bootstrap: [AppCmp] 50 | * }) 51 | * class AppModule { 52 | * } 53 | * ``` 54 | */ 55 | @NgModule({}) 56 | export class RouterConnectedToStoreModule { 57 | static forRoot(routes: Routes, config?: ExtraOptions): ModuleWithProviders{ 58 | return { 59 | ngModule: RouterModule, 60 | providers: [ 61 | CanActivateChild_Internal, 62 | ...RouterModule.forRoot(wrapRoutes(routes), config).providers 63 | ] 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /spec/ngrx-store-router-module.spec.ts: -------------------------------------------------------------------------------- 1 | import {Component, NgModule} from "@angular/core"; 2 | import {BrowserModule} from "@angular/platform-browser"; 3 | import {platformBrowserDynamic} from "@angular/platform-browser-dynamic"; 4 | import {NavigationEnd, Router} from "@angular/router"; 5 | import {Store, StoreModule} from "@ngrx/store"; 6 | import "rxjs/add/operator/filter"; 7 | import {ROUTER_NAVIGATION, RouterConnectedToStoreModule} from "../src/ngrx-store-router-module"; 8 | 9 | 10 | function routerReducer(state: string = "", action: any) { 11 | if (action.type === ROUTER_NAVIGATION) { 12 | return action.payload.url.toString(); 13 | } else { 14 | return state; 15 | } 16 | } 17 | 18 | @Component({ 19 | selector: 'test-app', 20 | template: '' 21 | }) 22 | class AppCmp { 23 | } 24 | 25 | @Component({ 26 | selector: 'pagea-cmp', 27 | template: 'pagea-cmp' 28 | }) 29 | class SimpleCmp {} 30 | 31 | @NgModule({ 32 | declarations: [AppCmp, SimpleCmp], 33 | imports: [ 34 | BrowserModule, 35 | StoreModule.provideStore({router: routerReducer}), 36 | RouterConnectedToStoreModule.forRoot([ 37 | { path: '', component: SimpleCmp }, 38 | { path: 'next', component: SimpleCmp } 39 | ], {useHash: true, initialNavigation: false}) 40 | ], 41 | bootstrap: [AppCmp] 42 | }) 43 | class TestAppModule { 44 | } 45 | 46 | describe('ngrx', () => { 47 | beforeEach(() => { 48 | document.body.appendChild(document.createElement("test-app")); 49 | }); 50 | 51 | it('should work', (done) => { 52 | 53 | platformBrowserDynamic().bootstrapModule(TestAppModule).then(ref => { 54 | const router = ref.injector.get(Router); 55 | const store = ref.injector.get(Store); 56 | 57 | let log = setUpLogging(router, store); 58 | 59 | router.navigateByUrl("/"); 60 | 61 | setTimeout(() => { 62 | expect(log).toEqual([ 63 | {type: 'store', url: ""}, //init event. has nothing to do with the router 64 | {type: 'store', url: "/"}, // ROUTER_NAVIGATION event in the store 65 | {type: 'router', url: '/'} // NavigationEnd 66 | ]); 67 | log.splice(0); 68 | 69 | router.navigateByUrl("next").then(() => { 70 | expect(log).toEqual([ 71 | {type: 'store', url: "/next"}, 72 | {type: 'router', url: '/next'} 73 | ]); 74 | done(); 75 | }); 76 | }, 100); 77 | }); 78 | }); 79 | }); 80 | 81 | function setUpLogging(router: Router, store: Store): string[] { 82 | const log = []; 83 | router.events.filter(e => e instanceof NavigationEnd). 84 | subscribe(e => log.push({type: 'router', url: e.url.toString()})); 85 | store.subscribe(store => log.push({type: 'store', url: store.router})); 86 | return log; 87 | } -------------------------------------------------------------------------------- /spec/victor-store-router-module.spec.ts: -------------------------------------------------------------------------------- 1 | import {Component, NgModule} from "@angular/core"; 2 | import {BrowserModule} from "@angular/platform-browser"; 3 | import {platformBrowserDynamic} from "@angular/platform-browser-dynamic"; 4 | import {NavigationEnd, Router} from "@angular/router"; 5 | import "rxjs/add/operator/filter"; 6 | import {RouterConnectedToStoreModule, Store} from "../src/victor-store-router-module"; 7 | 8 | 9 | function routerReducer(store: Store, state: string, action: any) { 10 | if (action.type === "ROUTER_NAVIGATION") { 11 | return action.state.url.toString(); 12 | } else { 13 | return state; 14 | } 15 | } 16 | 17 | @Component({ 18 | selector: 'victor-test-app', 19 | template: '' 20 | }) 21 | class AppCmp { 22 | } 23 | 24 | @Component({ 25 | selector: 'pagea-cmp', 26 | template: 'pagea-cmp' 27 | }) 28 | class SimpleCmp {} 29 | 30 | @NgModule({ 31 | declarations: [AppCmp, SimpleCmp], 32 | imports: [ 33 | BrowserModule, 34 | RouterConnectedToStoreModule.forRoot( 35 | {reducer: "StoreReducer", initState: "StoreInitState"}, 36 | [ 37 | { path: '', component: SimpleCmp }, 38 | { path: 'next', component: SimpleCmp } 39 | ], 40 | {useHash: true, initialNavigation: false} 41 | ) 42 | ], 43 | providers: [ 44 | {provide: "StoreReducer", useValue: routerReducer}, 45 | {provide: "StoreInitState", useValue: ""} 46 | ], 47 | bootstrap: [AppCmp] 48 | }) 49 | class TestAppModule { 50 | } 51 | 52 | describe('victor', () => { 53 | beforeEach(() => { 54 | document.body.appendChild(document.createElement("victor-test-app")); 55 | }); 56 | 57 | it('should work', (done) => { 58 | platformBrowserDynamic().bootstrapModule(TestAppModule).then(ref => { 59 | const router = ref.injector.get(Router); 60 | const store = ref.injector.get(Store); 61 | 62 | let log = setUpLogging(router, store); 63 | 64 | router.navigateByUrl("/"); 65 | 66 | setTimeout(() => { 67 | expect(log).toEqual([ 68 | {type: 'store', url: ""}, // Init event 69 | {type: 'store', url: "/"}, // ROUTER_NAVIGATION event in the store 70 | {type: 'router', url: '/'} // NavigationEnd 71 | ]); 72 | log.splice(0); 73 | done(); 74 | 75 | router.navigateByUrl("next").then(() => { 76 | expect(log).toEqual([ 77 | {type: 'store', url: "/next"}, 78 | {type: 'router', url: '/next'} 79 | ]); 80 | done(); 81 | }); 82 | }, 100); 83 | }); 84 | }); 85 | }); 86 | 87 | function setUpLogging(router: Router, store: Store): string[] { 88 | const log = []; 89 | router.events.filter(e => e instanceof NavigationEnd). 90 | subscribe(e => log.push({type: 'router', url: e.url.toString()})); 91 | store.subscribe(state => log.push({type: 'store', url: state})); 92 | return log; 93 | } -------------------------------------------------------------------------------- /src/victor-store-router-module.ts: -------------------------------------------------------------------------------- 1 | import {BehaviorSubject, Observable, Observer, Operator, Scheduler, Subject} from "rxjs"; 2 | import {of} from "rxjs/observable/of"; 3 | import {Injectable, Injector, ModuleWithProviders, NgModule, Optional} from "@angular/core"; 4 | import { 5 | ActivatedRouteSnapshot, CanActivateChild, ExtraOptions, RouterModule, RouterStateSnapshot, 6 | Routes 7 | } from "@angular/router"; 8 | 9 | export type RollbackFunction = (currentState: S, oldState: S, action: A) => S; 10 | export type Reducer = (store: Store, state: S, action: A) => S|Observable; 11 | export type RouterNavigation = { type: 'ROUTER_NAVIGATION', state: RouterStateSnapshot }; 12 | 13 | 14 | 15 | @Injectable() 16 | export class Store { 17 | private actions = new Subject<{action: A, result: Observer}>(); 18 | private states: BehaviorSubject; 19 | 20 | constructor(private reducer: Reducer, initState: S) { 21 | this.states = new BehaviorSubject(initState); 22 | 23 | this.actions.observeOn(Scheduler.async).mergeMap(a => { 24 | const state = reducer(this, this.states.value, a.action); 25 | const obs = state instanceof Observable ? state : of(state); 26 | return obs.map(state => ({state, result: a.result})); 27 | }).subscribe(pair => { 28 | this.states.next(pair.state); 29 | pair.result.next(true); 30 | pair.result.complete(); 31 | }); 32 | } 33 | 34 | subscribe(callback: (v:S) => void) { 35 | return this.states.subscribe(callback); 36 | } 37 | 38 | dispatch(action: A): Observable { 39 | const res = new Subject(); 40 | this.actions.next({action, result: res}); 41 | return res; 42 | } 43 | } 44 | 45 | @Injectable() 46 | export class CanActivateChild_Internal implements CanActivateChild { 47 | private lastState: RouterStateSnapshot = null; 48 | constructor(private store: Store) {} 49 | 50 | canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean|Observable { 51 | if (this.lastState !== state) { 52 | this.lastState = state; 53 | return this.store.dispatch({type: 'ROUTER_NAVIGATION', state: state}); 54 | } else { 55 | return true; 56 | } 57 | } 58 | } 59 | 60 | export function wrapRoutes(routes: Routes): Routes { 61 | return [{path: '', canActivateChild: [CanActivateChild_Internal], children: routes}]; 62 | } 63 | 64 | export interface StoreOptions { 65 | reducer: any; 66 | initState: any; 67 | } 68 | 69 | @NgModule({}) 70 | export class RouterConnectedToStoreModule { 71 | static forRoot(store: StoreOptions, routes: Routes, config: ExtraOptions): ModuleWithProviders{ 72 | return { 73 | ngModule: RouterModule, 74 | providers: [ 75 | CanActivateChild_Internal, 76 | {provide: Store, useFactory: (inj) => new Store(inj.get(store.reducer), inj.get(store.initState)), deps: [Injector]}, 77 | ...RouterModule.forRoot(wrapRoutes(routes), config).providers 78 | ] 79 | }; 80 | } 81 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Connection Router to Store (Prototypes) 2 | 3 | ## Current Approach (ngrx/router-store) 4 | 5 | ### The current version of RouterStore does four things: 6 | 7 | * It adds "path" to the state 8 | * It adds "syntax sugar" actions (e.g., go, replace, search), so the use can send an action to navigate to the store, which will eventually called the router 9 | * It sets up a listener that will send an action to the store when router navigates 10 | * It sets up a listener that will send an action the router when the store "updates" the "path" property 11 | 12 | ### This approach suffers from a lot of problems: 13 | 14 | #### Problem 1: Dispatch is different from clicking on a link 15 | 16 | Calling 17 | 18 | ``` 19 | store.dispatch(go("/url")) 20 | ``` 21 | 22 | is different from 23 | 24 | ``` 25 | router.navigateByUrl("/url") 26 | ``` 27 | 28 | This means that `store.dispatch(go("/url"))` is different from clicking on a link ```. 29 | 30 | In this first case the order of events will look like this: 31 | 32 | * The reducer is called before the router updates 33 | * The router updates 34 | * The reducer is called after the router updates 35 | 36 | Clicking on the link, on the other hand, will result in the following: 37 | 38 | * The router updates 39 | * The reducer is called after the router updates 40 | 41 | Updating the URL directly will result in the following as well: 42 | 43 | * The router updates 44 | * The reducer is called after the router updates 45 | 46 | This breaks the fundamental rule 'Updating the URL directly should not differ from updating it imperatively'. In practice this may not matter as much, but it is unfortunate that this property no longer holds. 47 | 48 | 49 | 50 | #### Problem 2: Resolvers and Guards See Obsolete Data 51 | 52 | Resolvers and guards cannot access the new state, which makes them less useful. 53 | 54 | Since the order of events looks like this: 55 | 56 | * The router updates 57 | * The reducer is called after the router updates 58 | 59 | We cannot access the updated state in guards or resolvers. 60 | 61 | 62 | 63 | #### Problem 3: Router state stored in the store is a string 64 | 65 | RouterStateSnapshot is the data structure we should use. It's way more useful for any non-trivial analysis of the URL. It's also almost impossible to write a useful reduction over a string. So writing reducers in the current setup is challenging. 66 | 67 | 68 | 69 | 70 | #### Problem 4: Store has no way of preventing a navigation 71 | 72 | The logic of the application is in the store. It's very handy for the store to be able to say 'no' to navigation. 73 | 74 | 75 | These are really important problems which are caused by us synchronizing the store and the router. Any time we sync two mutable objects, we will have this problem. 76 | 77 | 78 | 79 | ## New Approach 80 | 81 | I think we should NOT synchronize the store and the router, and instead make the store reduction part of the navigation process. 82 | 83 | So it looks like this: 84 | 85 | ``` 86 | [Router Parses URL, Applies Redirects, Creates RouterStateSnapshot] => 87 | [Store Emits New State] => 88 | [Router Runs Guards and Resolvers] => 89 | [RouteR Activates Components] 90 | ``` 91 | 92 | This approach fixes all the problems listed above. 93 | 94 | * Every navigation corresponds to a single call to the reducing function. We follow the rule 'Updating the URL directly should not differ from updating it imperatively'. 95 | * Resolvers and guards run after the store updates, so they see the latest data and can make decisions based on it. 96 | * The store receives a RouterStateSnapshot, not a string. This is way more useful as you have access to the router config. 97 | * The store can prevent navigation (see more comments below). 98 | * No synchronization needed! 99 | * We can achieve all of this with less code and no changes to the router itself. 100 | 101 | 102 | 103 | ## Two Versions 104 | 105 | This repo contains two versions of the library: 106 | 107 | * One for `ngrx/store` 108 | * One for Victor's store 109 | 110 | "Victor's store" differs from the ngrx/store in the following ways: 111 | 112 | * The reducer can return an observable, in which case other actions will be blocked until this observable completes. This allows us to run async work in the reducer and wait for that async work to complete. The user dispatching an action can wait for that action to be processed! 113 | * If that observable "errors", the navigation will be canceled. 114 | * The store supports automatic rollbacks. --------------------------------------------------------------------------------