├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── circle.yml ├── package.json ├── scripts └── release ├── src ├── Route.js ├── Router.js ├── actionTypes.js ├── actions.js ├── createRoutex.js ├── errors.js ├── index.js ├── react │ ├── Link.js │ ├── View.js │ └── index.js └── utils │ ├── routeUtils.js │ ├── routerUtils.js │ ├── stringUtils.js │ └── urlUtils.js ├── test ├── Route.spec.js ├── Router.spec.js ├── createRoutex.spec.js ├── react │ ├── Link.spec.js │ └── View.spec.js ├── routex.spec.js └── utils │ └── urlUtils.spec.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "loose": "all", 3 | "stage": 0 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [**.{js}] 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [**.{html,json}] 13 | indent_style = space 14 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "env": { 4 | "browser": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "ecmaFeatures": { 9 | "experimentalObjectRestSpread": true 10 | }, 11 | "rules": { 12 | "comma-dangle": [2, "never"], 13 | "padded-blocks": 0, 14 | // indent 4 spaces 15 | "indent": [2, 4, {"SwitchCase": 1}] 16 | }, 17 | "plugins": [ 18 | "react" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion 4 | 5 | *.iml 6 | 7 | ## Directory-based project format: 8 | .idea/ 9 | # if you remove the above rule, at least ignore the following: 10 | 11 | # User-specific stuff: 12 | # .idea/workspace.xml 13 | # .idea/tasks.xml 14 | # .idea/dictionaries 15 | 16 | # Sensitive or high-churn files: 17 | # .idea/dataSources.ids 18 | # .idea/dataSources.xml 19 | # .idea/sqlDataSources.xml 20 | # .idea/dynamic.xml 21 | # .idea/uiDesigner.xml 22 | 23 | # Gradle: 24 | # .idea/gradle.xml 25 | # .idea/libraries 26 | 27 | # Mongo Explorer plugin: 28 | # .idea/mongoSettings.xml 29 | 30 | ## File-based project format: 31 | *.ipr 32 | *.iws 33 | 34 | ## Plugin-specific files: 35 | 36 | # IntelliJ 37 | /out/ 38 | 39 | # mpeltonen/sbt-idea plugin 40 | .idea_modules/ 41 | 42 | # JIRA plugin 43 | atlassian-ide-plugin.xml 44 | 45 | # Crashlytics plugin (for Android Studio and IntelliJ) 46 | com_crashlytics_export_strings.xml 47 | crashlytics.properties 48 | crashlytics-build.properties 49 | 50 | 51 | ### Linux template 52 | *~ 53 | 54 | # KDE directory preferences 55 | .directory 56 | 57 | # Linux trash folder which might appear on any partition or disk 58 | .Trash-* 59 | 60 | 61 | ### OSX template 62 | .DS_Store 63 | .AppleDouble 64 | .LSOverride 65 | 66 | # Icon must end with two \r 67 | Icon 68 | 69 | # Thumbnails 70 | ._* 71 | 72 | # Files that might appear in the root of a volume 73 | .DocumentRevisions-V100 74 | .fseventsd 75 | .Spotlight-V100 76 | .TemporaryItems 77 | .Trashes 78 | .VolumeIcon.icns 79 | 80 | # Directories potentially created on remote AFP share 81 | .AppleDB 82 | .AppleDesktop 83 | Network Trash Folder 84 | Temporary Items 85 | .apdisk 86 | 87 | 88 | ### Node template 89 | # Logs 90 | logs 91 | *.log 92 | 93 | # Runtime data 94 | pids 95 | *.pid 96 | *.seed 97 | 98 | # Directory for instrumented libs generated by jscoverage/JSCover 99 | lib-cov 100 | 101 | # Coverage directory used by tools like istanbul 102 | coverage 103 | 104 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 105 | .grunt 106 | 107 | # node-waf configuration 108 | .lock-wscript 109 | 110 | # Compiled binary addons (http://nodejs.org/api/addons.html) 111 | build/Release 112 | 113 | # Dependency directory 114 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 115 | node_modules 116 | 117 | webpack-stats.json 118 | 119 | lib 120 | dist 121 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | 3 | src 4 | test 5 | scripts 6 | *.log 7 | .DS_Store 8 | 9 | 10 | ### JetBrains template 11 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion 12 | 13 | *.iml 14 | 15 | ## Directory-based project format: 16 | .idea/ 17 | # if you remove the above rule, at least ignore the following: 18 | 19 | # User-specific stuff: 20 | # .idea/workspace.xml 21 | # .idea/tasks.xml 22 | # .idea/dictionaries 23 | 24 | # Sensitive or high-churn files: 25 | # .idea/dataSources.ids 26 | # .idea/dataSources.xml 27 | # .idea/sqlDataSources.xml 28 | # .idea/dynamic.xml 29 | # .idea/uiDesigner.xml 30 | 31 | # Gradle: 32 | # .idea/gradle.xml 33 | # .idea/libraries 34 | 35 | # Mongo Explorer plugin: 36 | # .idea/mongoSettings.xml 37 | 38 | ## File-based project format: 39 | *.ipr 40 | *.iws 41 | 42 | ## Plugin-specific files: 43 | 44 | # IntelliJ 45 | /out/ 46 | 47 | # mpeltonen/sbt-idea plugin 48 | .idea_modules/ 49 | 50 | # JIRA plugin 51 | atlassian-ide-plugin.xml 52 | 53 | # Crashlytics plugin (for Android Studio and IntelliJ) 54 | com_crashlytics_export_strings.xml 55 | crashlytics.properties 56 | crashlytics-build.properties 57 | 58 | .babelrc 59 | .editorconfig 60 | .eslintignore 61 | .eslint 62 | .gitignore 63 | .travis.yml 64 | webpack.config.js 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | ## [1.0.0-alpha.21] - 2016/03/10 7 | * `onEnter` and `onLeave` handlers are resolved always, please make sure in handlers if you need to do something (e.g. ajax calls, ...) or not 8 | * removed argument `resolveOnLoad` from router, router always resolves routes and route handlers 9 | 10 | ## [1.0.0-alpha.18] - 2016/01/06 11 | * removed dependency on `rackt/history` 12 | * onEnter handlers on initial load can be disabled with fourth argument to `createRoutex` or `Route`. `false` will disable running onEnter handlers on initial load (after page is loaded) 13 | 14 | ## [1.0.0-alpha.17] - 2016/01/04 15 | * run onEnter handlers on initial load (after page is loaded) 16 | 17 | ## [1.0.0-alpha.16] - 2015/12/16 18 | * add `fullPath` to resolved route 19 | 20 | ## [1.0.0-alpha.15] - 2015/12/15 21 | * run route `onEnter` and `onLeave` handlers in order (not parallel) 22 | 23 | ## [1.0.0-alpha.14] - 2015/12/06 24 | * fix regex groups in route patterns 25 | 26 | ## [1.0.0-alpha.13] - 2015/12/06 27 | * fix link blinking in TRANSITIONING state 28 | 29 | ## [1.0.0-alpha.9] - 2015/12/05 30 | * fixed bug when multiple regex patterns are in route pattern 31 | 32 | ## [1.0.0-alpha.8] - 2015/10/27 33 | * fixed bug in matching active routes if href length is equal 1 34 | 35 | ## [1.0.0-alpha.7] - 2015/10/27 36 | * fixed bug in matching active routes if `Link` href is longer than matching path 37 | 38 | ## [1.0.0-alpha.6] - 2015/10/27 39 | * fixed active props for nested routes 40 | 41 | ## [1.0.0-alpha.5] - 2015/09/30 42 | * fixed matching of active routes 43 | 44 | ## [1.0.0-alpha.4] - 2015/09/30 45 | * fixed `Link` component `propTypes` 46 | 47 | ## [1.0.0-alpha.3] - 2015/09/30 48 | * added active and inactive props to `Link` component 49 | 50 | ## [1.0.0-alpha.2] - 2015/09/26 51 | * fixed bug in subroute base path 52 | 53 | ## [0.4.0] - 2015/08/16 54 | * added `createHref` to Router API 55 | * `Link` component is using `createHref` from a router instance 56 | 57 | ## [0.3.0] - 2015/07/31 58 | * replaced histories with rackt-history (BC breaking change) 59 | * added prever testing for node/browser environment 60 | 61 | ## [0.1.0] - 2015/07/18 62 | * Initial public release (dev, use on your own risk) 63 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct. 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) 23 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michal Kvasničák 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Routex 2 | 3 | [![Circle CI](https://circleci.com/gh/michalkvasnicak/routex.svg?style=svg)](https://circleci.com/gh/michalkvasnicak/routex) 4 | 5 | Simple router for [Redux](https://github.com/rackt/redux) universal applications. Can be used with [React](https://github.com/facebook/react) too. 6 | 7 | ## Installation 8 | 9 | `npm install routex` 10 | 11 | ## Requirements 12 | 13 | **Routex needs some abstraction over browser history, we recommend to use [rackt/history^1.0.0](https://github.com/rackt/history)** 14 | 15 | ## Usage 16 | 17 | ### Creating routex (without react) 18 | 19 | ```js 20 | import { createRoutex, actions } from 'routex'; 21 | import { compose, createStore, combineReducers } from 'redux'; 22 | import { createHistory } from 'history'; 23 | 24 | const routes = [ 25 | { 26 | path: '/', 27 | children: [ 28 | { 29 | path: 'about', 30 | children: [/* ... */] 31 | } 32 | ], 33 | attrs: { 34 | custom: true // optional custom attributes to assign to route 35 | } 36 | }/* ... */ 37 | ]; 38 | 39 | // this will return object with high order store and reducer 40 | const routex = createRoutex(routes, createHistory(), () => console.log('Transition finished') ); 41 | 42 | const newCreateStore = compose(routex.store, createStore); 43 | const routexReducer = routex.reducer; 44 | const reducers = combineReducers({ ...routexReducer /* your reducers */ }); 45 | 46 | const store = newCreateStore(reducers); 47 | 48 | store.dispatch(actions.transitionTo('/about')); // transitions to about 49 | 50 | store.generateLink('about'); // generates link object (see api) 51 | ``` 52 | 53 | ### Creating routex using in React app (React >= 0.14) 54 | 55 | ```js 56 | import { createRoutex } from 'routex'; 57 | import { compose, createStore, combineReducers } from 'redux'; 58 | import React, { Component } from 'react'; 59 | import { View, Link } from 'routex/lib/react'; 60 | import { createHistory } from 'history'; 61 | 62 | class App extends Component { 63 | render() { 64 | //this props children contains nested route 65 | // so everywhere when you can render nested routes you need to do this 66 | return ( 67 |
68 | 69 | {this.props.children} 70 |
71 | ); 72 | } 73 | } 74 | 75 | const routes = [ 76 | { 77 | path: '/', 78 | component: App, // you need components in all routes because needs to render them 79 | attrs: {}, // default attrs 80 | children: [ 81 | { 82 | path: 'about', 83 | attrs: { test: 1 }, 84 | component: () => Promise.resolve(About), 85 | children: () => Promise.resolve([{ path: '/', component: Child }]) 86 | } 87 | ] 88 | }/* ... */ 89 | ]; 90 | 91 | // this will return object with high order store and reducer 92 | const routex = createRoutex(routes, createHistory(), () => console.log('Transition finished') ); 93 | 94 | const newCreateStore = compose(routex.store, createStore); 95 | const routexReducer = routex.reducer; 96 | const reducers = combineReducers({ ...routexReducer /* your reducers */ }); 97 | 98 | const store = newCreateStore(reducers); 99 | 100 | React.render( 101 | 102 | 103 | 104 | , document.getElementById('App') 105 | ); 106 | 107 | ``` 108 | 109 | ### Use router as standalone (without redux / react) 110 | 111 | ```js 112 | import { Router } from 'routex'; 113 | import { createHistory } from 'history'; 114 | 115 | const router = new Router([/* routes */], createHistory() /*, optional onTransition hook */); 116 | 117 | router.listen(); // start listening to pop state events (immediately will start transition for current location) 118 | 119 | // if you want to transition to another location you have to run this 120 | // if you won't then router will lose track of current location and will pretend 121 | // that location didn't change 122 | router.run('/where-you-want-to-go', { /* query params object */}); 123 | ``` 124 | 125 | ### API 126 | 127 | - **`Router`**: 128 | - **`constructor(routes, history, onTransition, resolveOnLoad)`**: 129 | - **`routes`** (`RouteObject[]`) array of route objects (see below) 130 | - **`history`** (`HistoryObject`) history object (see below) 131 | - **`onTransition`** (`Function(error: ?Error, resolvedRoute: ?Object`) optional function called every time router resolves/rejects route 132 | - **`resolveOnLoad`** (`Boolean`) optional, should route onEnter handlers be called on initial load? (useful if page is rendered in node, so we don't want to run onEnter again) 133 | - **`wrapOnEnterHandler(wrapper)`**: 134 | - **`wrapper`** (`Function(Function)`): 135 | - wrapper is function receiving route onEnter handler and returning its result 136 | - can be used to decorate onEnter handler (e.g. passing some variables, etc) 137 | - it will be called with original handler bound to default arguments (see routeObject) as a first argument 138 | - `router.wrapOnEnterHandler((onEnter) => onEnter(someVar)` will append someVar to default onEnter argument list 139 | - **`wrapOnLeaveHandler(wrapper)`**: 140 | - **`wrapper`** (`Function(Function)`): 141 | - wrapper is function receiving route onLeave handler and returning its result 142 | - can be used to decorate onLeave handler (e.g. passing some variables, etc) 143 | - it will be called with original handler bound to default arguments (see routeObject) as a first argument 144 | - `router.wrapOnLeaveHandler((onLeave) => onLeave(someVar)` will append someVar to default onLeave argument list 145 | - **`createHref(pathname, query):String`** 146 | - **`pathname`** (`String`) - url pathname 147 | - **`query`** (`?Object.`) - optional query parameters 148 | - creates link 149 | - **`currentRoute():null|RouteObject`** returns current route 150 | - **`addChangeStartListener(listener:Function):Function`** - returns unsubscribe function 151 | - **`addChangeSuccessListener(listener:Function):Function`** - returns unsubscribe function 152 | - **`addChangeFailListener(listener:Function):Function`** - returns unsubscribe function 153 | - **`addNotFoundListener(listener:Function):Function`** - returns unsubscribe function 154 | - **`run(path, query):Promise`**: 155 | - **`path`** (`String`) - url pathname 156 | - **`query`** (`?Object.`) - optional query parameters 157 | - resolves route for given pathname 158 | - **`listen()`** - starts listening to history pop events (and will fire POPstate event immediately after `listen()` call 159 | 160 | - **`createRoutex(routes, history, onTransition, resolveOnLoad)`** 161 | - **`routes`** (`RouteObject[]`) array of RouteObject (see below) 162 | - **`history`** (`HistoryObject`) history object (see below) 163 | - **`onTransition`** (`Function(error: ?Error, resolvedRoute: ?Object`) optional function called every time router resolves/rejects route 164 | - **`resolveOnLoad`** (`Boolean`) optional, should route onEnter handlers be called on initial load? (useful if page is rendered in node, so we don't want to run onEnter again) 165 | - returns (`Object`) 166 | - **`store`** (`Function`) - high order store function 167 | - **`reducer`** (`{ router: Function }`) - object usable in `combineReducers` of `redux` 168 | 169 | - **`actions.transitionTo(pathname, query)`** 170 | - creates action, that routex store will try to transition to 171 | - **`path`** (`String`) - path without query string of new route 172 | - **`query`** (`Object.`) - optional, parsed query string parameters to object 173 | 174 | - **`RouteObject:`** (`Object`): 175 | - **`path`** (`String`) - route path (regexp will be created from it) 176 | - `/path:variable` 177 | - `/path/:variable` 178 | - `/path/:variable{\\d+}` - variable should be number 179 | - **`component`** (`Function|ReactElement`) ReactElement (optional)|Function:Promise` 180 | - returns ReactElement or `Function` returning `Promise` resolving to ReactElement 181 | - ReactElement is required only in case that you are using `` with React otherwise component can be anything you want 182 | - can be async, have to be a function returning a Promise otherwise it is sync 183 | - **`?children`** (`RouteObject[]`) 184 | - optional array of RouteObjects or function returning Promise (which resolves to array of RouteObjects) 185 | - **`?onEnter`** (`Function`) 186 | - optional route onEnter handler function 187 | - function used to determine if router can transition to this route (can be used as guard, or to load data needed for view to store) 188 | - **this function is called on popState (moving in browser history using back/forward buttons), on `` click or dispatching `transitionTo`** 189 | - function signature is `function (currentRoute, nextRoute, router):Promise` **if is used outside of createRoutex** 190 | - function signature is `function (currentRoute, nextRoute, router, dispatch, getState):Promise` **if is used by createRoutex, because it is wrapped** 191 | - **`currentRoute`** (`RouteObject|null`)` - current state of routex 192 | - **`nextRoute`** (`RouteObject`) - route we are transitioning to 193 | - **`router`**: (`Router`) - instance of router 194 | - returns **`Promise`** 195 | - if promise is resolved, transition will finish and changes the state of the router reducer 196 | - if promise is rejected, transition will finish but it won't change the state of the router reducer 197 | - **`?onLeave`** (`Function`) 198 | - optional route onLeave handler function 199 | - signature is same as in the `onEnter` 200 | - function used to determine if router can transition from this route (can be used as guard, ...) to a new route 201 | - **this function is called on popState (moving in browser history using back/forward buttons), on `` click or dispatching `transitionTo`** 202 | - **`?attrs`** (`Object`) 203 | - optional object of attributes assigned to route 204 | - is overridden by child attributes if have same key name 205 | 206 | - **`HistoryObject:`** (`Object`): 207 | - abstraction over browser history 208 | - **`listen`** (`Function(Function(LocationObject))`) - 209 | - method used to register history change events listeners (pop and push) 210 | - **`pushState`** (`Function(state, path)`) 211 | - pushes state for given path 212 | - **`state`** (`?Object`) - state stored for given path 213 | - **`path`** (`String)` - full path with query parameters 214 | - **`replaceState`** (`Function(state, path)`) 215 | - replaces current state with given state and path 216 | - **`state`** (`?Object`) - state stored for given path 217 | - **`path`** (`String)` - full path with query parameters 218 | 219 | - **`LocationObject:`** (`Object`): 220 | - abstraction over current location 221 | - **`action`** (`String`) - `POP` or `PUSH` 222 | - **`state`** (`?Object`) - current state of location 223 | - **`pathname`** (`String`) - pathname without query parameters 224 | - **`search`** (`String`) - search part of path (query parameters as string) 225 | 226 | ### React components 227 | 228 | #### `` Component 229 | 230 | Use this component whenever you want to render routes. This component needs `store` to be accessible in context. 231 | `` components can be nested, so you can use them in your own components (in case of nested routes) 232 | 233 | ``` 234 | // will render current route component (if route component renders too, it will render component of nested route 235 | ``` 236 | 237 | #### `` Component 238 | 239 | Use this component whenever you want an `` element to go to route. This component need `store` to be accessible in context. 240 | Internally this component is dispatching action `transitionTo()` 241 | 242 | - **Props**: 243 | - **`to`** (`String`) - url pathname to go to 244 | - **`query`** (`?Object.`) - optional, query parameters (will be add to `href` attribute) 245 | - **`stateProps`** (`?Object.>`) - properties for `active`, `inactive` state of `` 246 | - **`active`** (`?Object.`) - optional props to be assigned if `` `href` is active (matching current route) 247 | - **`inactive`** (`?Object.`) - optional props to be assigned if `` `href` is inactive (not matching current route) 248 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | pre: 3 | - "sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.9 20" 4 | - "sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-4.9 20" 5 | - "nvm install v4.1.1 && nvm alias default v4.1.1" 6 | dependencies: 7 | pre: 8 | - "npm install -g npm" 9 | test: 10 | override: 11 | - "npm run test" 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "routex", 3 | "version": "1.0.0-alpha.22", 4 | "description": "Simple router for Redux universal applications. Can be used with React too.", 5 | "main": "lib/index.js", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/michalkvasnicak/routex.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/michalkvasnicak/routex/issues" 13 | }, 14 | "homepage": "https://github.com/michalkvasnicak/routex", 15 | "keywords": [ 16 | "redux", 17 | "router", 18 | "react" 19 | ], 20 | "scripts": { 21 | "clean": "rimraf lib && rimraf dist", 22 | "build": "npm run clean && npm run build:node && npm run build:umd", 23 | "build:node": "babel src --out-dir lib", 24 | "build:umd": "npm run build:umd:routex && npm run build:umd:react", 25 | "build:umd:routex": "webpack src/index.js dist/routex.js && NODE_ENV=production webpack src/index.js dist/routex.min.js", 26 | "build:umd:react": "MODULENAME=react-routex webpack src/react/index.js dist/react-routex.js && NODE_ENV=production MODULE_NAME=react-routex webpack src/react/index.js dist/react-routex.min.js", 27 | "lint": "eslint -c .eslintrc src test", 28 | "test": "npm run lint && mocha --compilers js:babel/register --recursive" 29 | }, 30 | "peerDependencies": { 31 | "redux": ">=2.0.0", 32 | "react-redux": ">=2.0.0" 33 | }, 34 | "dependencies": { 35 | "qs": "^4.0.0", 36 | "invariant": "^2.1.0" 37 | }, 38 | "devDependencies": { 39 | "babel": "^5.5.8", 40 | "babel-core": "^5.6.15", 41 | "babel-eslint": "^3.1.15", 42 | "babel-loader": "^5.1.4", 43 | "eslint": "^1.0.0", 44 | "eslint-config-airbnb": "^1.0.0", 45 | "eslint-plugin-mocha": "^1.1.0", 46 | "eslint-plugin-react": "^3.13.1", 47 | "history": "^1.3.0", 48 | "chai": "^3.0.0", 49 | "chai-as-promised": "5.1.0", 50 | "jsdom": "~5.4.3", 51 | "mocha": "^2.2.5", 52 | "mocha-jsdom": "~0.4.0", 53 | "redux": "^3.0.0", 54 | "react": "^15.0.2", 55 | "react-addons-test-utils": "^15.0.2", 56 | "react-dom": "^15.0.2", 57 | "react-redux": "^4.0.0", 58 | "rimraf": "^2.3.4", 59 | "sinon": "^1.16.0", 60 | "webpack": "^1.10.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | update_version() { 4 | echo "$(node -p "p=require('./${1}');p.version='${2}';JSON.stringify(p,null,2)")" > $1 5 | echo "Updated ${1} version to ${2}" 6 | } 7 | 8 | validate_semver() { 9 | if ! [[ $1 =~ ^[0-9]\.[0-9]+\.[0-9](-.+)? ]]; then 10 | echo "Version $1 is not valid! It must be a valid semver string like 1.0.2 or 2.3.0-beta.1" 11 | exit 1 12 | fi 13 | } 14 | 15 | current_version=$(node -p "require('./package').version") 16 | 17 | printf "Next version (current is $current_version)? " 18 | read next_version 19 | 20 | validate_semver $next_version 21 | 22 | next_ref="v$next_version" 23 | 24 | npm test 25 | 26 | update_version 'package.json' $next_version 27 | 28 | npm run build 29 | git add -A src 30 | git add -A test 31 | 32 | git commit -am "Version $next_version" 33 | 34 | git tag $next_ref 35 | git tag latest -f 36 | 37 | git push origin master 38 | git push origin $next_ref 39 | git push origin latest -f 40 | 41 | npm publish 42 | -------------------------------------------------------------------------------- /src/Route.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import { buildMatcher, normalizeRouteDefinition } from './utils/routeUtils'; 3 | import { resolveWithFirstMatched } from './utils/routerUtils'; 4 | import { normalizeSlashes } from './utils/stringUtils'; 5 | import { 6 | NoRoutesToResolveError 7 | } from './errors'; 8 | import { createHref } from './utils/urlUtils'; 9 | 10 | /** 11 | * Resolves async routes and returns Promise which resolves to normalized definitions 12 | * 13 | * @param {Function} children 14 | * @returns {Promise} 15 | */ 16 | function resolveAsyncRoutes(children) { 17 | return new Promise((resolve, reject) => { 18 | const routes = children(); 19 | 20 | if (!(routes instanceof Promise)) { 21 | const type = typeof routes; 22 | 23 | reject( 24 | Error(`Async route definition resolvers should return a promise, ${type} given.`) 25 | ); 26 | } 27 | 28 | routes.then( 29 | (_routes) => { 30 | if (!Array.isArray(_routes)) { 31 | const type = typeof _routes; 32 | 33 | reject( 34 | Error(`Async route definition resolvers should resolve to array, ${type} given.`) 35 | ); 36 | } 37 | 38 | resolve(_routes); 39 | }, reject 40 | ); 41 | }); 42 | } 43 | 44 | /** 45 | * Resolves child routes (sync and async too) 46 | * 47 | * @param {Array|Function} children 48 | * @returns {Promise} 49 | */ 50 | function resolveChildRoutes(children) { 51 | function normalizeRoutes(routes, onError) { 52 | try { 53 | return routes.map((route) => { 54 | return normalizeRouteDefinition(route); 55 | }); 56 | } catch (e) { 57 | onError(e); 58 | } 59 | } 60 | 61 | return new Promise((resolve, reject) => { 62 | if (!Array.isArray(children)) { 63 | resolveAsyncRoutes(children).then( 64 | (routes) => resolve(normalizeRoutes(routes, reject)), 65 | reject 66 | ); 67 | } else { 68 | if (!children.length) { 69 | resolve([]); 70 | } else { 71 | resolve(normalizeRoutes(children, reject)); 72 | } 73 | } 74 | }); 75 | } 76 | 77 | export default class Route { 78 | constructor( 79 | path = '/', 80 | basePath = '/', 81 | children = [], 82 | onEnter, 83 | onLeave, 84 | component, 85 | attrs = {} 86 | ) { 87 | const pathType = typeof path; 88 | const basePathType = typeof basePath; 89 | const childrenType = typeof children; 90 | const onEnterType = typeof onEnter; 91 | const onLeaveType = typeof onLeave; 92 | 93 | invariant(pathType === 'string', `Route path should be string, ${pathType} given.`); 94 | invariant(basePathType === 'string', `Route base path should be string, ${basePathType} given.`); 95 | invariant( 96 | Array.isArray(children) || childrenType === 'function', 97 | `Route children should be an array or function, ${childrenType} given.` 98 | ); 99 | invariant( 100 | onEnterType === 'function', 101 | `Route handler \`onEnter\` should be a function, ${onEnterType} given.` 102 | ); 103 | invariant( 104 | onLeaveType === 'function', 105 | `Route handler \`onLeave\` should be a function, ${onLeaveType} given.` 106 | ); 107 | 108 | /** 109 | * Eager matcher for this route only 110 | * 111 | * @type {null|Function} 112 | */ 113 | this.matcher = null; 114 | 115 | /** 116 | * Non eager matcher for this route (will match this route + something more) 117 | * 118 | * @type {null|Function} 119 | */ 120 | this.childMatcher = null; 121 | 122 | this.path = path; 123 | 124 | this.basePath = basePath; 125 | 126 | this.onEnter = onEnter; 127 | 128 | this.onLeave = onLeave; 129 | 130 | this.component = component; 131 | 132 | this.children = children; 133 | 134 | this.attrs = attrs; 135 | } 136 | 137 | match(path, query) { 138 | return new Promise((resolve, reject) => { 139 | // lazy create matchers 140 | if (this.matcher === null) { 141 | const { eager, nonEager } = buildMatcher(this.path, this.basePath); 142 | 143 | this.matcher = eager; 144 | this.childMatcher = nonEager; 145 | } 146 | 147 | const instantiateRoutes = (routes) => { 148 | return routes.map((route) => { 149 | return new Route( 150 | route.path, 151 | normalizeSlashes(this.basePath + '/' + this.path), 152 | route.children, 153 | route.onEnter, 154 | route.onLeave, 155 | route.component, 156 | route.attrs 157 | ); 158 | }); 159 | }; 160 | 161 | 162 | // this resolves current path using eager regexp 163 | // in case children does not match 164 | const resolveOnlyCurrentRoute = () => { 165 | const match = this.matcher(path); 166 | 167 | if (match) { 168 | const { vars } = match; 169 | 170 | return resolve({ 171 | pathname: path, 172 | vars, 173 | query, 174 | fullPath: createHref(path, query), 175 | components: [this.component], 176 | onEnter: [this.onEnter], 177 | onLeave: [this.onLeave], 178 | attrs: this.attrs 179 | }); 180 | } 181 | 182 | return reject(); 183 | }; 184 | 185 | // this resolves current route only if child routes returned 186 | // NoRoutesToResolveError ( means children is empty ) 187 | const resolveOnlyCurrentIfNoError = (err) => { 188 | if (!err || (err instanceof NoRoutesToResolveError)) { 189 | resolveOnlyCurrentRoute(); 190 | } else { 191 | reject(err); 192 | } 193 | }; 194 | 195 | // if child matchers matches, try to match children first 196 | const childMatch = this.childMatcher(path); 197 | 198 | if (childMatch) { 199 | // resolve children routes 200 | resolveChildRoutes(this.children).then( 201 | (routes) => { 202 | try { 203 | this.children = instantiateRoutes(routes); 204 | 205 | // try to match children and resolve with first matched 206 | resolveWithFirstMatched(this.children, path, query).then( 207 | (match) => { 208 | const { vars, onEnter, onLeave, components, attrs } = match; 209 | 210 | resolve({ 211 | pathname: path, 212 | vars, 213 | query, 214 | fullPath: createHref(path, query), 215 | components: [this.component, ...components], 216 | onEnter: [this.onEnter, ...onEnter], 217 | onLeave: [this.onLeave, ...onLeave], 218 | attrs: { ...this.attrs, ...attrs } 219 | }); 220 | }, 221 | resolveOnlyCurrentIfNoError // this is called when children don't match 222 | ); 223 | } catch (e) { 224 | reject(e); 225 | } 226 | }, 227 | reject 228 | ); 229 | } else { 230 | resolveOnlyCurrentRoute(); 231 | } 232 | }); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/Router.js: -------------------------------------------------------------------------------- 1 | import Route from './Route'; 2 | import { normalizeRouteDefinition, runRouteHandlers, resolveComponents } from './utils/routeUtils'; 3 | import { resolveWithFirstMatched } from './utils/routerUtils'; 4 | import invariant from 'invariant'; 5 | import { RouteNotFoundError } from './errors'; 6 | import { createHref, parseQuery } from './utils/urlUtils'; 7 | 8 | function instantiateRoutes(routes) { 9 | return routes.map((definition) => { 10 | const normalized = normalizeRouteDefinition(definition); 11 | 12 | return new Route( 13 | normalized.path, 14 | undefined, 15 | normalized.children, 16 | normalized.onEnter, 17 | normalized.onLeave, 18 | normalized.component, 19 | normalized.attrs 20 | ); 21 | }); 22 | } 23 | 24 | const REPLACE_STATE = 'replace'; 25 | const PUSH_STATE = 'push'; 26 | const DO_NOTHING = 'nope'; 27 | 28 | export default class Router { 29 | constructor( 30 | routes = [], 31 | history, 32 | onTransition = function transitionFinished() {} 33 | ) { 34 | invariant(Array.isArray(routes), `Routes should be an array, ${typeof routes} given.`); 35 | invariant( 36 | typeof onTransition === 'function', 37 | `Router onTransition callback should be a function, ${typeof onTransition} given.` 38 | ); 39 | 40 | this.routes = instantiateRoutes(routes); 41 | 42 | // enable queries means that query parameters can be used directly as objects 43 | this.history = history; 44 | 45 | this.onTransition = onTransition || function transitionFinished() {}; 46 | 47 | this.listeners = { 48 | changeStart: [], 49 | changeSuccess: [], 50 | changeFail: [], 51 | notFound: [] 52 | }; 53 | 54 | this.handlerWrappers = { 55 | onEnter(onEnter) { 56 | return onEnter(); 57 | }, 58 | onLeave(onLeave) { 59 | return onLeave(); 60 | } 61 | }; 62 | 63 | this._currentRoute = null; 64 | } 65 | 66 | listen() { 67 | // listen to popState event 68 | this.history.listen(this._handleChange.bind(this)); 69 | } 70 | 71 | _handleChange(location) { 72 | if (location.action === 'POP') { 73 | // on handle pop state (we are moving in history) 74 | const path = location.pathname; 75 | const query = parseQuery(location.search); 76 | 77 | this.run(path, query, !!location.state ? DO_NOTHING : REPLACE_STATE); 78 | } 79 | } 80 | 81 | currentRoute() { 82 | return this._currentRoute; 83 | } 84 | 85 | _wrapRouteHandler(name, wrapper) { 86 | invariant( 87 | typeof wrapper === 'function', 88 | `${name} handler wrapper should be a function, ${typeof wrapper} given.` 89 | ); 90 | 91 | this.handlerWrappers[name] = wrapper; 92 | } 93 | 94 | _callEventListeners(name, ...args) { 95 | this.listeners[name].forEach((listener) => listener(...args)); 96 | } 97 | 98 | _registerEventListener(name, listener) { 99 | invariant( 100 | typeof listener === 'function', 101 | `${name} event listener should be function, ${typeof listener} given.` 102 | ); 103 | 104 | const listeners = this.listeners[name]; 105 | 106 | listeners.push(listener); 107 | 108 | return function unsubscribe() { 109 | const index = listeners.indexOf(listener); 110 | listeners.splice(index); 111 | }; 112 | } 113 | 114 | _rejectTransition(reason) { 115 | const err = new Error(reason); 116 | 117 | return (parentErr) => { 118 | const e = parentErr || err; 119 | this._callEventListeners('changeFail', e, this._currentRoute, this); 120 | this.onTransition(e); 121 | 122 | throw err; 123 | }; 124 | } 125 | 126 | /** 127 | * Finishes run route resolving 128 | * 129 | * @param {Object} resolvedRoute 130 | * @param {String} path 131 | * @param {Object} query 132 | * @param {String} action 133 | * @returns {Object} 134 | * @private 135 | */ 136 | _finishRun(resolvedRoute, path, query, action) { 137 | this._currentRoute = resolvedRoute; 138 | this._callEventListeners('changeSuccess', resolvedRoute); 139 | 140 | /* eslint-disable default-case */ 141 | switch (action) { 142 | case PUSH_STATE: 143 | this.history.pushState(resolvedRoute, createHref(path, query)); 144 | break; 145 | case REPLACE_STATE: 146 | this.history.replaceState( 147 | resolvedRoute, 148 | createHref(path, query) 149 | ); 150 | break; 151 | } 152 | /* eslint-enable default-case */ 153 | 154 | this.onTransition(null, resolvedRoute); 155 | 156 | return resolvedRoute; 157 | } 158 | 159 | addChangeStartListener(listener) { 160 | return this._registerEventListener('changeStart', listener); 161 | } 162 | 163 | addChangeSuccessListener(listener) { 164 | return this._registerEventListener('changeSuccess', listener); 165 | } 166 | 167 | addChangeFailListener(listener) { 168 | return this._registerEventListener('changeFail', listener); 169 | } 170 | 171 | addNotFoundListener(listener) { 172 | return this._registerEventListener('notFound', listener); 173 | } 174 | 175 | /** 176 | * Wraps route onEnter handler 177 | * 178 | * @param {Function} handler 179 | */ 180 | wrapOnEnterHandler(handler) { 181 | this._wrapRouteHandler('onEnter', handler); 182 | } 183 | 184 | /** 185 | * Wraps route onLeave handler 186 | * 187 | * @param {Function} handler 188 | */ 189 | wrapOnLeaveHandler(handler) { 190 | this._wrapRouteHandler('onLeave', handler); 191 | } 192 | 193 | /** 194 | * Starts router transition 195 | * 196 | * @param {String} path 197 | * @param {Object} query 198 | * @param {String} action 199 | * @returns {Promise} 200 | */ 201 | run(path, query = {}, action = PUSH_STATE) { 202 | const runResolvedRoute = (resolvedRoute) => { 203 | const currentRoute = this._currentRoute; 204 | this._callEventListeners('changeStart', currentRoute, resolvedRoute, this); 205 | 206 | const handlerWrappers = this.handlerWrappers; 207 | 208 | // call on leave in order (so we can cancel transition) 209 | return runRouteHandlers('onLeave', currentRoute, handlerWrappers, resolvedRoute, this).then( 210 | () => runRouteHandlers('onEnter', resolvedRoute, handlerWrappers, currentRoute, resolvedRoute, this).then( 211 | () => resolveComponents(resolvedRoute.components).then( 212 | (components) => { 213 | return this._finishRun({ ...resolvedRoute, components }, path, query, action); 214 | }, 215 | this._rejectTransition('Route components cannot be resolved') 216 | ), 217 | this._rejectTransition('Route onEnter handlers are rejected.') 218 | ), 219 | this._rejectTransition('Current route onLeave handlers are rejected.') 220 | ); 221 | }; 222 | 223 | const notFound = () => { 224 | const err = new RouteNotFoundError('Route not found'); 225 | this._callEventListeners('notFound', path, query); 226 | this.onTransition(err); 227 | 228 | throw err; 229 | }; 230 | 231 | return resolveWithFirstMatched(this.routes, path, query).then( 232 | runResolvedRoute, 233 | notFound 234 | ); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const ROUTE_CHANGE_START = '@@ROUTEX/ROUTE_CHANGE_START'; 2 | export const ROUTE_CHANGE_SUCCESS = '@@ROUTEX/ROUTE_CHANGE_SUCCESS'; 3 | export const ROUTE_CHANGE_FAIL = '@@ROUTEX/ROUTE_CHANGE_FAIL'; 4 | export const ROUTE_NOT_FOUND = '@@ROUTEX/ROUTE_NOT_FOUND'; 5 | export const TRANSITION_TO = '@@ROUTEX/TRANSITION_TO'; 6 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | ROUTE_CHANGE_START, 3 | ROUTE_CHANGE_SUCCESS, 4 | ROUTE_CHANGE_FAIL, 5 | ROUTE_NOT_FOUND, 6 | TRANSITION_TO 7 | } from './actionTypes'; 8 | 9 | 10 | export function changeStart(currentRoute, nextRoute) { 11 | return { 12 | type: ROUTE_CHANGE_START, 13 | route: currentRoute, 14 | nextRoute: nextRoute 15 | }; 16 | } 17 | 18 | export function changeSuccess(currentRoute) { 19 | return { 20 | type: ROUTE_CHANGE_SUCCESS, 21 | route: currentRoute 22 | }; 23 | } 24 | 25 | export function changeFail(currentRoute, error) { 26 | return { 27 | type: ROUTE_CHANGE_FAIL, 28 | route: currentRoute, 29 | error: error 30 | }; 31 | } 32 | 33 | export function notFound(path, query) { 34 | return { 35 | type: ROUTE_NOT_FOUND, 36 | path, 37 | query 38 | }; 39 | } 40 | 41 | export function transitionTo(path, query = {}) { 42 | return { 43 | type: TRANSITION_TO, 44 | pathname: path, 45 | query: query 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/createRoutex.js: -------------------------------------------------------------------------------- 1 | import Router from './Router'; 2 | 3 | import { 4 | ROUTE_CHANGE_START, 5 | ROUTE_CHANGE_FAIL, 6 | ROUTE_CHANGE_SUCCESS, 7 | TRANSITION_TO 8 | } from './actionTypes'; 9 | 10 | import { 11 | changeSuccess, 12 | changeFail, 13 | notFound, 14 | changeStart 15 | } from './actions'; 16 | 17 | /** 18 | * Creates routex instance and returns store, reducer and router insance 19 | * 20 | * @param {Array} routes 21 | * @param {Object} history 22 | * @param {?Function} onTransition 23 | * @returns {{router: Router, store: store, reducer: {router: reducer}}} 24 | */ 25 | export default function createRoutex(routes, history, onTransition) { 26 | const initialReducerState = { state: 'INITIAL', route: null }; 27 | 28 | const router = new Router(routes, history, onTransition); 29 | 30 | const store = (next) => (reducer, initialState) => { 31 | const modifiedInitialState = initialState; 32 | 33 | // reset state of reducer to be initial because components cannot be rehydrated immediately 34 | // because we need to wait for initial router.run 35 | if (typeof initialState === 'object' && initialState !== null && initialState.hasOwnProperty('router')) { 36 | modifiedInitialState.router = initialReducerState; 37 | } 38 | 39 | const nextStore = next(reducer, modifiedInitialState); 40 | 41 | /** 42 | * Dispatch function of this store 43 | * 44 | * @param {*} action 45 | * @returns {*} 46 | */ 47 | function dispatch(action) { 48 | if (typeof action !== 'object' || !action.hasOwnProperty('type') || action.type !== TRANSITION_TO) { 49 | return nextStore.dispatch(action); 50 | } 51 | 52 | return router.run(action.pathname, action.query); 53 | } 54 | 55 | // register listeners 56 | router.addChangeStartListener((currentRoute, resolvedRoute/* , router*/) => { 57 | nextStore.dispatch(changeStart(currentRoute, resolvedRoute)); 58 | }); 59 | 60 | router.addChangeSuccessListener((resolvedRoute) => { 61 | nextStore.dispatch(changeSuccess(resolvedRoute)); 62 | }); 63 | 64 | router.addChangeFailListener((error, previousRoute/* , router*/) => { 65 | nextStore.dispatch(changeFail(previousRoute, error)); 66 | }); 67 | 68 | router.addNotFoundListener((path, query) => { 69 | nextStore.dispatch(notFound(path, query)); 70 | }); 71 | 72 | // wrap handlers 73 | router.wrapOnEnterHandler((onEnter) => { 74 | return onEnter(dispatch, nextStore.getState); 75 | }); 76 | 77 | router.wrapOnLeaveHandler((onLeave) => { 78 | return onLeave(dispatch, nextStore.getState); 79 | }); 80 | 81 | // initial run of router 82 | // this is not needed because history.listen will be called with location 83 | // so pop state event will be handled 84 | // router.run(history.pathname(), history.query()); 85 | router.listen(); // register popState listener 86 | 87 | return { 88 | ...nextStore, 89 | dispatch, 90 | router 91 | }; 92 | }; 93 | 94 | const reducer = (state = initialReducerState, action) => { 95 | switch (action.type) { 96 | case ROUTE_CHANGE_START: 97 | return { 98 | state: 'TRANSITIONING', 99 | nextRoute: action.nextRoute, 100 | route: state.route 101 | }; 102 | case ROUTE_CHANGE_SUCCESS: 103 | return { 104 | state: 'TRANSITIONED', 105 | route: action.route 106 | }; 107 | case ROUTE_CHANGE_FAIL: 108 | return { 109 | state: 'TRANSITIONED', 110 | route: action.route, // will be set to previous route 111 | error: action.error 112 | }; 113 | /* 114 | todo: not found make as only action which can user listen to and make redirects? 115 | case 'ROUTE_NOT_FOUND': 116 | return { 117 | state: 'TRANSITIONED', 118 | route: action.route // set to previous route 119 | };*/ 120 | default: 121 | return state; 122 | } 123 | }; 124 | 125 | return { 126 | router, 127 | store, 128 | reducer: { router: reducer } 129 | }; 130 | } 131 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | export class NoRoutesToResolveError extends Error {} 2 | export class RouteNotFoundError extends Error {} 3 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes'; 2 | import * as actions from './actions'; 3 | import Router from './Router'; 4 | import createRoutex from './createRoutex'; 5 | 6 | export { 7 | actionTypes, 8 | actions, 9 | createRoutex, 10 | Router 11 | }; 12 | -------------------------------------------------------------------------------- /src/react/Link.js: -------------------------------------------------------------------------------- 1 | import { transitionTo } from '../actions'; 2 | import { createHref } from '../utils/urlUtils'; 3 | import Router from '../Router'; 4 | 5 | export default function createLink(React, connect) { 6 | const { Component, PropTypes } = React; 7 | 8 | class Link extends Component { 9 | shouldComponentUpdate(nextProps) { 10 | return nextProps.router.state === 'TRANSITIONED'; 11 | } 12 | 13 | handleClick(e) { 14 | e.preventDefault(); 15 | 16 | this.context.store.dispatch( 17 | transitionTo( 18 | this.props.to, 19 | this.props.query 20 | ) 21 | ); 22 | } 23 | 24 | render() { 25 | const { to, query, router, stateProps, ...props } = this.props; 26 | const href = createHref(to, query); 27 | const { state, route } = router; 28 | let newProps = props; 29 | 30 | if (state === 'TRANSITIONED' && stateProps && route) { 31 | let matches = href === route.pathname; 32 | 33 | if (!matches) { 34 | if (href === '/') { 35 | matches = true; 36 | } else if (href.length < route.pathname.length) { 37 | matches = (new RegExp(`^(${href}|${href}/.*)$`)).test(route.pathname); 38 | } 39 | } 40 | 41 | newProps = { 42 | ...props, 43 | ...(stateProps[matches ? 'active' : 'inactive'] || {}) 44 | }; 45 | } 46 | 47 | return ( 48 | 52 | {this.props.children} 53 | 54 | ); 55 | } 56 | } 57 | 58 | Link.propTypes = { 59 | to: PropTypes.string.isRequired, 60 | query: PropTypes.object, 61 | children: PropTypes.oneOfType([PropTypes.element, PropTypes.array]), 62 | stateProps: PropTypes.shape({ 63 | active: PropTypes.object, 64 | inactive: PropTypes.object 65 | }), 66 | router: PropTypes.object.isRequired 67 | }; 68 | 69 | Link.contextTypes = { 70 | store: PropTypes.shape({ 71 | dispatch: PropTypes.func.isRequired, 72 | router: PropTypes.instanceOf(Router).isRequired 73 | }).isRequired 74 | }; 75 | 76 | return connect( 77 | (state) => { 78 | return { 79 | router: state.router 80 | }; 81 | } 82 | )(Link); 83 | } 84 | -------------------------------------------------------------------------------- /src/react/View.js: -------------------------------------------------------------------------------- 1 | export default function createView(React, connect) { 2 | const { Component, PropTypes, isValidElement } = React; 3 | 4 | class View extends Component { 5 | render() { 6 | const { state, route, ...props } = this.props; 7 | 8 | if (state === 'INITIAL' || !route || !route.components) { 9 | return null; 10 | } 11 | 12 | return route.components.reduceRight((component, parent) => { 13 | if (component === null) { 14 | return React.createElement(parent, props); 15 | } 16 | 17 | const child = isValidElement(component) ? component : React.createElement(component, props); 18 | 19 | return React.createElement(parent, props, child); 20 | }, null); 21 | } 22 | } 23 | 24 | View.propTypes = { 25 | state: PropTypes.oneOf(['INITIAL', 'TRANSITIONING', 'TRANSITIONED']).isRequired, 26 | route: PropTypes.shape({ 27 | pathname: PropTypes.string.isRequired, 28 | query: PropTypes.object.isRequired, 29 | vars: PropTypes.object.isRequired, 30 | components: PropTypes.array.isRequired 31 | }) 32 | }; 33 | 34 | View.contextTypes = { 35 | store: PropTypes.object.isRequired 36 | }; 37 | 38 | return connect((state) => state.router)(View); 39 | } 40 | -------------------------------------------------------------------------------- /src/react/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import createLink from './Link'; 4 | import createView from './View'; 5 | 6 | export default { 7 | Link: createLink(React, connect), 8 | View: createView(React, connect) 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/routeUtils.js: -------------------------------------------------------------------------------- 1 | import { normalizeSlashes, trimSlashesFromPathEnd } from './stringUtils'; 2 | import invariant from 'invariant'; 3 | 4 | /** 5 | * Reduce promises 6 | * 7 | * @param {Function} fn 8 | * @param {*} start 9 | * @returns {Function} 10 | */ 11 | function reduce(fn, start) { 12 | return (val) => { 13 | const values = Array.isArray(val) ? val : [val]; 14 | 15 | return values.reduce((promise, curr) => { 16 | return promise.then((prev) => { 17 | return fn(prev, curr); 18 | }); 19 | }, Promise.resolve(start)); 20 | }; 21 | } 22 | 23 | /** 24 | * Builds path matcher 25 | * 26 | * @param {string} pathPattern 27 | * @param {string} basePath 28 | * 29 | * @returns {{ eager: Function, nonEager: Function }} 30 | */ 31 | export function buildMatcher(pathPattern, basePath = '/') { 32 | // first find all variables 33 | let pathRegexp; 34 | const variableNames = []; 35 | const variablePatterns = []; 36 | 37 | // normalize slashes, trim slashes from end 38 | // and parse path pattern to variable names, etc 39 | pathRegexp = normalizeSlashes(basePath + '/' + pathPattern); 40 | pathRegexp = trimSlashesFromPathEnd(pathRegexp); 41 | 42 | pathRegexp = pathRegexp.replace(/:([a-zA-Z]+)({([^:]+)})?/g, (match, variableName, _, variablePattern) => { 43 | if (variableNames.indexOf(variableName) !== -1) { 44 | throw Error(`Route parameter \`${variableName}\` is already defined.`); 45 | } 46 | 47 | if (variableName) { 48 | variableNames.push(variableName); 49 | } 50 | 51 | const pattern = variablePattern || '[^/]+'; 52 | 53 | variablePatterns.push(pattern); 54 | 55 | return `(${pattern})`; 56 | }); 57 | 58 | pathRegexp += '/?'; 59 | 60 | /** 61 | * Creates matcher for route path 62 | * 63 | * @param {string } pattern 64 | * @param {bool} eager should matcher be eager? 65 | * @returns {Function} 66 | */ 67 | function createMatcher(pattern, eager) { 68 | return function matcher(path) { 69 | const matched = path.match(new RegExp(`^${pattern}${eager ? '$' : '.*$'}`, 'i')); 70 | 71 | if (!matched || !matched.length) { 72 | return false; 73 | } 74 | 75 | const vars = {}; 76 | let indexInMatch = 1; 77 | 78 | variableNames.forEach((name, index) => { 79 | const start = variablePatterns[index][0]; 80 | const end = variablePatterns[index].slice(-1); 81 | 82 | if (start === '(' && end === ')') { 83 | vars[name] = matched[indexInMatch]; 84 | indexInMatch += 2; // skip nested group 85 | return; 86 | } 87 | 88 | vars[name] = matched[indexInMatch++]; 89 | }); 90 | 91 | return { 92 | matched, 93 | vars 94 | }; 95 | }; 96 | } 97 | 98 | return { 99 | eager: createMatcher(pathRegexp, true), 100 | nonEager: createMatcher(pathRegexp, false) 101 | }; 102 | } 103 | 104 | 105 | /** 106 | * Normalizes route definition object (validates it and sets default values) 107 | * 108 | * @param {Object} definition 109 | * @returns {{path: *, children: (*|Array), onEnter: (*|Function), onLeave: (*|Function), component: (*|{}), attrs: ({})}} 110 | */ 111 | export function normalizeRouteDefinition(definition) { 112 | const definitionType = typeof definition; 113 | 114 | invariant( 115 | typeof definition === 'object' && definition !== null, 116 | `Route definition should be plain object, ${definitionType} given.` 117 | ); 118 | 119 | invariant( 120 | definition.hasOwnProperty('path'), 121 | `Route definition should have \`path\` property.` 122 | ); 123 | 124 | const noop = () => { return Promise.resolve(); }; 125 | 126 | return { 127 | path: definition.path, 128 | children: definition.children || [], 129 | onEnter: definition.onEnter || noop, 130 | onLeave: definition.onLeave || noop, 131 | component: definition.component || null, 132 | attrs: definition.attrs || {} 133 | }; 134 | } 135 | 136 | /* eslint-disable consistent-return */ 137 | export function runRouteHandlers(handlers, route, wrappers = [], ...args) { 138 | // if current route is not defined, resolve immediately 139 | // this will prevent calling onLeave on initial load, because we don't have previous route 140 | if (!route) { 141 | return Promise.resolve(); 142 | } 143 | 144 | // runs route handler bound to given arguments (from our code) 145 | // wrapper can call it with additional parameters 146 | const runWrappedHandler = (originalHandler, originalProps, wrapper) => { 147 | return wrapper((...fromWrapper) => originalHandler(...originalProps, ...fromWrapper)); 148 | }; 149 | 150 | // create handlers runner 151 | const composedHandlers = reduce( 152 | (acc, current) => { 153 | try { 154 | const result = runWrappedHandler(current, args, wrappers[handlers]); 155 | 156 | if (result && typeof result.then === 'function') { 157 | return result.then(res => { 158 | acc.push(res); 159 | 160 | return acc; 161 | }); 162 | } 163 | 164 | acc.push(result); 165 | 166 | return Promise.resolve(acc); 167 | } catch (e) { 168 | return Promise.reject(e); 169 | } 170 | }, [] 171 | ); 172 | 173 | const routeHandlers = route[handlers]; 174 | 175 | // if running onEnter, run handlers from parent to child 176 | // if onLeave, run them from child to parent 177 | return composedHandlers( 178 | handlers === 'onEnter' ? routeHandlers : routeHandlers.reverse() 179 | ); 180 | } 181 | /* eslint-enable consistent-return */ 182 | 183 | export function resolveComponents(components) { 184 | if (!Array.isArray(components)) { 185 | return Promise.resolve([]); 186 | } 187 | 188 | // go through components and if function, call it 189 | return Promise.all( 190 | components.map((component) => { 191 | if (typeof component === 'function') { 192 | try { 193 | // if is react class, it throws error 194 | const result = component(); 195 | 196 | if (typeof result.then === 'function') { 197 | return result; 198 | } 199 | 200 | return component; 201 | } catch (e) { 202 | return component; 203 | } 204 | } 205 | 206 | return component; 207 | }) 208 | ); 209 | } 210 | -------------------------------------------------------------------------------- /src/utils/routerUtils.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant'; 2 | import Route from '../Route'; 3 | import { 4 | NoRoutesToResolveError 5 | } from '../errors'; 6 | 7 | /** 8 | * Returns first resolved route, if none resolve, rejects 9 | * 10 | * Routes are resolved in order 11 | * 12 | * @param {Array} routes 13 | * @param {string} path 14 | * @param {query} query 15 | * @returns {Promise} 16 | */ 17 | export function resolveWithFirstMatched(routes = [], path, query) { 18 | invariant(Array.isArray(routes), `Routes should be an array, ${typeof routes} given.`); 19 | 20 | function runAndResolveOnFirstResolved(promises, _resolve, _reject, currentIndex = 0) { 21 | const route = promises[currentIndex]; 22 | 23 | invariant( 24 | route instanceof Route, 25 | `Routes should contain only Route objects, ${typeof route} given at index ${currentIndex}` 26 | ); 27 | 28 | const result = route.match(path, query); 29 | 30 | result.then( 31 | _resolve, 32 | (err) => { 33 | if (currentIndex === routes.length - 1) { 34 | _reject(err); 35 | } else { 36 | runAndResolveOnFirstResolved(promises, _resolve, _reject, currentIndex + 1); 37 | } 38 | } 39 | ); 40 | } 41 | 42 | return new Promise((resolve, reject) => { 43 | // call routes in order 44 | if (!routes.length) { 45 | return reject(new NoRoutesToResolveError('No routes to resolve')); 46 | } 47 | 48 | return runAndResolveOnFirstResolved(routes, resolve, reject); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/stringUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Trims slashes from path end 3 | * 4 | * @param {string} path 5 | * @returns {string} 6 | */ 7 | export function trimSlashesFromPathEnd(path) { 8 | return path.replace(/(\/)$/, ''); 9 | } 10 | 11 | /** 12 | * Normalizes occurrences of multiple slashes in one place to just one slash 13 | * 14 | * @param {string} path 15 | * @returns {string} 16 | */ 17 | export function normalizeSlashes(path) { 18 | return path.replace(/(\/)+\//g, '/'); 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/urlUtils.js: -------------------------------------------------------------------------------- 1 | import { parse as _parseQuery, stringify as _stringifyQuery } from 'qs'; 2 | 3 | /** 4 | * Parses query 5 | * 6 | * @param {String} search 7 | * @returns {Object.} 8 | */ 9 | export function parseQuery(search) { 10 | if (/^\?/.test(search)) { 11 | return _parseQuery(search.substring(1)); 12 | } 13 | 14 | return {}; 15 | } 16 | 17 | /** 18 | * Stringifies query 19 | * 20 | * @param {Object.} query 21 | * @returns {String} 22 | */ 23 | export function stringifyQuery(query = {}) { 24 | return _stringifyQuery(query, { arrayFormat: 'brackets' }); 25 | } 26 | 27 | /** 28 | * Creates href 29 | * 30 | * @param {String} path 31 | * @param {Object.} query 32 | * @returns {String} 33 | */ 34 | export function createHref(path, query = {}) { 35 | // if path contains ? strip it 36 | const match = path.match(/^([^?]*)(\?.*)?$/); 37 | 38 | let url = `${match[1]}`; 39 | let queryParams = match[2] ? parseQuery(match[2]) : {}; 40 | 41 | // merge with query 42 | queryParams = { ...queryParams, ...query }; 43 | 44 | // stringify params only if query contains something 45 | if (Object.keys(queryParams).length) { 46 | url += `?${stringifyQuery(queryParams)}`; 47 | } 48 | 49 | return url; 50 | } 51 | -------------------------------------------------------------------------------- /test/Route.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import Route from '../src/Route'; 3 | 4 | describe('Route', () => { 5 | describe('#constructor()', () => { 6 | it('throws if path is not an string', () => { 7 | [true, 1, 1.0, null].forEach((path) => { 8 | const type = typeof path; 9 | 10 | expect(() => new Route(path)).to.throw( 11 | `Route path should be string, ${type} given.` 12 | ); 13 | }); 14 | }); 15 | 16 | it('throws if path is not an string', () => { 17 | [true, 1, 1.0, null].forEach((path) => { 18 | const type = typeof path; 19 | 20 | expect(() => new Route(path)).to.throw( 21 | `Route path should be string, ${type} given.` 22 | ); 23 | }); 24 | }); 25 | 26 | it('throws if base path is not an string', () => { 27 | [true, 1, 1.0, null].forEach((path) => { 28 | const type = typeof path; 29 | 30 | expect(() => new Route('/', path)).to.throw( 31 | `Route base path should be string, ${type} given.` 32 | ); 33 | }); 34 | }); 35 | 36 | it('throws if route children is not an array or function', () => { 37 | [true, 1].forEach((children) => { 38 | const type = typeof children; 39 | 40 | expect(() => new Route('/', '', children)).to.throw( 41 | `Route children should be an array or function, ${type} given.` 42 | ); 43 | }); 44 | }); 45 | 46 | it('throws if route onEnter handler is not an function', () => { 47 | expect(() => new Route('/', '', [], 'a')).to.throw( 48 | `Route handler \`onEnter\` should be a function, string given.` 49 | ); 50 | }); 51 | 52 | it('throws if route onLeave handler is not an function', () => { 53 | expect(() => new Route('/', '', [], () => {}, 'a')).to.throw( 54 | `Route handler \`onLeave\` should be a function, string given.` 55 | ); 56 | }); 57 | }); 58 | 59 | describe('#match()', () => { 60 | it('rejects if async route definition does not return Promise or array', (done) => { 61 | const steps = [true, 1, 'a', null]; 62 | 63 | const stepper = (step, doneFn) => { 64 | if (step === steps.length) { 65 | return doneFn(); 66 | } 67 | 68 | const asyncRoutes = () => steps[step]; 69 | const route = new Route('/', '', asyncRoutes, () => {}, () => {}); 70 | 71 | route.match('/test').then( 72 | doneFn.bind(this, Error('Route should reject for value of type ' + typeof steps[step])), 73 | () => stepper(step + 1, doneFn) 74 | ); 75 | }; 76 | 77 | stepper(0, done); 78 | }); 79 | 80 | it('rejects if route contains multiple variables of the same name', () => { 81 | const asyncRoutes = () => { 82 | return Promise.resolve([ 83 | { 84 | path: '/:variable', 85 | component: 'b' 86 | } 87 | ]); 88 | }; 89 | const route = new Route('/:variable', '', asyncRoutes, () => {}, () => {}, 'a'); 90 | 91 | return route.match('/test/test').catch((err) => { 92 | expect(err).to.be.eql(Error('Route parameter `variable` is already defined.')); 93 | }); 94 | }); 95 | 96 | it('resolves simple route without children', () => { 97 | const onEnter = () => {}; 98 | const onLeave = () => {}; 99 | const eagerlyMatchedRoute = new Route('/', '', [], onEnter, onLeave, 'a'); 100 | 101 | return eagerlyMatchedRoute.match('/').then( 102 | (match) => { 103 | expect(match).be.an('object'); 104 | expect(match) 105 | .to.have.property('pathname') 106 | .and.to.be.equal('/'); 107 | expect(match) 108 | .to.have.property('fullPath') 109 | .and.to.be.equal('/'); 110 | expect(match) 111 | .to.have.property('vars') 112 | .and.to.be.deep.equal({}); 113 | expect(match) 114 | .to.have.property('onEnter') 115 | .and.to.be.an('array') 116 | .and.to.be.deep.equal([onEnter]); 117 | expect(match) 118 | .to.have.property('onLeave') 119 | .and.to.be.an('array') 120 | .and.to.be.deep.equal([onLeave]); 121 | expect(match) 122 | .to.have.property('components') 123 | .and.to.be.an('array') 124 | .and.to.be.deep.equal(['a']); 125 | expect(match) 126 | .to.have.property('attrs') 127 | .and.to.be.an('object') 128 | .and.to.be.deep.equal({}); 129 | } 130 | ); 131 | }); 132 | 133 | it('resolves complex route with multiple variables', () => { 134 | const onEnter = () => {}; 135 | const onLeave = () => {}; 136 | const eagerlyMatchedRoute = new Route('/:from-:to', '', [], onEnter, onLeave, 'a'); 137 | 138 | return eagerlyMatchedRoute.match('/10-11').then( 139 | (match) => { 140 | expect(match).be.an('object'); 141 | expect(match) 142 | .to.have.property('vars') 143 | .and.to.be.deep.equal({ 144 | from: '10', 145 | to: '11' 146 | }); 147 | expect(match) 148 | .to.have.property('pathname') 149 | .and.to.be.equal('/10-11'); 150 | expect(match) 151 | .to.have.property('fullPath') 152 | .and.to.be.equal('/10-11'); 153 | expect(match) 154 | .to.have.property('onEnter') 155 | .and.to.be.an('array') 156 | .and.to.be.deep.equal([onEnter]); 157 | expect(match) 158 | .to.have.property('onLeave') 159 | .and.to.be.an('array') 160 | .and.to.be.deep.equal([onLeave]); 161 | expect(match) 162 | .to.have.property('components') 163 | .and.to.be.an('array') 164 | .and.to.be.deep.equal(['a']); 165 | expect(match) 166 | .to.have.property('attrs') 167 | .and.to.be.an('object') 168 | .and.to.be.deep.equal({}); 169 | } 170 | ); 171 | }); 172 | 173 | it('resolves complex route with multiple variables (patterns)', () => { 174 | const onEnter = () => {}; 175 | const onLeave = () => {}; 176 | const eagerlyMatchedRoute = new Route('/:from{[0-9]+}-:to{[a-z]+}', '', [], onEnter, onLeave, 'a', { a: true }); 177 | 178 | return eagerlyMatchedRoute.match('/10-a').then( 179 | (match) => { 180 | expect(match).be.an('object'); 181 | expect(match) 182 | .to.have.property('vars') 183 | .and.to.be.deep.equal({ 184 | from: '10', 185 | to: 'a' 186 | }); 187 | expect(match) 188 | .to.have.property('pathname') 189 | .and.to.be.equal('/10-a'); 190 | expect(match) 191 | .to.have.property('fullPath') 192 | .and.to.be.equal('/10-a'); 193 | expect(match) 194 | .to.have.property('onEnter') 195 | .and.to.be.an('array') 196 | .and.to.be.deep.equal([onEnter]); 197 | expect(match) 198 | .to.have.property('onLeave') 199 | .and.to.be.an('array') 200 | .and.to.be.deep.equal([onLeave]); 201 | expect(match) 202 | .to.have.property('components') 203 | .and.to.be.an('array') 204 | .and.to.be.deep.equal(['a']); 205 | expect(match) 206 | .to.have.property('attrs') 207 | .and.to.be.an('object') 208 | .and.to.be.deep.equal({ a: true }); 209 | } 210 | ); 211 | }); 212 | 213 | it('resolves complex route with complex children', () => { 214 | const onEnter = () => {}; 215 | const onLeave = () => {}; 216 | const children = [ 217 | { 218 | path: '/detail/:id{[a-zA-Z0-9]+}-:slug', 219 | component: 'b', 220 | onEnter, 221 | onLeave, 222 | attrs: { 223 | a: false, 224 | b: true 225 | } 226 | } 227 | ]; 228 | const eagerlyMatchedRoute = new Route('/:lang{(en|de)}', '', children, onEnter, onLeave, 'a', { a: true }); 229 | 230 | return eagerlyMatchedRoute.match('/en/detail/565ee0d31709ae7b174eb8a1-test').then( 231 | (match) => { 232 | expect(match).be.an('object'); 233 | expect(match) 234 | .to.have.property('vars') 235 | .and.to.be.deep.equal({ 236 | lang: 'en', 237 | id: '565ee0d31709ae7b174eb8a1', 238 | slug: 'test' 239 | }); 240 | expect(match) 241 | .to.have.property('pathname') 242 | .and.to.be.equal('/en/detail/565ee0d31709ae7b174eb8a1-test'); 243 | expect(match) 244 | .to.have.property('fullPath') 245 | .and.to.be.equal('/en/detail/565ee0d31709ae7b174eb8a1-test'); 246 | expect(match) 247 | .to.have.property('onEnter') 248 | .and.to.be.an('array') 249 | .and.to.be.deep.equal([onEnter, onEnter]); 250 | expect(match) 251 | .to.have.property('onLeave') 252 | .and.to.be.an('array') 253 | .and.to.be.deep.equal([onLeave, onLeave]); 254 | expect(match) 255 | .to.have.property('components') 256 | .and.to.be.an('array') 257 | .and.to.be.deep.equal(['a', 'b']); 258 | expect(match) 259 | .to.have.property('attrs') 260 | .and.to.be.an('object') 261 | .and.to.be.deep.equal({ a: false, b: true }); 262 | } 263 | ); 264 | }); 265 | 266 | it('resolves route with async children', () => { 267 | const asyncRoutes = () => { 268 | return Promise.resolve([ 269 | { 270 | path: '/', 271 | component: 'b' 272 | } 273 | ]); 274 | }; 275 | const route = new Route('/', '', asyncRoutes, () => {}, () => {}, 'a'); 276 | 277 | return route.match('/').then( 278 | (match) => { 279 | expect(match).be.an('object'); 280 | expect(match) 281 | .to.have.property('pathname') 282 | .and.to.be.equal('/'); 283 | expect(match) 284 | .to.have.property('fullPath') 285 | .and.to.be.equal('/'); 286 | expect(match) 287 | .to.have.property('vars') 288 | .and.to.be.deep.equal({}); 289 | expect(match) 290 | .to.have.property('onEnter') 291 | .and.to.be.an('array') 292 | .and.have.length(2); 293 | expect(match) 294 | .to.have.property('onLeave') 295 | .and.to.be.an('array') 296 | .and.have.length(2); 297 | expect(match) 298 | .to.have.property('components') 299 | .and.to.be.an('array') 300 | .and.to.be.deep.equal(['a', 'b']); 301 | expect(match) 302 | .to.have.property('attrs') 303 | .and.to.be.an('object') 304 | .and.to.be.deep.equal({}); 305 | } 306 | ); 307 | }); 308 | 309 | it('resolves route with sync children', () => { 310 | const routes = [ 311 | { 312 | path: '/', 313 | component: 'b' 314 | } 315 | ]; 316 | const route = new Route('/', '', routes, () => {}, () => {}, 'a'); 317 | 318 | return route.match('/').then( 319 | (match) => { 320 | expect(match).be.an('object'); 321 | expect(match) 322 | .to.have.property('pathname') 323 | .and.to.be.equal('/'); 324 | expect(match) 325 | .to.have.property('fullPath') 326 | .and.to.be.equal('/'); 327 | expect(match) 328 | .to.have.property('vars') 329 | .and.to.be.deep.equal({}); 330 | expect(match) 331 | .to.have.property('onEnter') 332 | .and.to.be.an('array') 333 | .and.have.length(2); 334 | expect(match) 335 | .to.have.property('onLeave') 336 | .and.to.be.an('array') 337 | .and.have.length(2); 338 | expect(match) 339 | .to.have.property('components') 340 | .and.to.be.an('array') 341 | .and.to.be.deep.equal(['a', 'b']); 342 | expect(match) 343 | .to.have.property('attrs') 344 | .and.to.be.an('object') 345 | .and.to.be.deep.equal({}); 346 | } 347 | ); 348 | }); 349 | 350 | it('matches route with query params', () => { 351 | const routes = [ 352 | { 353 | path: '/', 354 | component: 'b' 355 | } 356 | ]; 357 | const route = new Route('/', '', routes, () => {}, () => {}, 'a'); 358 | 359 | return route.match('/', { a: 1, b: 2 }).then( 360 | (match) => { 361 | expect(match).be.an('object'); 362 | expect(match) 363 | .to.have.property('pathname') 364 | .and.to.be.equal('/'); 365 | expect(match) 366 | .to.have.property('fullPath') 367 | .and.to.be.equal('/?a=1&b=2'); 368 | expect(match) 369 | .to.have.property('vars') 370 | .and.to.be.deep.equal({}); 371 | expect(match) 372 | .to.have.property('onEnter') 373 | .and.to.be.an('array') 374 | .and.have.length(2); 375 | expect(match) 376 | .to.have.property('onLeave') 377 | .and.to.be.an('array') 378 | .and.have.length(2); 379 | expect(match) 380 | .to.have.property('components') 381 | .and.to.be.an('array') 382 | .and.to.be.deep.equal(['a', 'b']); 383 | expect(match) 384 | .to.have.property('attrs') 385 | .and.to.be.an('object') 386 | .and.to.be.deep.equal({}); 387 | } 388 | ); 389 | }); 390 | 391 | it('asynchronously tries to match a route and rejects with an error if not found', (done) => { 392 | const asyncRoutes = () => { 393 | return Promise.resolve([ 394 | { 395 | path: '/', 396 | component: 'b' 397 | } 398 | ]); 399 | }; 400 | const route = new Route('/', '', asyncRoutes, () => {}, () => {}, 'a'); 401 | 402 | route.match('/test').then( 403 | () => done(Error('Should not found')), 404 | () => done() 405 | ); 406 | }); 407 | }); 408 | }); 409 | -------------------------------------------------------------------------------- /test/Router.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { spy, stub } from 'sinon'; 3 | import { Component } from 'react'; 4 | import Router from '../src/Router'; 5 | import { createMemoryHistory } from 'history'; 6 | import { RouteNotFoundError } from '../src/errors'; 7 | 8 | describe('Router', () => { 9 | describe('#constructor()', () => { 10 | it('throws if routes are not an array', () => { 11 | [1, true, 1.0, Date()].forEach((routes) => { 12 | expect( 13 | () => new Router(routes) 14 | ).to.throw( 15 | `Routes should be an array, ${typeof routes} given.` 16 | ); 17 | }); 18 | }); 19 | 20 | it('throws if onTransition is not an function or undefined', () => { 21 | [1, true, 1.0, Date()].forEach((callback) => { 22 | expect( 23 | () => new Router([], createMemoryHistory(), callback) 24 | ).to.throw( 25 | `Router onTransition callback should be a function, ${typeof callback} given.` 26 | ); 27 | }); 28 | }); 29 | }); 30 | 31 | describe('#listen()', () => { 32 | it( 33 | 'starts listening to pop state events and replaces state on initial and replaces state if undefined', 34 | (done) => { 35 | const changeStart = spy(); 36 | const changeSuccess = spy(); 37 | const onEnter = spy(); 38 | const history = createMemoryHistory(); 39 | 40 | const router = new Router( 41 | [{ path: '/', component: 'A', onEnter }], 42 | history, 43 | (err, resolvedRoute) => { 44 | try { 45 | expect(err).to.be.equal(null); 46 | expect(resolvedRoute).to.be.an('object'); 47 | expect(resolvedRoute.pathname).to.be.equal('/'); 48 | expect(resolvedRoute.fullPath).to.be.equal('/'); 49 | expect(resolvedRoute.components).to.be.eql(['A']); 50 | expect(resolvedRoute.attrs).to.be.eql({}); 51 | 52 | expect(history.replaceState.calledOnce).to.be.equal(true); 53 | expect(history.replaceState.getCall(0).args[0]).to.be.equal(resolvedRoute); 54 | expect(history.replaceState.getCall(0).args[1]).to.be.equal('/'); 55 | 56 | expect(changeStart.called).to.be.equal(true); 57 | expect(changeSuccess.calledOnce).to.be.equal(true); 58 | expect(onEnter.called).to.be.equal(true); 59 | 60 | done(); 61 | } catch (e) { 62 | done(e); 63 | } 64 | } 65 | ); 66 | 67 | spy(history, 'replaceState'); 68 | 69 | router.addChangeStartListener(changeStart); 70 | router.addChangeSuccessListener(changeSuccess); 71 | 72 | router.listen(); 73 | } 74 | ); 75 | 76 | it( 77 | 'starts listening to pop state events and calls not found listeners if current location is not mapped to route', 78 | (done) => { 79 | const changeStart = spy(); 80 | const changeSuccess = spy(); 81 | const notFound = spy(); 82 | const history = createMemoryHistory([{ pathname: '/unknown', search: '?a=1&b=0' }]); 83 | 84 | const router = new Router( 85 | [{ path: '/', component: 'A' }], 86 | history, 87 | (err, resolvedRoute) => { 88 | try { 89 | expect(err).not.to.be.equal(null); 90 | expect(resolvedRoute).to.be.equal(undefined); 91 | 92 | expect(history.replaceState.called).to.be.equal(false); 93 | 94 | expect(changeStart.called).to.be.equal(false); 95 | expect(changeSuccess.called).to.be.equal(false); 96 | expect(notFound.calledOnce).to.be.equal(true); 97 | expect(notFound.getCall(0).args[0]).to.be.equal('/unknown'); 98 | expect(notFound.getCall(0).args[1]).to.deep.equal({ a: '1', b: '0' }); 99 | 100 | done(); 101 | } catch (e) { 102 | done(e); 103 | } 104 | } 105 | ); 106 | 107 | spy(history, 'replaceState'); 108 | 109 | router.addChangeStartListener(changeStart); 110 | router.addChangeSuccessListener(changeSuccess); 111 | router.addNotFoundListener(notFound); 112 | 113 | router.listen(); 114 | } 115 | ); 116 | }); 117 | 118 | describe('#run()', () => { 119 | it('resolves simple route with sync children and calls all callbacks', () => { 120 | const onTransition = spy(); 121 | let history; 122 | 123 | const router = new Router( 124 | [ 125 | { 126 | path: '/', 127 | component: 'a', 128 | children: () => Promise.resolve([{ path: 'test', component: 'b' }]) 129 | } 130 | ], 131 | history = createMemoryHistory(), 132 | onTransition 133 | ); 134 | 135 | spy(history, 'pushState'); 136 | 137 | const changeStart = spy(); 138 | const changeSuccess = spy(); 139 | 140 | router.addChangeStartListener(changeStart); 141 | router.addChangeSuccessListener(changeSuccess); 142 | 143 | return router.run('/test', { a: 1, b: 0 }).then( 144 | (resolvedRoute) => { 145 | expect(resolvedRoute).to.be.an('object'); 146 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/test'); 147 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'b']); 148 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({}); 149 | expect(resolvedRoute).to.have.property('query').and.be.deep.equal({ a: 1, b: 0 }); 150 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({}); 151 | expect(router.currentRoute()).to.be.an('object'); 152 | expect(history.pushState.calledOnce).to.be.equal(true); 153 | expect(history.pushState.getCall(0).args[0]).to.be.equal(resolvedRoute); 154 | expect(history.pushState.getCall(0).args[1]).to.be.equal('/test?a=1&b=0'); 155 | expect(changeStart.calledOnce).to.be.equal(true); 156 | expect(changeSuccess.calledOnce).to.be.equal(true); 157 | expect(onTransition.calledOnce).to.be.equal(true); 158 | } 159 | ); 160 | }); 161 | 162 | it('resolves route components asynchronously', () => { 163 | const onTransition = spy(); 164 | let history; 165 | 166 | class App extends Component {} 167 | 168 | const router = new Router( 169 | [ 170 | { 171 | path: '/', 172 | component: App, 173 | attrs: { 174 | overridden: false 175 | }, 176 | children: () => Promise.resolve([ 177 | { 178 | path: 'test', 179 | component: () => Promise.resolve(App) 180 | } 181 | ]) 182 | } 183 | ], 184 | history = createMemoryHistory(), 185 | onTransition 186 | ); 187 | 188 | stub(history); 189 | 190 | const changeStart = spy(); 191 | const changeSuccess = spy(); 192 | 193 | router.addChangeStartListener(changeStart); 194 | router.addChangeSuccessListener(changeSuccess); 195 | 196 | return router.run('/test').then( 197 | (resolvedRoute) => { 198 | expect(resolvedRoute).to.be.an('object'); 199 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/test'); 200 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal([App, App]); 201 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({}); 202 | expect(resolvedRoute).to.have.property('query').and.be.deep.equal({}); 203 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({ overridden: false }); 204 | expect(router.currentRoute()).to.be.an('object'); 205 | expect(history.pushState.calledOnce).to.be.equal(true); 206 | expect(changeStart.calledOnce).to.be.equal(true); 207 | expect(changeSuccess.calledOnce).to.be.equal(true); 208 | expect(onTransition.calledOnce).to.be.equal(true); 209 | } 210 | ); 211 | }); 212 | 213 | it('resolves a route with variables and calls all callbacks', () => { 214 | const onTransition = spy(); 215 | let history; 216 | 217 | const router = new Router( 218 | [ 219 | { 220 | path: '/', 221 | component: 'a', 222 | attrs: { 223 | override: true 224 | }, 225 | children: () => Promise.resolve([{ path: 'test/:variable', component: 'b', attrs: { override: false } }]) 226 | } 227 | ], 228 | history = createMemoryHistory(), 229 | onTransition 230 | ); 231 | 232 | const changeStart = spy(); 233 | const changeSuccess = spy(); 234 | 235 | stub(history); 236 | 237 | router.addChangeStartListener(changeStart); 238 | router.addChangeSuccessListener(changeSuccess); 239 | 240 | return router.run('/test/10').then( 241 | (resolvedRoute) => { 242 | expect(resolvedRoute).to.be.an('object'); 243 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/test/10'); 244 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'b']); 245 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({ 246 | variable: '10' 247 | }); 248 | expect(resolvedRoute).to.have.property('query').and.be.deep.equal({}); 249 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({ override: false }); 250 | expect(router.currentRoute()).to.be.an('object'); 251 | expect(history.pushState.calledOnce).to.be.equal(true); 252 | expect(changeStart.calledOnce).to.be.equal(true); 253 | expect(changeSuccess.calledOnce).to.be.equal(true); 254 | expect(onTransition.calledOnce).to.be.equal(true); 255 | } 256 | ); 257 | }); 258 | 259 | it('rejects if route is not found and calls callbacks', () => { 260 | const onTransition = spy(); 261 | let history; 262 | 263 | const router = new Router( 264 | [ 265 | { 266 | path: '/', 267 | component: 'a', 268 | children: () => Promise.resolve([{ path: 'test/:variable{\\d+}', component: 'b' }]) 269 | } 270 | ], 271 | history = createMemoryHistory(), 272 | onTransition 273 | ); 274 | 275 | const changeStart = spy(); 276 | const changeFail = spy(); 277 | const notFound = spy(); 278 | 279 | stub(history); 280 | 281 | router.addChangeStartListener(changeStart); 282 | router.addChangeFailListener(changeFail); 283 | router.addNotFoundListener(notFound); 284 | 285 | return router.run('/test/abcd').catch( 286 | (err) => { 287 | expect(changeStart.called).to.be.equal(false); 288 | expect(changeFail.called).to.be.equal(false); 289 | expect(notFound.called).to.be.equal(true); 290 | expect(err).to.be.instanceof(RouteNotFoundError); 291 | expect(router.currentRoute()).to.be.equal(null); 292 | } 293 | ); 294 | }); 295 | 296 | it('resolves simple route and calls pushState on current and subsequent runs', () => { 297 | const onTransition = spy(); 298 | let history; 299 | 300 | const router = new Router( 301 | [ 302 | { 303 | path: '/', 304 | component: 'a', 305 | children: () => Promise.resolve([ 306 | { path: '', component: 'b' }, 307 | { path: 'test', component: 'c' } 308 | ]) 309 | } 310 | ], 311 | history = createMemoryHistory(), 312 | onTransition 313 | ); 314 | 315 | const changeStart = spy(); 316 | const changeSuccess = spy(); 317 | 318 | spy(history, 'pushState'); 319 | 320 | router.addChangeStartListener(changeStart); 321 | router.addChangeSuccessListener(changeSuccess); 322 | 323 | return router.run('/').then( 324 | (resolvedRoute) => { 325 | expect(resolvedRoute).to.be.an('object'); 326 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/'); 327 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'b']); 328 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({}); 329 | expect(resolvedRoute).to.have.property('query').and.be.deep.equal({}); 330 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({}); 331 | expect(router.currentRoute()).to.be.equal(resolvedRoute); 332 | 333 | expect(history.pushState.calledOnce).to.be.equal(true); 334 | expect(changeStart.calledOnce).to.be.equal(true); 335 | expect(changeSuccess.calledOnce).to.be.equal(true); 336 | expect(onTransition.calledOnce).to.be.equal(true); 337 | 338 | return router.run('/test').then( 339 | (_resolvedRoute) => { 340 | expect(_resolvedRoute).to.be.an('object'); 341 | expect(_resolvedRoute).to.have.property('pathname').and.be.equal('/test'); 342 | expect(_resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'c']); 343 | expect(_resolvedRoute).to.have.property('vars').and.be.deep.equal({}); 344 | expect(_resolvedRoute).to.have.property('query').and.be.deep.equal({}); 345 | expect(_resolvedRoute).to.have.property('attrs').and.be.deep.equal({}); 346 | expect(router.currentRoute()).to.be.equal(_resolvedRoute); 347 | 348 | expect(history.pushState.calledTwice).to.be.equal(true); 349 | expect(changeStart.calledTwice).to.be.equal(true); 350 | expect(changeSuccess.calledTwice).to.be.equal(true); 351 | expect(onTransition.calledTwice).to.be.equal(true); 352 | } 353 | ); 354 | } 355 | ); 356 | }); 357 | 358 | it('rejects not found route (and if has previous state, calls fail callback)', () => { 359 | const onTransition = spy(); 360 | let history; 361 | 362 | const router = new Router( 363 | [ 364 | { 365 | path: '/', 366 | component: 'a', 367 | children: () => Promise.resolve([ 368 | { path: '', component: 'b' }, 369 | { path: 'test', component: 'c' } 370 | ]) 371 | } 372 | ], 373 | history = createMemoryHistory(), 374 | onTransition 375 | ); 376 | 377 | const changeStart = spy(); 378 | const changeSuccess = spy(); 379 | const changeFail = spy(); 380 | 381 | spy(history, 'pushState'); 382 | 383 | router.addChangeStartListener(changeStart); 384 | router.addChangeSuccessListener(changeSuccess); 385 | router.addChangeFailListener(changeFail); 386 | 387 | return router.run('/').then( 388 | (resolvedRoute) => { 389 | expect(resolvedRoute).to.be.an('object'); 390 | expect(resolvedRoute).to.have.property('pathname').and.be.equal('/'); 391 | expect(resolvedRoute).to.have.property('components').and.be.deep.equal(['a', 'b']); 392 | expect(resolvedRoute).to.have.property('vars').and.be.deep.equal({}); 393 | expect(resolvedRoute).to.have.property('attrs').and.be.deep.equal({}); 394 | expect(router.currentRoute()).to.be.an('object'); 395 | expect(router.currentRoute()).to.have.property('pathname').and.be.equal('/'); 396 | expect(router.currentRoute()).to.have.property('components').and.be.deep.equal(['a', 'b']); 397 | expect(router.currentRoute()).to.have.property('vars').and.be.deep.equal({}); 398 | expect(router.currentRoute()).to.have.property('attrs').and.be.deep.equal({}); 399 | expect(history.pushState.calledOnce).to.be.equal(true); 400 | expect(changeStart.calledOnce).to.be.equal(true); 401 | expect(changeSuccess.calledOnce).to.be.equal(true); 402 | expect(changeFail.called).to.be.equal(false); 403 | expect(onTransition.calledOnce).to.be.equal(true); 404 | 405 | return router.run('/lalala').catch( 406 | (err) => { 407 | expect(router.currentRoute()).to.be.deep.equal(resolvedRoute); 408 | expect(err).to.be.instanceof(RouteNotFoundError); 409 | 410 | // change listeners should not be called at alle 411 | // because they are called only if route matches 412 | expect(changeStart.calledOnce).to.be.equal(true); 413 | expect(changeFail.called).to.be.equal(false); 414 | expect(changeSuccess.calledOnce).to.be.equal(true); 415 | 416 | // this is called everytime routes finishes 417 | expect(onTransition.calledTwice).to.be.equal(true); 418 | 419 | // we don't expect to change state of history 420 | // because we want user to do something about not found event 421 | expect(history.pushState.calledOnce).to.be.equal(true); 422 | } 423 | ); 424 | } 425 | ); 426 | }); 427 | 428 | it('calls onEnter on route with current route and resolving route', () => { 429 | const onTransition = spy(); 430 | 431 | const router = new Router( 432 | [ 433 | { 434 | path: 'a', 435 | component: 'dashboard', 436 | children: [ 437 | { 438 | path: 'b', 439 | component: 'newmessage' 440 | } 441 | ] 442 | }, 443 | { 444 | path: '', 445 | component: 'login' 446 | }, 447 | { 448 | path: 'registration', 449 | component: 'registration' 450 | } 451 | ], 452 | createMemoryHistory(), 453 | onTransition 454 | ); 455 | 456 | const changeStart = spy(); 457 | 458 | router.addChangeStartListener(changeStart); 459 | 460 | expect(router.currentRoute()).to.be.equal(null); 461 | 462 | return router.run('/').then( 463 | (resolvedRoute) => { 464 | expect(changeStart.calledOnce).to.be.equal(true); 465 | expect(router.currentRoute()).not.to.be.equal(null); 466 | expect(resolvedRoute).to.be.equal(router.currentRoute()); 467 | const previousRoute = router.currentRoute(); 468 | 469 | return router.run('/a/b', { a: 1 }).then( 470 | (newRoute) => { 471 | expect(changeStart.calledTwice).to.be.equal(true); 472 | expect(router.currentRoute()).not.to.be.equal(previousRoute); 473 | expect(newRoute).not.to.be.equal(previousRoute); 474 | expect(newRoute.fullPath).to.be.equal('/a/b?a=1'); 475 | expect(router.currentRoute()).to.be.equal(newRoute); 476 | expect(changeStart.getCall(1).args[0]).to.be.deep.equal(previousRoute); 477 | expect(changeStart.getCall(1).args[1]).to.be.deep.equal(newRoute); 478 | } 479 | ); 480 | } 481 | ); 482 | }); 483 | 484 | it('resolves onEnter handlers in order', () => { 485 | const onTransition = spy(); 486 | const onEnter1 = spy(() => new Promise(resolve => { 487 | setTimeout(resolve, 150); 488 | })); 489 | const onEnter2 = spy(() => Promise.resolve()); 490 | 491 | const router = new Router( 492 | [ 493 | { 494 | path: 'a', 495 | component: 'dashboard', 496 | onEnter: onEnter1, 497 | children: [ 498 | { 499 | path: 'b', 500 | component: 'newmessage', 501 | onEnter: onEnter2 502 | } 503 | ] 504 | } 505 | ], 506 | createMemoryHistory(), 507 | onTransition 508 | ); 509 | 510 | return router.run('/a/b').then( 511 | (resolvedRoute) => { 512 | expect(router.currentRoute()).not.to.be.equal(null); 513 | expect(resolvedRoute).to.be.equal(router.currentRoute()); 514 | expect(onEnter1.calledBefore(onEnter2)).to.be.equal(true); 515 | } 516 | ); 517 | }); 518 | 519 | it('resolves onLeave handlers in order', () => { 520 | const onTransition = spy(); 521 | const onLeave1 = spy(() => new Promise(resolve => { 522 | setTimeout(resolve, 150); 523 | })); 524 | const onLeave2 = spy(() => Promise.resolve()); 525 | 526 | const router = new Router( 527 | [ 528 | { 529 | path: '/', 530 | component: 'a' 531 | }, 532 | { 533 | path: 'a', 534 | component: 'dashboard', 535 | onLeave: onLeave1, 536 | children: [ 537 | { 538 | path: 'b', 539 | component: 'newmessage', 540 | onLeave: onLeave2 541 | } 542 | ] 543 | } 544 | ], 545 | createMemoryHistory(), 546 | onTransition 547 | ); 548 | 549 | return router.run('/a/b').then( 550 | () => { 551 | return router.run('/').then(() => { 552 | expect(onLeave2.calledBefore(onLeave1)).to.be.equal(true); 553 | }); 554 | } 555 | ); 556 | }); 557 | }); 558 | 559 | describe('handler wrapping', () => { 560 | function createRouterForWrappers() { 561 | const onAEnterSpy = spy(); 562 | const onBLeaveSpy = spy(); 563 | 564 | const router = new Router([ 565 | { path: '/', component: 'A', onEnter: onAEnterSpy }, 566 | { path: '/test', component: 'B', onLeave: onBLeaveSpy } 567 | ], createMemoryHistory()); 568 | 569 | return { 570 | router, 571 | onAEnterSpy, 572 | onBLeaveSpy 573 | }; 574 | } 575 | 576 | describe('#wrapOnEnterHandler()', () => { 577 | it('wraps route onEnter handler with provided function', () => { 578 | const { router, onAEnterSpy } = createRouterForWrappers(); 579 | 580 | const onEnterSpy = spy((onEnter) => { 581 | return onEnter('a', 'b', 'c'); 582 | }); 583 | 584 | router.wrapOnEnterHandler(onEnterSpy); 585 | 586 | return router.run('/', {}).then( 587 | () => { 588 | expect(onEnterSpy.calledOnce).to.be.equal(true); 589 | expect(onAEnterSpy.calledOnce).to.be.equal(true); 590 | 591 | const call = onAEnterSpy.getCall(0); 592 | const [previous, current, _router, ...rest] = call.args; 593 | 594 | expect(call.args).to.have.length(6); 595 | expect(previous).to.be.equal(null); // previous route 596 | expect(current).to.be.an('object').with.property('pathname').equal('/'); // current route 597 | expect(_router).to.be.equal(router); 598 | expect(rest).to.be.eql(['a', 'b', 'c']); 599 | } 600 | ); 601 | }); 602 | }); 603 | 604 | describe('#wrapOnLeaveHandler()', () => { 605 | it('wraps route onEnter handler with provided function', () => { 606 | const { router, onBLeaveSpy } = createRouterForWrappers(); 607 | 608 | const onLeaveSpy = spy((onLeave) => { 609 | return onLeave('a', 'b', 'c'); 610 | }); 611 | 612 | router.wrapOnLeaveHandler(onLeaveSpy); 613 | 614 | return router.run('/test', {}).then( 615 | () => { 616 | return router.run('/', {}).then( 617 | () => { 618 | expect(onLeaveSpy.calledOnce).to.be.equal(true); 619 | expect(onBLeaveSpy.calledOnce).to.be.equal(true); 620 | 621 | const call = onBLeaveSpy.getCall(0); 622 | const [resolved, _router, ...rest] = call.args; 623 | 624 | expect(call.args).to.have.length(5); 625 | expect(resolved).to.be.an('object').with.property('pathname').equal('/'); // current route 626 | expect(_router).to.be.equal(router); 627 | expect(rest).to.be.eql(['a', 'b', 'c']); 628 | } 629 | ); 630 | } 631 | ); 632 | }); 633 | }); 634 | }); 635 | }); 636 | -------------------------------------------------------------------------------- /test/createRoutex.spec.js: -------------------------------------------------------------------------------- 1 | import createRoutex from '../src/createRoutex'; 2 | import { createMemoryHistory } from 'history'; 3 | import { expect } from 'chai'; 4 | import Router from '../src/Router'; 5 | import { RouteNotFoundError } from '../src/errors'; 6 | import { compose, createStore, combineReducers } from 'redux'; 7 | import { transitionTo } from '../src/actions'; 8 | import { spy } from 'sinon'; 9 | 10 | describe('createRoutex()', () => { 11 | let routex; 12 | 13 | beforeEach(() => { 14 | routex = createRoutex([ 15 | { 16 | path: '/', 17 | component: 'Index' 18 | }, 19 | { 20 | path: '/test', 21 | component: 'Test' 22 | }, 23 | { 24 | path: '/rejected', 25 | onEnter: () => Promise.reject() 26 | } 27 | ], createMemoryHistory()); 28 | }); 29 | 30 | it('exposes public API + router instance', () => { 31 | expect(routex).to.be.an('object'); 32 | expect(routex).to.have.property('router').and.to.be.instanceof(Router); 33 | expect(routex).to.have.property('store').and.to.be.a('function'); 34 | expect(routex).to.have.property('reducer').and.to.be.a('object'); 35 | expect(routex.reducer).to.have.property('router').and.to.be.a('function'); 36 | }); 37 | 38 | it('exposes redux public API + router instance on redux store', () => { 39 | const store = compose(routex.store)(createStore)(combineReducers(routex.reducer)); 40 | 41 | expect(store).to.be.an('object'); 42 | expect(store).to.have.keys('dispatch', 'subscribe', 'getState', 'replaceReducer', 'router'); 43 | expect(store.router).to.be.instanceof(Router); 44 | }); 45 | 46 | it('starts listening to pop state event on initial store creation', () => { 47 | routex.router.listen = spy(routex.router.listen); 48 | 49 | compose(routex.store)(createStore)(combineReducers(routex.reducer)); 50 | 51 | expect(routex.router.listen.calledOnce).to.be.equal(true); 52 | }); 53 | 54 | it('runs listeners on successful transition dispatch and sets state in reducer', (done) => { 55 | routex.router.run = spy(routex.router.run); 56 | 57 | const store = compose(routex.store)(createStore)(combineReducers(routex.reducer)); 58 | const startSpy = spy(); 59 | const successSpy = spy(); 60 | let indexRoute; 61 | 62 | const unsubscribe = store.router.addChangeSuccessListener((resolvedRoute) => { 63 | indexRoute = resolvedRoute; 64 | unsubscribe(); // unregister previous 65 | 66 | store.router.addChangeStartListener(startSpy); 67 | store.router.addChangeStartListener((currentRoute, nextRoute) => { 68 | try { 69 | expect(store.getState().router.state).to.be.equal('TRANSITIONING'); 70 | expect(store.getState().router.route).to.be.equal(indexRoute); 71 | expect(store.getState().router.route).to.be.equal(currentRoute); 72 | expect(store.getState().router.nextRoute).to.be.equal(nextRoute); 73 | expect(currentRoute).to.not.be.equal(nextRoute); 74 | } catch (e) { 75 | done(e); 76 | } 77 | }); 78 | store.router.addChangeSuccessListener(successSpy); 79 | 80 | store.dispatch(transitionTo('/test')).then(() => { 81 | try { 82 | expect(startSpy.calledOnce).to.be.equal(true); 83 | 84 | expect(startSpy.getCall(0).args[0]).to.contain.all.keys('pathname', 'components'); 85 | expect(startSpy.getCall(0).args[0].pathname).to.be.equal('/'); 86 | expect(startSpy.getCall(0).args[0].components).to.be.eql(['Index']); 87 | 88 | expect(startSpy.getCall(0).args[1]).to.contain.all.keys('pathname', 'components'); 89 | expect(startSpy.getCall(0).args[1].pathname).to.be.equal('/test'); 90 | expect(startSpy.getCall(0).args[1].components).to.be.eql(['Test']); 91 | 92 | expect(successSpy.calledOnce).to.be.equal(true); 93 | 94 | expect(successSpy.getCall(0).args[0]).to.contain.all.keys('pathname', 'components'); 95 | expect(successSpy.getCall(0).args[0].pathname).to.be.equal('/test'); 96 | expect(successSpy.getCall(0).args[0].components).to.be.eql(['Test']); 97 | 98 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 99 | expect(store.getState().router.route).to.be.equal(successSpy.getCall(0).args[0]); 100 | expect(store.getState().router).to.not.have.key('nextRoute'); 101 | 102 | done(); 103 | } catch (e) { 104 | done(e); 105 | } 106 | }); 107 | }); 108 | }); 109 | 110 | it('runs listeners on failed transition dispatch and sets state in reducer', (done) => { 111 | routex.router.run = spy(routex.router.run); 112 | 113 | const store = compose(routex.store)(createStore)(combineReducers(routex.reducer)); 114 | const startSpy = spy(); 115 | const failSpy = spy(); 116 | let indexRoute; 117 | 118 | const unsubscribe = store.router.addChangeSuccessListener((resolvedRoute) => { 119 | indexRoute = resolvedRoute; 120 | unsubscribe(); // unregister previous 121 | 122 | store.router.addChangeStartListener(startSpy); 123 | store.router.addChangeStartListener((currentRoute, nextRoute) => { 124 | try { 125 | expect(store.getState().router.state).to.be.equal('TRANSITIONING'); 126 | expect(store.getState().router.route).to.be.equal(indexRoute); 127 | expect(store.getState().router.route).to.be.equal(currentRoute); 128 | expect(store.getState().router.nextRoute).to.be.equal(nextRoute); 129 | expect(currentRoute).to.not.be.equal(nextRoute); 130 | } catch (e) { 131 | done(e); 132 | } 133 | }); 134 | store.router.addChangeFailListener(failSpy); 135 | 136 | store.dispatch(transitionTo('/rejected')).catch(() => { 137 | try { 138 | expect(failSpy.calledOnce).to.be.equal(true); 139 | 140 | expect(failSpy.getCall(0).args[1]).to.be.equal(indexRoute); 141 | 142 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 143 | expect(store.getState().router.route).to.be.equal(failSpy.getCall(0).args[1]); 144 | expect(store.getState().router).to.not.have.key('nextRoute'); 145 | 146 | done(); 147 | } catch (e) { 148 | done(e); 149 | } 150 | }); 151 | }); 152 | }); 153 | 154 | it('runs only not found listeners on transition dispatch to non existing route', (done) => { 155 | routex.router.run = spy(routex.router.run); 156 | 157 | const store = compose(routex.store)(createStore)(combineReducers(routex.reducer)); 158 | const startSpy = spy(); 159 | const successSpy = spy(); 160 | const failSpy = spy(); 161 | const notFoundSpy = spy(); 162 | 163 | const unsubscribe = store.router.addChangeSuccessListener((resolvedRoute) => { 164 | unsubscribe(); // unregister previous 165 | 166 | try { 167 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 168 | expect(store.getState().router.route).to.be.equal(resolvedRoute); 169 | } catch (e) { 170 | done(e); 171 | } 172 | 173 | store.router.addChangeStartListener(startSpy); 174 | store.router.addChangeFailListener(failSpy); 175 | store.router.addChangeSuccessListener(successSpy); 176 | store.router.addNotFoundListener(notFoundSpy); 177 | 178 | store.dispatch(transitionTo('/not-existing')).catch((err) => { 179 | try { 180 | expect(err).to.be.instanceof(RouteNotFoundError); 181 | 182 | expect(startSpy.called).to.be.equal(false); 183 | expect(failSpy.called).to.be.equal(false); 184 | expect(successSpy.called).to.be.equal(false); 185 | expect(notFoundSpy.calledOnce).to.be.equal(true); 186 | 187 | expect(notFoundSpy.getCall(0).args[0]).to.be.equal('/not-existing'); 188 | expect(notFoundSpy.getCall(0).args[1]).to.be.eql({}); 189 | 190 | // state is untouched 191 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 192 | expect(store.getState().router.route).to.be.equal(resolvedRoute); 193 | 194 | done(); 195 | } catch (e) { 196 | done(e); 197 | } 198 | }); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /test/react/Link.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint func-names:0 */ 2 | import { expect } from 'chai'; 3 | import jsdom from 'mocha-jsdom'; 4 | import { createStore, combineReducers, compose } from 'redux'; 5 | import { createMemoryHistory } from 'history'; 6 | import createRoutex from '../../src/createRoutex.js'; 7 | import React from 'react'; 8 | import { Provider } from 'react-redux'; 9 | import { Link } from '../../src/react'; 10 | import { renderToStaticMarkup } from 'react-dom/server'; 11 | import { transitionTo } from '../../src/actions'; 12 | 13 | describe('React', () => { 14 | describe('Link', () => { 15 | jsdom(); 16 | 17 | let store; 18 | 19 | beforeEach(() => { 20 | const routex = createRoutex([ 21 | { 22 | path: '/path/:id', 23 | component: 'b', 24 | children: [ 25 | { 26 | path: 'messages', 27 | component: 'a' 28 | } 29 | ] 30 | }, 31 | { 32 | path: '/', 33 | component: 'index', 34 | children: [ 35 | { 36 | path: '/', 37 | component: 'nested-index' 38 | }, 39 | { 40 | path: '/:id', 41 | component: 'nested-var-index' 42 | } 43 | ] 44 | } 45 | ], createMemoryHistory()); 46 | 47 | store = compose( 48 | routex.store 49 | )(createStore)( 50 | combineReducers(routex.reducer) 51 | ); 52 | }); 53 | 54 | it('renders anchor with simple path', function() { 55 | const tree = renderToStaticMarkup( 56 | 57 | 58 | 59 | ); 60 | 61 | expect(tree).to.be.equal(''); 62 | }); 63 | 64 | it('renders anchor with href and query string', function() { 65 | const tree = renderToStaticMarkup( 66 | 67 | 68 | 69 | ); 70 | 71 | expect(tree).to.be.equal(''); 72 | }); 73 | 74 | describe('adds props from stateProps by current state', () => { 75 | it('short route', function(done) { 76 | const stateProps = { 77 | active: { className: 'active' }, 78 | inactive: { className: 'inactive' } 79 | }; 80 | 81 | store 82 | .dispatch(transitionTo('/path/123')) 83 | .then( 84 | () => { 85 | try { 86 | const tree = renderToStaticMarkup( 87 | 88 |
89 | 90 | 91 | 92 |
93 |
94 | ); 95 | 96 | expect(tree).to.be.equal( 97 | '
' + 98 | '' + 99 | '' + 100 | '' + 101 | '
' 102 | ); 103 | 104 | done(); 105 | } catch (e) { 106 | done(e); 107 | } 108 | }, 109 | () => done(new Error('Route not found')) 110 | ); 111 | }); 112 | 113 | it('longer route', function(done) { 114 | const stateProps = { 115 | active: { className: 'active' }, 116 | inactive: { className: 'inactive' } 117 | }; 118 | 119 | store 120 | .dispatch(transitionTo('/path/123/messages')) 121 | .then( 122 | () => { 123 | try { 124 | const tree = renderToStaticMarkup( 125 | 126 |
127 | 128 | 129 | 130 |
131 |
132 | ); 133 | 134 | expect(tree).to.be.equal( 135 | '
' + 136 | '' + 137 | '' + 138 | '' + 139 | '
' 140 | ); 141 | 142 | done(); 143 | } catch (e) { 144 | done(e); 145 | } 146 | }, 147 | () => done(new Error('Route not found')) 148 | ); 149 | }); 150 | 151 | it('possible conflict in routes', function(done) { 152 | const stateProps = { 153 | active: { className: 'active' }, 154 | inactive: { className: 'inactive' } 155 | }; 156 | 157 | store 158 | .dispatch(transitionTo('/path/12')) 159 | .then( 160 | () => { 161 | try { 162 | const tree = renderToStaticMarkup( 163 | 164 |
165 | 166 | 167 | 168 |
169 |
170 | ); 171 | 172 | expect(tree).to.be.equal( 173 | '
' + 174 | '' + 175 | '' + 176 | '' + 177 | '
' 178 | ); 179 | 180 | done(); 181 | } catch (e) { 182 | done(e); 183 | } 184 | }, 185 | () => done(new Error('Route not found')) 186 | ); 187 | }); 188 | 189 | it('nested routes', function(done) { 190 | const stateProps = { 191 | active: { className: 'active' }, 192 | inactive: { className: 'inactive' } 193 | }; 194 | 195 | store 196 | .dispatch(transitionTo('/haha')) 197 | .then( 198 | () => { 199 | try { 200 | const tree = renderToStaticMarkup( 201 | 202 |
203 | 204 | 205 | 206 |
207 |
208 | ); 209 | 210 | expect(tree).to.be.equal( 211 | '
' + 212 | '' + 213 | '' + 214 | '' + 215 | '
' 216 | ); 217 | 218 | done(); 219 | } catch (e) { 220 | done(e); 221 | } 222 | }, 223 | () => done(new Error('Route not found')) 224 | ); 225 | }); 226 | }); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /test/react/View.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint func-names:0, react/prop-types:0, react/no-multi-comp:0 */ 2 | global.navigator = { 3 | userAgent: 'node.js' 4 | }; 5 | 6 | import { expect } from 'chai'; 7 | import { createStore, compose, combineReducers } from 'redux'; 8 | import TestUtils from 'react-addons-test-utils'; 9 | import jsdom from 'mocha-jsdom'; 10 | import { createRoutex, actions } from '../../src'; 11 | import { createMemoryHistory } from 'history'; 12 | import React, { Component } from 'react'; 13 | import ReactDOM from 'react-dom/server'; 14 | import { Provider } from 'react-redux'; 15 | import { View } from '../../src/react'; 16 | 17 | describe('React', () => { 18 | function createRoutexStore(routes, initialState, onTransition) { 19 | const routex = createRoutex(routes, createMemoryHistory('/'), onTransition); 20 | 21 | return compose(routex.store)(createStore)(combineReducers(routex.reducer), initialState); 22 | } 23 | 24 | describe('View', () => { 25 | jsdom(); 26 | 27 | class App extends Component { 28 | render() { 29 | return
{this.props.children || 'Pom'}
; 30 | } 31 | } 32 | 33 | class Child extends Component { 34 | render() { 35 | return
Child
; 36 | } 37 | } 38 | 39 | it('renders matched route on initial load when state is not provided (default state)', function(done) { 40 | const store = createRoutexStore( 41 | [ 42 | { 43 | path: '/', 44 | component: App 45 | } 46 | ], 47 | undefined, 48 | (err) => { 49 | if (err) done(err); 50 | 51 | const tree = TestUtils.renderIntoDocument( 52 | 53 | 54 | 55 | ); 56 | 57 | TestUtils.findRenderedComponentWithType(tree, App); 58 | done(); 59 | }); 60 | }); 61 | 62 | it('renders matched route on initial load (rehydrated)', function(done) { 63 | const store = createRoutexStore( 64 | [ 65 | { 66 | path: '/', 67 | component: App 68 | } 69 | ], 70 | { 71 | router: { state: 'TRANSITIONED', route: { pathname: '/', query: {}, vars: {} }} 72 | }, 73 | (err) => { 74 | if (err) done(err); 75 | 76 | const tree = TestUtils.renderIntoDocument( 77 | 78 | 79 | 80 | ); 81 | 82 | TestUtils.findRenderedComponentWithType(tree, App); 83 | done(); 84 | } 85 | ); 86 | }); 87 | 88 | it('renders route components on successful transition', function(done) { 89 | let started = false; 90 | 91 | const store = createRoutexStore( 92 | [ 93 | { 94 | path: '/', 95 | component: App, 96 | children: [ 97 | { 98 | path: '/child', 99 | component: Child 100 | } 101 | ] 102 | } 103 | ], 104 | { 105 | router: { state: 'TRANSITIONED', route: { pathname: '/', query: {}, vars: {} }} 106 | }, 107 | () => { 108 | if (started) { 109 | return; 110 | } 111 | 112 | started = true; 113 | 114 | try { 115 | expect( 116 | ReactDOM.renderToString( 117 | 118 | 119 | 120 | ) 121 | ).to.match(/Pom/); 122 | } catch (e) { 123 | done(e); 124 | } 125 | 126 | setImmediate( 127 | () => { 128 | store.dispatch(actions.transitionTo('/child')).then( 129 | () => { 130 | try { 131 | expect(store.getState().router.route.pathname).to.be.equal('/child'); 132 | expect( 133 | ReactDOM.renderToString( 134 | 135 | 136 | 137 | ) 138 | ).to.match(/Child/); 139 | done(); 140 | } catch (e) { 141 | done(e); 142 | } 143 | }, 144 | done.bind(this, Error('Should transition to /child')) 145 | ); 146 | } 147 | ); 148 | 149 | } 150 | ); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /test/routex.spec.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, compose } from 'redux'; 2 | import createRoutex from '../src/createRoutex'; 3 | import { createMemoryHistory } from 'history'; 4 | import * as actions from '../src/actions'; 5 | import { spy } from 'sinon'; 6 | import { expect } from 'chai'; 7 | 8 | describe('routex', () => { 9 | let history; 10 | let store; 11 | 12 | const transitionTo = actions.transitionTo; 13 | 14 | function createRoutexStore(_history, onTransition, initialState) { 15 | const routex = createRoutex( 16 | [ 17 | { path: '/', component: 'A' }, 18 | { path: '/child', component: 'Child' }, 19 | { path: '/rejected-on-enter', onEnter: () => Promise.reject(), component: 'RejectedOnEnter' }, 20 | { path: '/rejected-on-leave', onLeave: () => Promise.reject(), component: 'RejectedOnLeave' }, 21 | { path: '/with-variables/:user/:id{\\d+}', component: 'WithVariables' } 22 | ], 23 | _history, 24 | onTransition 25 | ); 26 | 27 | return compose(routex.store)(createStore)(combineReducers(routex.reducer), initialState); 28 | } 29 | 30 | function stripRouteInfo(route) { 31 | const { pathname, query, vars, components } = route; 32 | 33 | return { 34 | pathname, 35 | query, 36 | vars, 37 | components 38 | }; 39 | } 40 | 41 | function stepper(steps, done) { 42 | let currentStep = 0; 43 | 44 | return function nextStep() { 45 | try { 46 | steps[currentStep++](); 47 | } catch (e) { 48 | done(e); 49 | } 50 | }; 51 | } 52 | 53 | beforeEach(() => { 54 | history = createMemoryHistory(); 55 | }); 56 | 57 | it('replaces state in history on initial load if router state is initial', (done) => { 58 | spy(history, 'replaceState'); 59 | 60 | const onTransition = spy(() => { 61 | expect(onTransition.called).to.be.equal(true); 62 | expect(history.replaceState.called).to.be.equal(true); 63 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 64 | expect(stripRouteInfo(store.getState().router.route)).to.be.deep.equal({ 65 | pathname: '/', 66 | query: {}, 67 | vars: {}, 68 | components: ['A'] 69 | }); 70 | 71 | done(); 72 | }); 73 | store = createRoutexStore(history, onTransition); 74 | }); 75 | 76 | it('replaces state in history on initial load if current state is null (in browser after load)', (done) => { 77 | spy(history, 'replaceState'); 78 | 79 | const onTransition = spy(() => { 80 | expect(onTransition.called).to.be.equal(true); 81 | expect(history.replaceState.called).to.be.equal(true); 82 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 83 | expect(stripRouteInfo(store.getState().router.route)).to.be.deep.equal({ 84 | pathname: '/', 85 | query: {}, 86 | vars: {}, 87 | components: ['A'] 88 | }); 89 | 90 | done(); 91 | }); 92 | 93 | store = createRoutexStore(history, onTransition, { 94 | router: { 95 | state: 'TRANSITIONED', 96 | route: { 97 | pathname: '/', 98 | query: {}, 99 | vars: {} 100 | } 101 | } 102 | }); 103 | }); 104 | 105 | it('pushes state to history on successful transition (from known state to another)', (done) => { 106 | let _stepper; 107 | spy(history, 'pushState'); 108 | 109 | const steps = [ 110 | () => { 111 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 112 | expect(stripRouteInfo(store.getState().router.route)).to.be.deep.equal({ 113 | pathname: '/', 114 | query: {}, 115 | vars: {}, 116 | components: ['A'] 117 | }); 118 | 119 | store.dispatch(transitionTo('/child', {})); 120 | }, 121 | () => { 122 | expect(_stepper.calledTwice).to.be.equal(true); 123 | expect(history.pushState.called).to.be.equal(true); 124 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal({ 125 | pathname: '/child', 126 | query: {}, 127 | vars: {}, 128 | components: ['Child'] 129 | }); 130 | 131 | done(); 132 | } 133 | ]; 134 | 135 | _stepper = spy(stepper(steps, done)); 136 | 137 | store = createRoutexStore(history, _stepper, { 138 | router: { 139 | state: 'TRANSITIONED', 140 | route: { 141 | pathname: '/', 142 | query: {}, 143 | vars: {} 144 | } 145 | } 146 | }); 147 | }); 148 | 149 | it('changes state using change success action if pop state event is emitted', (done) => { 150 | const childState = { 151 | pathname: '/child', 152 | query: {}, 153 | vars: {}, 154 | components: ['Child'] 155 | }; 156 | 157 | const indexState = { 158 | pathname: '/', 159 | query: {}, 160 | vars: {}, 161 | components: ['A'] 162 | }; 163 | 164 | const steps = [ 165 | () => { 166 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 167 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState); 168 | 169 | store.dispatch(transitionTo('/child', {})); 170 | }, 171 | () => { 172 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 173 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(childState); 174 | 175 | // call on pop state with state from history and return back 176 | // this dispatches ROUTE_CHANGE_SUCCESS immediately 177 | history.goBack(); 178 | }, 179 | () => { 180 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 181 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState); 182 | 183 | // go forward 184 | history.goForward(); 185 | }, 186 | () => { 187 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 188 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(childState); 189 | 190 | done(); 191 | } 192 | ]; 193 | 194 | store = createRoutexStore(history, stepper(steps, done), { 195 | router: { 196 | state: 'TRANSITIONED', 197 | route: { 198 | pathname: '/', 199 | query: {}, 200 | vars: {} 201 | } 202 | } 203 | }); 204 | }); 205 | 206 | it('cancels transition if one of onEnter handlers rejects', (done) => { 207 | const indexState = { 208 | pathname: '/', 209 | query: {}, 210 | vars: {}, 211 | components: ['A'] 212 | }; 213 | 214 | const steps = [ 215 | () => { 216 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 217 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState); 218 | 219 | store.dispatch(transitionTo('/rejected-on-enter', {})); 220 | }, 221 | () => { 222 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 223 | expect(store.getState().router.error).to.be.eql(Error('onEnter handlers on route rejected-on-enter are not resolved.')); 224 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState); 225 | 226 | done(); 227 | } 228 | ]; 229 | 230 | const _stepper = stepper(steps, done); 231 | 232 | store = createRoutexStore(createMemoryHistory(), _stepper, { 233 | router: { 234 | state: 'TRANSITIONED', 235 | route: { 236 | pathname: '/', 237 | query: {}, 238 | vars: {} 239 | } 240 | } 241 | }); 242 | }); 243 | 244 | it('cancels transition if one of onLeave handlers rejects', (done) => { 245 | const indexState = { 246 | pathname: '/rejected-on-leave', 247 | query: {}, 248 | vars: {}, 249 | components: ['RejectedOnLeave'] 250 | }; 251 | 252 | const steps = [ 253 | () => { 254 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 255 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState); 256 | 257 | store.dispatch(transitionTo('/', {})); 258 | }, 259 | () => { 260 | expect(store.getState().router.state).to.be.equal('TRANSITIONED'); 261 | expect(store.getState().router.error).to.be.eql(Error('onLeave handlers on route rejected-on-leave are not resolved.')); 262 | expect(stripRouteInfo(store.getState().router.route)).to.deep.equal(indexState); 263 | 264 | done(); 265 | } 266 | ]; 267 | 268 | const _stepper = stepper(steps, done); 269 | 270 | store = createRoutexStore(createMemoryHistory(['/rejected-on-leave']), _stepper, { 271 | router: { 272 | state: 'TRANSITIONED', 273 | route: { 274 | pathname: '/rejected-on-leave', 275 | query: {}, 276 | vars: {} 277 | } 278 | } 279 | }); 280 | }); 281 | 282 | }); 283 | -------------------------------------------------------------------------------- /test/utils/urlUtils.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { createHref } from '../../src/utils/urlUtils'; 3 | 4 | describe('utils', () => { 5 | 6 | describe('createHref()', () => { 7 | it('creates simple href', () => { 8 | expect(createHref('/', { a: 1 })).to.be.equal('/?a=1'); 9 | expect(createHref('/', { a: [1, 0] })).to.be.equal('/?a%5B%5D=1&a%5B%5D=0'); 10 | }); 11 | 12 | it('parses existing query string and merges with new query params', () => { 13 | expect(createHref('/?b=1', { a: [1, 0] })).to.be.equal('/?b=1&a%5B%5D=1&a%5B%5D=0'); 14 | expect(createHref('/?', { a: 1 })).to.be.equal('/?a=1'); 15 | }); 16 | 17 | it('strips question mark if query string of given path is empty', () => { 18 | expect(createHref('/?')).to.be.equal('/'); 19 | }); 20 | }); 21 | 22 | }); 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var webpack = require('webpack'); 4 | 5 | var plugins = [ 6 | new webpack.optimize.OccurenceOrderPlugin(), 7 | new webpack.DefinePlugin({ 8 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 9 | }) 10 | ]; 11 | 12 | if (process.env.NODE_ENV === 'production') { 13 | plugins.push( 14 | new webpack.optimize.UglifyJsPlugin({ 15 | compressor: { 16 | screw_ie8: true, 17 | warnings: false 18 | } 19 | }) 20 | ); 21 | } 22 | 23 | var reactExternal = { 24 | root: 'React', 25 | commonjs2: 'react', 26 | commonjs: 'react', 27 | amd: 'react' 28 | }; 29 | 30 | var reduxExternal = { 31 | root: 'Redux', 32 | commonjs2: 'redux', 33 | commonjs: 'redux', 34 | amd: 'redux' 35 | }; 36 | 37 | module.exports = { 38 | externals: { 39 | 'react': reactExternal, 40 | 'react-native': reactExternal, 41 | 'redux': reduxExternal 42 | }, 43 | module: { 44 | loaders: [{ 45 | test: /\.js$/, 46 | loaders: ['babel-loader'], 47 | exclude: /node_modules/ 48 | }] 49 | }, 50 | output: { 51 | library: !!process.env.MODULE_NAME ? process.env.MODULE_NAME : 'routex', 52 | libraryTarget: 'umd' 53 | }, 54 | plugins: plugins, 55 | resolve: { 56 | extensions: ['', '.js'] 57 | } 58 | }; 59 | --------------------------------------------------------------------------------