├── .npmignore
├── examples
├── .babelrc
├── server-rendering
│ ├── constants.js
│ ├── reducer.js
│ ├── routes.js
│ ├── package.json
│ ├── webpack.config.clientDev.js
│ ├── client.js
│ ├── components.js
│ └── server.js
├── basic
│ ├── index.html
│ ├── package.json
│ ├── webpack.config.clientDev.js
│ ├── server.js
│ └── index.js
├── mergeConfig.js
├── README.md
├── package.json
└── webpack.config.base.js
├── .babelrc
├── .travis.yml
├── .gitignore
├── src
├── __tests__
│ ├── init.js
│ ├── reduxReactRouter-test.js
│ └── ReduxRouter-test.js
├── serverModule.js
├── index.js
├── replaceRoutesMiddleware.js
├── historyMiddleware.js
├── isActive.js
├── routerStateEquals.js
├── matchMiddleware.js
├── constants.js
├── routerStateReducer.js
├── useDefaults.js
├── reduxReactRouter.js
├── actionCreators.js
├── client.js
├── server.js
├── routeReplacement.js
└── ReduxRouter.js
├── .eslintrc
├── LICENSE
├── ISSUE_TEMPLATE.md
├── package.json
└── README.md
/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | examples
3 |
--------------------------------------------------------------------------------
/examples/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0
3 | }
4 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0,
3 | "loose": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - stable
4 | - 4
5 |
--------------------------------------------------------------------------------
/examples/server-rendering/constants.js:
--------------------------------------------------------------------------------
1 | export const MOUNT_ID = 'root';
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | *.log
4 | lib
5 | /server.js
6 | dist
7 |
--------------------------------------------------------------------------------
/src/__tests__/init.js:
--------------------------------------------------------------------------------
1 | import chai from 'chai';
2 | global.expect = chai.expect;
3 |
--------------------------------------------------------------------------------
/src/serverModule.js:
--------------------------------------------------------------------------------
1 | // This file is copied to the root of the project to allow
2 | export { reduxReactRouter, match } from './lib/server';
3 |
--------------------------------------------------------------------------------
/examples/server-rendering/reducer.js:
--------------------------------------------------------------------------------
1 | import {combineReducers} from 'redux';
2 | import {routerStateReducer} from '../../lib'; // 'redux-router';
3 |
4 | export default combineReducers({
5 | router: routerStateReducer
6 | });
7 |
--------------------------------------------------------------------------------
/examples/basic/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Redux React Router – Basic Example
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/examples/mergeConfig.js:
--------------------------------------------------------------------------------
1 | import merge from 'lodash/object/merge';
2 |
3 | export default function mergeConfig(...configs) {
4 | return merge({}, ...configs, (a, b) => {
5 | if (Array.isArray(a)) {
6 | return a.concat(b);
7 | }
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export routerStateReducer from './routerStateReducer';
2 | export ReduxRouter from './ReduxRouter';
3 | export reduxReactRouter from './client';
4 | export isActive from './isActive';
5 |
6 | export {
7 | historyAPI,
8 | push,
9 | replace,
10 | setState,
11 | go,
12 | goBack,
13 | goForward
14 | } from './actionCreators';
15 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | Redux-Router Examples
2 | ======
3 |
4 | To run the examples in your development environment:
5 |
6 | 1. Run `npm install` from the root directory (redux-router)
7 | 2. Run `npm install` from the examples directory (this directory)
8 | 3. Run `npm start` from the example you wish to start (i.e. basic or server-rendering)
9 | 4. Point your browser to http://localhost:3000
10 |
--------------------------------------------------------------------------------
/examples/server-rendering/routes.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Route} from 'react-router';
3 | import {App, Parent, Child} from './components';
4 |
5 | export default (
6 |
7 |
8 |
9 |
10 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/examples/server-rendering/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-router-server-rendering-example",
3 | "version": "0.1.0",
4 | "description": "",
5 | "main": "server.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "node -r babel/register server.js"
9 | },
10 | "author": "Andrew Clark ",
11 | "license": "MIT",
12 | "dependencies": {}
13 | }
14 |
--------------------------------------------------------------------------------
/src/replaceRoutesMiddleware.js:
--------------------------------------------------------------------------------
1 | import { INIT_ROUTES, REPLACE_ROUTES } from './constants';
2 |
3 | export default function replaceRoutesMiddleware(replaceRoutes) {
4 | return () => next => action => {
5 | const isInitRoutes = action.type === INIT_ROUTES;
6 | if (isInitRoutes || action.type === REPLACE_ROUTES) {
7 | replaceRoutes(action.payload, isInitRoutes);
8 | }
9 | return next(action);
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/examples/basic/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-router-basic-example",
3 | "version": "0.1.0",
4 | "description": "",
5 | "main": "server.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1",
8 | "start": "node -r babel/register server.js"
9 | },
10 | "author": "Andrew Clark ",
11 | "license": "MIT",
12 | "dependencies": {
13 | "redux-router": "^1.0.0-beta8"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/historyMiddleware.js:
--------------------------------------------------------------------------------
1 | import { HISTORY_API } from './constants';
2 |
3 | /**
4 | * Middleware for interacting with the history API
5 | * @param {History} History object
6 | */
7 | export default function historyMiddleware(history) {
8 | return () => next => action => {
9 | if (action.type === HISTORY_API) {
10 | const { method, args } = action.payload;
11 | return history[method](...args);
12 | }
13 | return next(action);
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/src/isActive.js:
--------------------------------------------------------------------------------
1 | import _isActive from 'react-router/lib/isActive';
2 |
3 | /**
4 | * Creates a router state selector that returns whether or not the given
5 | * pathname and query are active.
6 | * @param {String} pathname
7 | * @param {Object} query
8 | * @param {Boolean} indexOnly
9 | * @return {Boolean}
10 | */
11 | export default function isActive(pathname, query, indexOnly = false) {
12 | return state => {
13 | if (!state) return false;
14 | const { location, params, routes } = state;
15 | return _isActive({ pathname, query }, indexOnly, location, routes, params);
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/src/routerStateEquals.js:
--------------------------------------------------------------------------------
1 | import deepEqual from 'deep-equal';
2 | import { DOES_NEED_REFRESH } from './constants';
3 |
4 | /**
5 | * Check if two router states are equal. Ignores `location.key`.
6 | * @returns {Boolean}
7 | */
8 | export default function routerStateEquals(a, b) {
9 | if (!a && !b) return true;
10 | if ((a && !b) || (!a && b)) return false;
11 | if (a[DOES_NEED_REFRESH] || b[DOES_NEED_REFRESH]) return false;
12 |
13 | return (
14 | a.location.pathname === b.location.pathname &&
15 | a.location.search === b.location.search &&
16 | deepEqual(a.location.state, b.location.state)
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/matchMiddleware.js:
--------------------------------------------------------------------------------
1 | import { routerDidChange } from './actionCreators';
2 | import { MATCH } from './constants';
3 |
4 | export default function matchMiddleware(match) {
5 | return ({ dispatch }) => next => action => {
6 | if (action.type === MATCH) {
7 | const { url, callback } = action.payload;
8 | match(url, (error, redirectLocation, routerState) => {
9 | if (!error && !redirectLocation && routerState) {
10 | dispatch(routerDidChange(routerState));
11 | }
12 | callback(error, redirectLocation, routerState);
13 | });
14 | }
15 | return next(action);
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | // Signals that the router's state has changed. It should
2 | // never be called by the application, only as an implementation detail of
3 | // redux-react-router.
4 | export const ROUTER_DID_CHANGE = '@@reduxReactRouter/routerDidChange';
5 |
6 | export const HISTORY_API = '@@reduxReactRouter/historyAPI';
7 | export const MATCH = '@@reduxReactRouter/match';
8 | export const INIT_ROUTES = '@@reduxReactRouter/initRoutes';
9 | export const REPLACE_ROUTES = '@@reduxReactRouter/replaceRoutes';
10 |
11 | export const ROUTER_STATE_SELECTOR = '@@reduxReactRouter/routerStateSelector';
12 |
13 | export const DOES_NEED_REFRESH = '@@reduxReactRouter/doesNeedRefresh';
14 |
--------------------------------------------------------------------------------
/examples/basic/webpack.config.clientDev.js:
--------------------------------------------------------------------------------
1 | import {
2 | HotModuleReplacementPlugin,
3 | NoErrorsPlugin
4 | } from 'webpack';
5 |
6 | import baseConfig from '../webpack.config.base';
7 | import mergeConfig from '../mergeConfig';
8 | import path from 'path';
9 |
10 | const clientDevConfig = mergeConfig(baseConfig, {
11 | entry: [
12 | 'webpack-hot-middleware/client',
13 | './index'
14 | ],
15 | output: {
16 | path: path.resolve(__dirname, 'build'),
17 | filename: 'bundle.js',
18 | publicPath: '/static/'
19 | },
20 | plugins: [
21 | new HotModuleReplacementPlugin(),
22 | new NoErrorsPlugin()
23 | ],
24 | devtool: 'eval'
25 | });
26 |
27 | export default clientDevConfig;
28 |
--------------------------------------------------------------------------------
/examples/server-rendering/webpack.config.clientDev.js:
--------------------------------------------------------------------------------
1 | import {
2 | HotModuleReplacementPlugin,
3 | NoErrorsPlugin
4 | } from 'webpack';
5 |
6 | import baseConfig from '../webpack.config.base';
7 | import mergeConfig from '../mergeConfig';
8 | import path from 'path';
9 |
10 | const clientDevConfig = mergeConfig(baseConfig, {
11 | entry: [
12 | 'webpack-hot-middleware/client',
13 | './client'
14 | ],
15 | output: {
16 | path: path.resolve(__dirname, 'build'),
17 | filename: 'bundle.js',
18 | publicPath: '/static/'
19 | },
20 | plugins: [
21 | new HotModuleReplacementPlugin(),
22 | new NoErrorsPlugin()
23 | ],
24 | devtool: 'eval'
25 | });
26 |
27 | export default clientDevConfig;
28 |
--------------------------------------------------------------------------------
/src/routerStateReducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | ROUTER_DID_CHANGE,
3 | REPLACE_ROUTES,
4 | DOES_NEED_REFRESH
5 | } from './constants';
6 |
7 | /**
8 | * Reducer of ROUTER_DID_CHANGE actions. Returns a state object
9 | * with { pathname, query, params, navigationType }
10 | * @param {Object} state - Previous state
11 | * @param {Object} action - Action
12 | * @return {Object} New state
13 | */
14 | export default function routerStateReducer(state = null, action) {
15 | switch (action.type) {
16 | case ROUTER_DID_CHANGE:
17 | return action.payload;
18 | case REPLACE_ROUTES:
19 | if (!state) return state;
20 | return {
21 | ...state,
22 | [DOES_NEED_REFRESH]: true
23 | };
24 | default:
25 | return state;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/examples/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-router-examples",
3 | "version": "0.1.0",
4 | "description": "",
5 | "scripts": {
6 | "test": "echo \"Error: no test specified\" && exit 1"
7 | },
8 | "author": "Andrew Clark ",
9 | "license": "MIT",
10 | "devDependencies": {
11 | "babel": "^5.8.23",
12 | "babel-core": "^5.8.23",
13 | "babel-loader": "^5.3.2",
14 | "babel-plugin-react-transform": "^1.0.3",
15 | "babel-runtime": "^5.8.20",
16 | "express": "^4.13.3",
17 | "lodash": "^3.10.1",
18 | "query-string": "^2.4.1",
19 | "react-transform-hmr": "^1.0.1",
20 | "redux": "^2.0.0",
21 | "serialize-javascript": "^1.1.2",
22 | "webpack": "^1.12.1",
23 | "webpack-dev-middleware": "^1.2.0",
24 | "webpack-hot-middleware": "^2.0.0"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/basic/server.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import webpack from 'webpack';
3 | import config from './webpack.config.clientDev';
4 | import path from 'path';
5 |
6 | import webpackDevMiddleware from 'webpack-dev-middleware';
7 | import webpackHotMiddleware from 'webpack-hot-middleware';
8 |
9 | const app = express();
10 | const compiler = webpack(config);
11 |
12 | app.use(webpackDevMiddleware(compiler, {
13 | noInfo: true,
14 | publicPath: config.output.publicPath
15 | }));
16 |
17 | app.use(webpackHotMiddleware(compiler));
18 |
19 | app.get('*', (req, res) => {
20 | res.sendFile(path.join(__dirname, 'index.html'));
21 | });
22 |
23 | app.listen(3000, 'localhost', error => {
24 | if (error) {
25 | console.log(error);
26 | return;
27 | }
28 |
29 | console.log('Listening at http://localhost:3000');
30 | });
31 |
--------------------------------------------------------------------------------
/src/useDefaults.js:
--------------------------------------------------------------------------------
1 | const defaults = {
2 | onError: error => { throw error; },
3 | routerStateSelector: state => state.router
4 | };
5 |
6 | export default function useDefaults(next) {
7 | return options => createStore => (reducer, initialState) => {
8 | const optionsWithDefaults = { ...defaults, ...options };
9 |
10 | const {
11 | createHistory: baseCreateHistory,
12 | history: baseHistory,
13 | } = optionsWithDefaults;
14 |
15 | let createHistory;
16 | if (typeof baseCreateHistory === 'function') {
17 | createHistory = baseCreateHistory;
18 | } else if (baseHistory) {
19 | createHistory = () => baseHistory;
20 | } else {
21 | createHistory = null;
22 | }
23 |
24 | return next({
25 | ...optionsWithDefaults,
26 | createHistory
27 | })(createStore)(reducer, initialState);
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-airbnb",
3 | "env": {
4 | "browser": true,
5 | "mocha": true,
6 | "node": true
7 | },
8 | "globals": {
9 | "expect": true
10 | },
11 | "rules": {
12 | "comma-dangle": 0,
13 | "no-wrap-func": 0,
14 | "spaced-comment": 0,
15 |
16 | "no-undef": 2,
17 |
18 | // Doesn't play nice with chai's assertions
19 | "no-unused-expressions": 0,
20 |
21 | // Discourages microcomponentization
22 | "react/no-multi-comp": 0,
23 |
24 | "react/jsx-uses-react": 2,
25 | "react/jsx-uses-vars": 2,
26 | "react/react-in-jsx-scope": 2,
27 |
28 | //Temporarirly disabled due to a possible bug in babel-eslint (todomvc example)
29 | "block-scoped-var": 0,
30 | // Temporarily disabled for test/* until babel/babel-eslint#33 is resolved
31 | "padded-blocks": 0
32 | },
33 | "plugins": [
34 | "react"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015-2016 Andrew Clark
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 |
--------------------------------------------------------------------------------
/examples/webpack.config.base.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 |
4 | const PROJECT_SRC = path.resolve(__dirname, '../src');
5 |
6 | const babelrc = fs.readFileSync(path.join('..', '.babelrc'));
7 | let babelLoaderQuery = {};
8 |
9 | try {
10 | babelLoaderQuery = JSON.parse(babelrc);
11 | } catch (err) {
12 | console.error('Error parsing .babelrc.');
13 | console.error(err);
14 | }
15 | babelLoaderQuery.plugins = babelLoaderQuery.plugins || [];
16 | babelLoaderQuery.plugins.push('react-transform');
17 | babelLoaderQuery.extra = babelLoaderQuery.extra || {};
18 | babelLoaderQuery.extra['react-transform'] = {
19 | transforms: [{
20 | transform: 'react-transform-hmr',
21 | imports: ['react'],
22 | locals: ['module']
23 | }]
24 | };
25 |
26 | export default {
27 | module: {
28 | loaders: [{
29 | test: /\.js$/,
30 | loader: 'babel',
31 | query: babelLoaderQuery,
32 | exclude: path.resolve(__dirname, 'node_modules'),
33 | include: [
34 | path.resolve(__dirname),
35 | PROJECT_SRC
36 | ]
37 | }]
38 | },
39 | resolve: {
40 | alias: {
41 | 'redux-router': PROJECT_SRC
42 | },
43 | extensions: ['', '.js']
44 | }
45 | };
46 |
--------------------------------------------------------------------------------
/examples/server-rendering/client.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { createStore, compose } from 'redux';
4 |
5 | import {
6 | ReduxRouter,
7 | reduxReactRouter,
8 | } from 'redux-router';
9 |
10 | import { Provider } from 'react-redux';
11 | import { devTools } from 'redux-devtools';
12 | import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react';
13 | import createHistory from 'history/lib/createBrowserHistory';
14 |
15 | import routes from './routes';
16 | import reducer from './reducer';
17 | import {MOUNT_ID} from './constants';
18 |
19 | const store = compose(
20 | reduxReactRouter({ createHistory }),
21 | devTools()
22 | )(createStore)(reducer, window.__initialState);
23 |
24 | const rootComponent = (
25 |
26 |
27 |
28 | );
29 |
30 | const mountNode = document.getElementById(MOUNT_ID);
31 |
32 | // First render to match markup from server
33 | ReactDOM.render(rootComponent, mountNode);
34 | // Optional second render with dev-tools
35 | ReactDOM.render((
36 |
37 | {rootComponent}
38 |
39 |
40 |
41 |
42 | ), mountNode);
43 |
--------------------------------------------------------------------------------
/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
22 |
23 |
24 | ## Version
25 |
26 | ## Steps to reproduce
27 |
28 | ## Expected Behavior
29 |
30 | ## Actual Behavior
31 |
--------------------------------------------------------------------------------
/src/reduxReactRouter.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware } from 'redux';
2 | import { useRouterHistory, createRoutes } from 'react-router';
3 | import createTransitionManager from 'react-router/lib/createTransitionManager' ;
4 | import historyMiddleware from './historyMiddleware';
5 | import { ROUTER_STATE_SELECTOR } from './constants';
6 |
7 | export default function reduxReactRouter({
8 | routes,
9 | createHistory,
10 | parseQueryString,
11 | stringifyQuery,
12 | routerStateSelector
13 | }) {
14 | return createStore => (reducer, initialState) => {
15 |
16 | let baseCreateHistory;
17 | if (typeof createHistory === 'function') {
18 | baseCreateHistory = createHistory;
19 | } else if (createHistory) {
20 | baseCreateHistory = () => createHistory;
21 | }
22 |
23 | const createAppHistory = useRouterHistory(baseCreateHistory);
24 |
25 | const history = createAppHistory({
26 | parseQueryString,
27 | stringifyQuery,
28 | });
29 |
30 | const transitionManager = createTransitionManager(
31 | history, createRoutes(routes)
32 | );
33 |
34 | const store =
35 | applyMiddleware(
36 | historyMiddleware(history)
37 | )(createStore)(reducer, initialState);
38 |
39 | store.transitionManager = transitionManager;
40 | store.history = history;
41 | store[ROUTER_STATE_SELECTOR] = routerStateSelector;
42 |
43 | return store;
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/examples/server-rendering/components.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import {connect} from 'react-redux';
3 | import {Link} from 'react-router';
4 |
5 | @connect(state => ({ routerState: state.router }))
6 | export const App = class App extends Component {
7 | static propTypes = {
8 | children: PropTypes.node
9 | }
10 |
11 | render() {
12 | // Display is only used for rendering, its not a property of
13 | const links = [
14 | { pathname: '/', display: '/'},
15 | { pathname: '/parent', query: { foo: 'bar' }, display: '/parent?foo=bar'},
16 | { pathname: '/parent/child', query: { bar: 'baz' }, display: '/parent/child?bar=baz'},
17 | { pathname: '/parent/child/123', query: { baz: 'foo' }, display: '/parent/child/123?baz=foo'}
18 | ].map((l, i) =>
19 |
20 | {l.display}
21 |
22 | );
23 |
24 | return (
25 |
26 |
App Container
27 | {links}
28 | {this.props.children}
29 |
30 | );
31 | }
32 | };
33 |
34 | export const Parent = class Parent extends Component {
35 | static propTypes = {
36 | children: PropTypes.node
37 | }
38 |
39 | render() {
40 | return (
41 |
42 |
Parent
43 | {this.props.children}
44 |
45 | );
46 | }
47 | };
48 |
49 | export const Child = class Child extends Component {
50 | render() {
51 | const { params: { id }} = this.props;
52 |
53 | return (
54 |
55 |
Child
56 | {id &&
{id}
}
57 |
58 | );
59 | }
60 | };
61 |
--------------------------------------------------------------------------------
/src/actionCreators.js:
--------------------------------------------------------------------------------
1 | import { ROUTER_DID_CHANGE, INIT_ROUTES, REPLACE_ROUTES, HISTORY_API } from './constants';
2 |
3 | /**
4 | * Action creator for signaling that the router has changed.
5 | * @private
6 | * @param {RouterState} state - New router state
7 | * @return {Action} Action object
8 | */
9 | export function routerDidChange(state) {
10 | return {
11 | type: ROUTER_DID_CHANGE,
12 | payload: state
13 | };
14 | }
15 |
16 | /**
17 | * Action creator that initiates route config
18 | * @private
19 | * @param {Array|ReactElement} routes - New routes
20 | */
21 | export function initRoutes(routes) {
22 | return {
23 | type: INIT_ROUTES,
24 | payload: routes
25 | };
26 | }
27 |
28 | /**
29 | * Action creator that replaces the current route config
30 | * @private
31 | * @param {Array|ReactElement} routes - New routes
32 | */
33 | export function replaceRoutes(routes) {
34 | return {
35 | type: REPLACE_ROUTES,
36 | payload: routes
37 | };
38 | }
39 |
40 | /**
41 | * Creates an action creator for calling a history API method.
42 | * @param {string} method - Name of method
43 | * @returns {ActionCreator} Action creator with same parameters as corresponding
44 | * history method
45 | */
46 | export function historyAPI(method) {
47 | return (...args) => ({
48 | type: HISTORY_API,
49 | payload: {
50 | method,
51 | args
52 | }
53 | });
54 | }
55 |
56 | export const push = historyAPI('push');
57 | export const replace = historyAPI('replace');
58 | export const setState = historyAPI('setState');
59 | export const go = historyAPI('go');
60 | export const goBack = historyAPI('goBack');
61 | export const goForward = historyAPI('goForward');
62 |
--------------------------------------------------------------------------------
/src/client.js:
--------------------------------------------------------------------------------
1 | import { compose } from 'redux';
2 | import { routerDidChange } from './actionCreators';
3 | import routerStateEquals from './routerStateEquals';
4 | import reduxReactRouter from './reduxReactRouter';
5 | import useDefaults from './useDefaults';
6 | import routeReplacement from './routeReplacement';
7 |
8 | function historySynchronization(next) {
9 | return options => createStore => (reducer, initialState) => {
10 | const { onError, routerStateSelector } = options;
11 | const store = next(options)(createStore)(reducer, initialState);
12 | const { history, transitionManager } = store;
13 |
14 | let prevRouterState;
15 | let routerState;
16 |
17 | transitionManager.listen((error, nextRouterState) => {
18 | if (error) {
19 | onError(error);
20 | return;
21 | }
22 |
23 | if (!routerStateEquals(routerState, nextRouterState)) {
24 | prevRouterState = routerState;
25 | routerState = nextRouterState;
26 | store.dispatch(routerDidChange(nextRouterState));
27 | }
28 | });
29 |
30 | store.subscribe(() => {
31 | const nextRouterState = routerStateSelector(store.getState());
32 |
33 | if (
34 | nextRouterState &&
35 | prevRouterState !== nextRouterState &&
36 | !routerStateEquals(routerState, nextRouterState)
37 | ) {
38 | routerState = nextRouterState;
39 | const { state, pathname, query } = nextRouterState.location;
40 | history.replace({state, pathname, query});
41 | }
42 | });
43 |
44 | return store;
45 | };
46 | }
47 |
48 | export default compose(
49 | useDefaults,
50 | routeReplacement,
51 | historySynchronization
52 | )(reduxReactRouter);
53 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | import { compose, applyMiddleware } from 'redux';
2 | import baseReduxReactRouter from './reduxReactRouter';
3 | import useDefaults from './useDefaults';
4 | import routeReplacement from './routeReplacement';
5 | import matchMiddleware from './matchMiddleware';
6 | import { MATCH } from './constants';
7 |
8 | function serverInvariants(next) {
9 | return options => createStore => {
10 | if (!options || !(options.routes || options.getRoutes)) {
11 | throw new Error(
12 | 'When rendering on the server, routes must be passed to the '
13 | + 'reduxReactRouter() store enhancer; routes as a prop or as children of '
14 | + ' is not supported. To deal with circular dependencies '
15 | + 'between routes and the store, use the option getRoutes(store).'
16 | );
17 | }
18 | if (!options || !(options.createHistory)) {
19 | throw new Error(
20 | 'When rendering on the server, createHistory must be passed to the '
21 | + 'reduxReactRouter() store enhancer'
22 | );
23 | }
24 |
25 | return next(options)(createStore);
26 | };
27 | }
28 |
29 | function matching(next) {
30 | return options => createStore => (reducer, initialState) => {
31 | const store = compose(
32 | applyMiddleware(
33 | matchMiddleware((url, callback) => {
34 | const location = store.history.createLocation(url);
35 |
36 | store.transitionManager.match(location, callback);
37 | })
38 | ),
39 | next(options))(createStore)(reducer, initialState);
40 | return store;
41 | };
42 | }
43 |
44 | export function match(url, callback) {
45 | return {
46 | type: MATCH,
47 | payload: {
48 | url,
49 | callback
50 | }
51 | };
52 | }
53 |
54 | export const reduxReactRouter = compose(
55 | serverInvariants,
56 | useDefaults,
57 | routeReplacement,
58 | matching
59 | )(baseReduxReactRouter);
60 |
--------------------------------------------------------------------------------
/src/routeReplacement.js:
--------------------------------------------------------------------------------
1 | import { applyMiddleware, compose } from 'redux';
2 | import { createRoutes } from 'react-router';
3 | import replaceRoutesMiddleware from './replaceRoutesMiddleware';
4 |
5 | export default function routeReplacement(next) {
6 | return options => createStore => (reducer, initialState) => {
7 | const {
8 | routes: baseRoutes,
9 | getRoutes,
10 | routerStateSelector
11 | } = options;
12 |
13 | let store;
14 |
15 | let childRoutes = [];
16 | let areChildRoutesResolved = false;
17 | const childRoutesCallbacks = [];
18 |
19 | function replaceRoutes(r, isInit) {
20 | childRoutes = createRoutes(r);
21 |
22 | const routerState = routerStateSelector(store.getState());
23 | if (routerState && !isInit) {
24 | const { state, pathname, query } = routerState.location;
25 | store.history.replace({state, pathname, query});
26 | }
27 |
28 | if (!areChildRoutesResolved) {
29 | areChildRoutesResolved = true;
30 | childRoutesCallbacks.forEach(cb => cb(null, childRoutes));
31 | }
32 | }
33 |
34 | let routes;
35 | if (baseRoutes) {
36 | routes = baseRoutes;
37 | } else if (getRoutes) {
38 | routes = getRoutes({
39 | dispatch: action => store.dispatch(action),
40 | getState: () => store.getState()
41 | });
42 | } else {
43 | routes = [{
44 | getChildRoutes: (location, cb) => {
45 | if (!areChildRoutesResolved) {
46 | childRoutesCallbacks.push(cb);
47 | return;
48 | }
49 |
50 | cb(null, childRoutes);
51 | }
52 | }];
53 | }
54 |
55 | store = compose(
56 | applyMiddleware(
57 | replaceRoutesMiddleware(replaceRoutes)
58 | ),
59 | next({
60 | ...options,
61 | routes: createRoutes(routes)
62 | })
63 | )(createStore)(reducer, initialState);
64 |
65 | return store;
66 | };
67 | }
68 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-router",
3 | "version": "2.1.2",
4 | "description": "Redux bindings for React Router — keep your router state inside your Redux Store.",
5 | "main": "lib/index.js",
6 | "scripts": {
7 | "build": "npm run build:commonjs && npm run build:umd && npm run build:umd:min",
8 | "build:commonjs": "babel src --out-dir lib && cp lib/serverModule.js server.js",
9 | "build:umd": "NODE_ENV=development browserify -s ReduxRouter --detect-globals lib/index.js -o dist/redux-router.js",
10 | "build:umd:min": "NODE_ENV=production browserify -s ReduxRouter --detect-globals lib/index.js | uglifyjs -c warnings=false -m > dist/redux-router.min.js",
11 | "clean": "rimraf lib && rimraf dist && rimraf server.js",
12 | "lint": "eslint src",
13 | "test": "mocha --compilers js:babel/register --recursive --require src/__tests__/init.js src/**/*-test.js",
14 | "test-watch": "mocha --compilers js:babel/register --recursive --require src/__tests__/init.js -w src/**/*-test.js",
15 | "prepublish": "npm run clean && mkdir dist && npm run build"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "git://github.com/acdlite/redux-router.git"
20 | },
21 | "keywords": [
22 | "redux",
23 | "react-router",
24 | "react",
25 | "router"
26 | ],
27 | "author": "Andrew Clark ",
28 | "license": "MIT",
29 | "files": [
30 | "dist",
31 | "lib",
32 | "src",
33 | "LICENSE",
34 | "*.md",
35 | "server.js"
36 | ],
37 | "devDependencies": {
38 | "babel": "^5.6.14",
39 | "babel-core": "5.6.15",
40 | "babel-eslint": "^4.1.1",
41 | "babel-loader": "^5.3.2",
42 | "browserify": "^13.0.1",
43 | "chai": "^3.0.0",
44 | "eslint": "^1.3.1",
45 | "eslint-config-airbnb": "0.0.8",
46 | "eslint-plugin-react": "^3.3.1",
47 | "history": "^2.0.0",
48 | "jsdom": "^5.6.0",
49 | "mocha": "^2.2.5",
50 | "mocha-jsdom": "^1.0.0",
51 | "node-libs-browser": "^0.5.2",
52 | "react": "^0.14.1",
53 | "react-addons-test-utils": "^0.14.1",
54 | "react-dom": "^0.14.1",
55 | "react-redux": "^4.0.0",
56 | "react-router": "^2.0.0",
57 | "react-transform-hmr": "^1.0.1",
58 | "redux": "^3.0.0",
59 | "redux-devtools": "^2.1.0",
60 | "rimraf": "^2.4.3",
61 | "sinon": "^1.15.4",
62 | "uglify-js": "^2.6.2",
63 | "webpack": "^1.12.1"
64 | },
65 | "dependencies": {
66 | "deep-equal": "^1.0.1"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/examples/server-rendering/server.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import webpack from 'webpack';
3 | import React from 'react';
4 | import {renderToString} from 'react-dom/server';
5 | import {Provider} from 'react-redux';
6 | import {createStore} from 'redux';
7 | import {ReduxRouter} from '../../src'; // 'redux-router'
8 | import {reduxReactRouter, match} from '../../src/server'; // 'redux-router/server';
9 | import qs from 'query-string';
10 | import serialize from 'serialize-javascript';
11 | import { createMemoryHistory } from 'history';
12 |
13 | import webpackDevMiddleware from 'webpack-dev-middleware';
14 | import webpackHotMiddleware from 'webpack-hot-middleware';
15 |
16 | import config from './webpack.config.clientDev';
17 | import {MOUNT_ID} from './constants';
18 | import reducer from './reducer';
19 | import routes from './routes';
20 |
21 | const app = express();
22 | const compiler = webpack(config);
23 |
24 | const getMarkup = (store) => {
25 | const initialState = serialize(store.getState());
26 |
27 | const markup = renderToString(
28 |
29 |
30 |
31 | );
32 |
33 | return `
34 |
35 |
36 | Redux React Router – Server rendering Example
37 |
38 |
39 | ${markup}
40 |
41 |
42 |
43 |
44 | `;
45 | };
46 |
47 | app.use(webpackDevMiddleware(compiler, {
48 | noInfo: true,
49 | publicPath: config.output.publicPath
50 | }));
51 |
52 | app.use(webpackHotMiddleware(compiler));
53 |
54 | app.use((req, res) => {
55 | const store = reduxReactRouter({ routes, createHistory: createMemoryHistory })(createStore)(reducer);
56 | const query = qs.stringify(req.query);
57 | const url = req.path + (query.length ? '?' + query : '');
58 |
59 | store.dispatch(match(url, (error, redirectLocation, routerState) => {
60 | if (error) {
61 | console.error('Router error:', error);
62 | res.status(500).send(error.message);
63 | } else if (redirectLocation) {
64 | res.redirect(302, redirectLocation.pathname + redirectLocation.search);
65 | } else if (!routerState) {
66 | res.status(400).send('Not Found');
67 | } else {
68 | res.status(200).send(getMarkup(store));
69 | }
70 | }));
71 | });
72 |
73 | app.listen(3000, 'localhost', error => {
74 | if (error) {
75 | console.log(error);
76 | return;
77 | }
78 |
79 | console.log('Listening at http://localhost:3000');
80 | });
81 |
--------------------------------------------------------------------------------
/src/ReduxRouter.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { RouterContext as DefaultRoutingContext } from 'react-router';
4 | import { createRouterObject } from 'react-router/lib/RouterUtils';
5 | import routerStateEquals from './routerStateEquals';
6 | import { ROUTER_STATE_SELECTOR } from './constants';
7 | import { initRoutes, replaceRoutes } from './actionCreators';
8 |
9 | function memoizeRouterStateSelector(selector) {
10 | let previousRouterState = null;
11 |
12 | return state => {
13 | const nextRouterState = selector(state);
14 | if (routerStateEquals(previousRouterState, nextRouterState)) {
15 | return previousRouterState;
16 | }
17 | previousRouterState = nextRouterState;
18 | return nextRouterState;
19 | };
20 | }
21 |
22 | function getRoutesFromProps(props) {
23 | return props.routes || props.children;
24 | }
25 |
26 | class ReduxRouter extends Component {
27 | static propTypes = {
28 | children: PropTypes.node
29 | }
30 |
31 | static contextTypes = {
32 | store: PropTypes.object
33 | }
34 |
35 | constructor(props, context) {
36 | super(props, context);
37 | this.router = createRouterObject(context.store.history, context.store.transitionManager);
38 | }
39 |
40 | componentWillMount() {
41 | this.context.store.dispatch(initRoutes(getRoutesFromProps(this.props)));
42 | }
43 |
44 | componentWillReceiveProps(nextProps) {
45 | this.receiveRoutes(getRoutesFromProps(nextProps));
46 | }
47 |
48 | receiveRoutes(routes) {
49 | if (!routes) return;
50 |
51 | const { store } = this.context;
52 | store.dispatch(replaceRoutes(routes));
53 | }
54 |
55 | render() {
56 | const { store } = this.context;
57 |
58 | if (!store) {
59 | throw new Error(
60 | 'Redux store missing from context of . Make sure you\'re '
61 | + 'using a '
62 | );
63 | }
64 |
65 | const {
66 | history,
67 | [ROUTER_STATE_SELECTOR]: routerStateSelector
68 | } = store;
69 |
70 | if (!history || !routerStateSelector) {
71 | throw new Error(
72 | 'Redux store not configured properly for . Make sure '
73 | + 'you\'re using the reduxReactRouter() store enhancer.'
74 | );
75 | }
76 |
77 | return (
78 |
84 | );
85 | }
86 | }
87 |
88 | @connect(
89 | (state, { routerStateSelector }) => routerStateSelector(state) || {}
90 | )
91 | class ReduxRouterContext extends Component {
92 | static propTypes = {
93 | location: PropTypes.object,
94 | RoutingContext: PropTypes.func
95 | }
96 |
97 | render() {
98 | const {location} = this.props;
99 |
100 | if (location === null || location === undefined) {
101 | return null; // Async matching
102 | }
103 |
104 | const RoutingContext = this.props.RoutingContext || DefaultRoutingContext;
105 |
106 | return ;
107 | }
108 | }
109 |
110 | export default ReduxRouter;
111 |
--------------------------------------------------------------------------------
/examples/basic/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { createStore, compose, combineReducers } from 'redux';
4 |
5 | import {
6 | ReduxRouter,
7 | routerStateReducer,
8 | reduxReactRouter,
9 | push,
10 | } from 'redux-router';
11 |
12 | import { Route, Link } from 'react-router';
13 | import { Provider, connect } from 'react-redux';
14 | import { devTools } from 'redux-devtools';
15 | import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react';
16 | import { createHistory } from 'history';
17 |
18 | @connect((state) => ({}))
19 | class App extends Component {
20 | static propTypes = {
21 | children: PropTypes.node
22 | }
23 |
24 | constructor(props) {
25 | super(props);
26 | this.handleClick = this.handleClick.bind(this);
27 | }
28 |
29 | handleClick(event) {
30 | event.preventDefault();
31 | const { dispatch } = this.props;
32 |
33 | dispatch(push({ pathname: '/parent/child/custom' }));
34 | }
35 |
36 | render() {
37 | // Display is only used for rendering, its not a property of
38 | const links = [
39 | { pathname: '/', display: '/'},
40 | { pathname: '/parent', query: { foo: 'bar' }, display: '/parent?foo=bar'},
41 | { pathname: '/parent/child', query: { bar: 'baz' }, display: '/parent/child?bar=baz'},
42 | { pathname: '/parent/child/123', query: { baz: 'foo' }, display: '/parent/child/123?baz=foo'}
43 | ].map((l, i) =>
44 |
45 | {l.display}
46 |
47 | );
48 |
49 | return (
50 |
59 | );
60 | }
61 | }
62 |
63 | class Parent extends Component {
64 | static propTypes = {
65 | children: PropTypes.node
66 | }
67 |
68 | render() {
69 | return (
70 |
71 |
Parent
72 | {this.props.children}
73 |
74 | );
75 | }
76 | }
77 |
78 | class Child extends Component {
79 | render() {
80 | const { params: { id }} = this.props;
81 |
82 | return (
83 |
84 |
Child
85 | {id &&
{id}
}
86 |
87 | );
88 | }
89 | }
90 |
91 | const reducer = combineReducers({
92 | router: routerStateReducer
93 | });
94 |
95 | const store = compose(
96 | reduxReactRouter({ createHistory }),
97 | devTools()
98 | )(createStore)(reducer);
99 |
100 | class Root extends Component {
101 | render() {
102 | return (
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | );
119 | }
120 | }
121 |
122 | ReactDOM.render(, document.getElementById('root'));
123 |
--------------------------------------------------------------------------------
/src/__tests__/reduxReactRouter-test.js:
--------------------------------------------------------------------------------
1 | import {
2 | reduxReactRouter,
3 | routerStateReducer,
4 | push,
5 | replace,
6 | isActive
7 | } from '../';
8 | import { REPLACE_ROUTES } from '../constants';
9 |
10 | import { createStore, combineReducers, compose, applyMiddleware } from 'redux';
11 | import React from 'react';
12 | import { Route } from 'react-router';
13 | import createHistory from 'history/lib/createMemoryHistory';
14 | import useBasename from 'history/lib/useBasename';
15 | import sinon from 'sinon';
16 |
17 | const routes = (
18 |
19 |
20 |
21 |
22 |
23 | );
24 |
25 | describe('reduxRouter()', () => {
26 | it('adds router state to Redux store', () => {
27 | const reducer = combineReducers({
28 | router: routerStateReducer
29 | });
30 |
31 | const store = reduxReactRouter({
32 | createHistory,
33 | routes
34 | })(createStore)(reducer);
35 |
36 | const history = store.history;
37 |
38 | const historySpy = sinon.spy();
39 | history.listen(() => historySpy());
40 |
41 | expect(historySpy.callCount).to.equal(1);
42 |
43 |
44 | store.dispatch(push({ pathname: '/parent' }));
45 | expect(store.getState().router.location.pathname).to.equal('/parent');
46 | expect(historySpy.callCount).to.equal(2);
47 |
48 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
49 | expect(historySpy.callCount).to.equal(3);
50 | expect(store.getState().router.location.pathname)
51 | .to.equal('/parent/child/123');
52 | expect(store.getState().router.location.query).to.eql({ key: 'value' });
53 | expect(store.getState().router.params).to.eql({ id: '123' });
54 | });
55 |
56 | it('detects external router state changes', () => {
57 | const baseReducer = combineReducers({
58 | router: routerStateReducer
59 | });
60 |
61 | const EXTERNAL_STATE_CHANGE = 'EXTERNAL_STATE_CHANGE';
62 |
63 | const externalState = {
64 | location: {
65 | pathname: '/parent/child/123',
66 | query: { key: 'value' },
67 | key: 'lolkey'
68 | }
69 | };
70 |
71 | const reducerSpy = sinon.spy();
72 | function reducer(state, action) {
73 | reducerSpy();
74 |
75 | if (action.type === EXTERNAL_STATE_CHANGE) {
76 | return { ...state, router: action.payload };
77 | }
78 |
79 | return baseReducer(state, action);
80 | }
81 |
82 | const history = createHistory();
83 | const historySpy = sinon.spy();
84 |
85 | let historyState;
86 | history.listen(s => {
87 | historySpy();
88 | historyState = s;
89 | });
90 |
91 | const store = reduxReactRouter({
92 | history,
93 | routes
94 | })(createStore)(reducer);
95 |
96 | expect(reducerSpy.callCount).to.equal(2);
97 | expect(historySpy.callCount).to.equal(1);
98 |
99 | store.dispatch({
100 | type: EXTERNAL_STATE_CHANGE,
101 | payload: externalState
102 | });
103 |
104 | expect(reducerSpy.callCount).to.equal(4);
105 | expect(historySpy.callCount).to.equal(2);
106 | expect(historyState.pathname).to.equal('/parent/child/123');
107 | expect(historyState.search).to.equal('?key=value');
108 | });
109 |
110 | it('works with navigation action creators', () => {
111 | const reducer = combineReducers({
112 | router: routerStateReducer
113 | });
114 |
115 | const store = reduxReactRouter({
116 | createHistory,
117 | routes
118 | })(createStore)(reducer);
119 |
120 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
121 | expect(store.getState().router.location.pathname)
122 | .to.equal('/parent/child/123');
123 | expect(store.getState().router.location.query).to.eql({ key: 'value' });
124 | expect(store.getState().router.params).to.eql({ id: '123' });
125 |
126 | store.dispatch(replace({ pathname: '/parent/child/321', query: { key: 'value2'} }));
127 | expect(store.getState().router.location.pathname)
128 | .to.equal('/parent/child/321');
129 | expect(store.getState().router.location.query).to.eql({ key: 'value2' });
130 | expect(store.getState().router.params).to.eql({ id: '321' });
131 | });
132 |
133 | it('doesn\'t interfere with other actions', () => {
134 | const APPEND_STRING = 'APPEND_STRING';
135 |
136 | function stringBuilderReducer(state = '', action) {
137 | if (action.type === APPEND_STRING) {
138 | return state + action.string;
139 | }
140 | return state;
141 | }
142 |
143 | const reducer = combineReducers({
144 | router: routerStateReducer,
145 | string: stringBuilderReducer
146 | });
147 |
148 | const history = createHistory();
149 |
150 | const store = reduxReactRouter({
151 | history,
152 | routes
153 | })(createStore)(reducer);
154 |
155 | store.dispatch({ type: APPEND_STRING, string: 'Uni' });
156 | store.dispatch({ type: APPEND_STRING, string: 'directional' });
157 | expect(store.getState().string).to.equal('Unidirectional');
158 | });
159 |
160 | it('stores the latest state in routerState', () => {
161 | const reducer = combineReducers({
162 | router: routerStateReducer
163 | });
164 |
165 | const history = createHistory();
166 |
167 | const store = reduxReactRouter({
168 | history,
169 | routes
170 | })(createStore)(reducer);
171 |
172 | let historyState;
173 | history.listen(s => {
174 | historyState = s;
175 | });
176 |
177 | store.dispatch(push({ pathname: '/parent' }));
178 |
179 | store.dispatch({
180 | type: REPLACE_ROUTES
181 | });
182 |
183 | historyState = null;
184 |
185 | store.dispatch({ type: 'RANDOM_ACTION' });
186 | expect(historyState).to.equal(null);
187 | });
188 |
189 | it('handles async middleware', (done) => {
190 | const reducer = combineReducers({
191 | router: routerStateReducer
192 | });
193 |
194 | const history = createHistory();
195 | const historySpy = sinon.spy();
196 |
197 | history.listen(() => historySpy());
198 | expect(historySpy.callCount).to.equal(1);
199 |
200 | compose(
201 | reduxReactRouter({
202 | history,
203 | routes,
204 | }),
205 | applyMiddleware(
206 | () => next => action => setTimeout(() => next(action), 0)
207 | )
208 | )(createStore)(reducer);
209 |
210 | history.push({ pathname: '/parent' });
211 | expect(historySpy.callCount).to.equal(2);
212 |
213 | setTimeout(() => {
214 | expect(historySpy.callCount).to.equal(2);
215 | done();
216 | }, 0);
217 | });
218 |
219 | it('accepts history object when using basename', () => {
220 | const reducer = combineReducers({
221 | router: routerStateReducer
222 | });
223 |
224 | const history = useBasename(createHistory)({
225 | basename: '/grandparent'
226 | });
227 |
228 | const store = reduxReactRouter({
229 | history,
230 | routes
231 | })(createStore)(reducer);
232 |
233 | store.dispatch(push({ pathname: '/parent' }));
234 | expect(store.getState().router.location.pathname).to.eql('/parent');
235 |
236 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
237 | expect(store.getState().router.location.pathname)
238 | .to.eql('/parent/child/123');
239 | expect(store.getState().router.location.basename).to.eql('/grandparent');
240 | expect(store.getState().router.location.query).to.eql({ key: 'value' });
241 | expect(store.getState().router.params).to.eql({ id: '123' });
242 | });
243 |
244 | describe('getRoutes()', () => {
245 | it('is passed dispatch and getState', () => {
246 | const reducer = combineReducers({
247 | router: routerStateReducer
248 | });
249 |
250 | let store;
251 | const history = createHistory();
252 |
253 | reduxReactRouter({
254 | history,
255 | getRoutes: s => {
256 | store = s;
257 | return routes;
258 | }
259 | })(createStore)(reducer);
260 |
261 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
262 | expect(store.getState().router.location.pathname)
263 | .to.equal('/parent/child/123');
264 | });
265 | });
266 |
267 | describe('onEnter hook', () => {
268 | it('can perform redirects', () => {
269 | const reducer = combineReducers({
270 | router: routerStateReducer
271 | });
272 |
273 | const history = createHistory();
274 |
275 | const requireAuth = (nextState, redirect) => {
276 | redirect({ pathname: '/login' });
277 | };
278 |
279 | const store = reduxReactRouter({
280 | history,
281 | routes: (
282 |
283 |
284 |
285 |
286 |
287 |
288 | )
289 | })(createStore)(reducer);
290 |
291 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
292 | expect(store.getState().router.location.pathname)
293 | .to.equal('/login');
294 | });
295 |
296 | describe('isActive', () => {
297 | it('creates a selector for whether a pathname/query pair is active', () => {
298 | const reducer = combineReducers({
299 | router: routerStateReducer
300 | });
301 |
302 | const history = createHistory();
303 |
304 | const store = reduxReactRouter({
305 | history,
306 | routes
307 | })(createStore)(reducer);
308 |
309 | const activeSelector = isActive('/parent', { key: 'value' });
310 | expect(activeSelector(store.getState().router)).to.be.false;
311 | store.dispatch(push({ pathname: '/parent', query: { key: 'value' } }));
312 | expect(activeSelector(store.getState().router)).to.be.true;
313 | });
314 | });
315 | });
316 | });
317 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | redux-router
2 | ============
3 |
4 | [](https://travis-ci.org/acdlite/redux-router)
5 | [](https://www.npmjs.com/package/redux-router)
6 | [](https://discord.gg/0ZcbPKXt5bVkq8Eo)
7 |
8 | ## This project is experimental.
9 |
10 | ### For bindings for React Router 1.x see [here](https://github.com/acdlite/redux-router/tree/263f623f6e8a8ec165568216888e572a48c5cd54)
11 |
12 | ### In most cases, you don’t need any library to bridge Redux and React Router. Just use React Router directly.
13 |
14 | ## Please check out [the differences between react-router-redux and redux-router](#differences-with-react-router-redux) before using this library
15 |
16 | [Redux](redux.js.org) bindings for [React Router](https://github.com/reactjs/react-router).
17 |
18 | - Keep your router state inside your Redux Store.
19 | - Interact with the Router with the same API you use to interact with the rest of your app state.
20 | - Completely interoperable with existing React Router API. ``, `router.transitionTo()`, etc. still work.
21 | - Serialize and deserialize router state.
22 | - Works with time travel feature of Redux Devtools!
23 |
24 | ```sh
25 | # installs version 2.x.x
26 | npm install --save redux-router
27 | ```
28 | Install the version 1.0.0 via the `previous` tag
29 | ```sh
30 | npm install --save redux-router@previous
31 | ```
32 |
33 |
34 | ## Why
35 |
36 | React Router is a fantastic routing library, but one downside is that it abstracts away a very crucial piece of application state — the current route! This abstraction is super useful for route matching and rendering, but the API for interacting with the router to 1) trigger transitions and 2) react to state changes within the component lifecycle leaves something to be desired.
37 |
38 | It turns out we already solved these problems with Flux (and Redux): We use action creators to trigger state changes, and we use higher-order components to subscribe to state changes.
39 |
40 | This library allows you to keep your router state **inside your Redux store**. So getting the current pathname, query, and params is as easy as selecting any other part of your application state.
41 |
42 | ## Example
43 |
44 | ```js
45 | import React from 'react';
46 | import { combineReducers, applyMiddleware, compose, createStore } from 'redux';
47 | import { reduxReactRouter, routerStateReducer, ReduxRouter } from 'redux-router';
48 | import { createHistory } from 'history';
49 | import { Route } from 'react-router';
50 |
51 | // Configure routes like normal
52 | const routes = (
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 |
61 | // Configure reducer to store state at state.router
62 | // You can store it elsewhere by specifying a custom `routerStateSelector`
63 | // in the store enhancer below
64 | const reducer = combineReducers({
65 | router: routerStateReducer,
66 | //app: rootReducer, //you can combine all your other reducers under a single namespace like so
67 | });
68 |
69 | // Compose reduxReactRouter with other store enhancers
70 | const store = compose(
71 | applyMiddleware(m1, m2, m3),
72 | reduxReactRouter({
73 | routes,
74 | createHistory
75 | }),
76 | devTools()
77 | )(createStore)(reducer);
78 |
79 |
80 | // Elsewhere, in a component module...
81 | import { connect } from 'react-redux';
82 | import { push } from 'redux-router';
83 |
84 | connect(
85 | // Use a selector to subscribe to state
86 | state => ({ q: state.router.location.query.q }),
87 |
88 | // Use an action creator for navigation
89 | { push }
90 | )(SearchBox);
91 | ```
92 |
93 | You will find a **server-rendering** example in the repo´s example directory.
94 |
95 | ### Works with Redux Devtools (and other external state changes)
96 |
97 | redux-router will notice if the router state in your Redux store changes from an external source other than the router itself — e.g. the Redux Devtools — and trigger a transition accordingly!
98 |
99 | ## Differences with react-router-redux
100 |
101 | #### react-router-redux
102 |
103 | [react-router-redux](https://github.com/reactjs/react-router-redux) (formerly redux-simple-router) takes a different approach to
104 | integrating routing with redux. react-router-redux lets React Router do all the heavy lifting and syncs the url data to a history
105 | [location](https://github.com/mjackson/history/blob/master/docs/Location.md#location) object in the store. This means that users can use
106 | React Router's APIs directly and benefit from the wide array of documentation and examples there.
107 |
108 | The README for react-router-redux has a useful picture included here:
109 |
110 | [redux](https://github.com/reactjs/redux) (`store.routing`) ↔ [**react-router-redux**](https://github.com/reactjs/react-router-redux) ↔ [history](https://github.com/reactjs/history) (`history.location`) ↔ [react-router](https://github.com/reactjs/react-router)
111 |
112 | This approach, while simple to use, comes with a few caveats:
113 | 1. The history location object does not include React Router params and they must be either passed down from a React Router component or recomputed.
114 | 2. It is discouraged (and dangerous) to connect the store data to a component because the store data potentially updates **after** the React Router properties have changed, therefore there can be race conditions where the location store data differs from the location object passed down via React Router to components.
115 |
116 | react-router-redux encourages users to use props directly from React Router in the components (they are passed down to any rendered route components). This means that if you want to access the location data far down the component tree, you may need to pass it down or use React's context feature.
117 |
118 | #### redux-router
119 |
120 | This project, on the other hand takes the approach of storing the **entire** React Router data inside the redux store. This has the main benefit that it becomes impossible for the properties passed down by redux-router to the components in the Route to differ from the data included in the store. Therefore feel free to connect the router data to any component you wish. You can also access the route params from the store directly. redux-router also provides an API for hot swapping the routes from the Router (something React Router does not currently provide).
121 |
122 | The picture of redux-router would look more like this:
123 |
124 | [redux](https://github.com/reactjs/redux) (`store.router`) ↔ [**redux-router**](https://github.com/acdlite/redux-router) ↔ [react-router (via RouterContext)](https://github.com/reactjs/react-router)
125 |
126 | This approach, also has its set of limitations:
127 | 1. The router data is not all serializable (because Components and functions are not direclty serializable) and therefore this can cause issues with some devTools extensions and libraries that help in saving the store to the browser session. This can be mitigated if the libraries offer ways to ignore seriliazing parts of the store but is not always possible.
128 | 2. redux-router takes advantage of the RouterContext to still use much of React Router's internal logic. However, redux-router must still implement many things that React Router already does on its own and can cause delays in upgrade paths.
129 | 3. redux-router must provide a slightly different top level API (due to 2) even if the Route logic/matching is identical
130 |
131 |
132 | Ultimately, your choice in the library is up to you and your project's needs. react-router-redux will continue to have a larger support
133 | in the community due to its inclusion into the reactjs github organization and visibility. **react-router-redux is the recommended approach**
134 | for react-router and redux integration. However, you may find that redux-router aligns better with your project's needs.
135 | redux-router will continue to be mantained as long as demand exists.
136 |
137 | ## API
138 |
139 | ### `reduxReactRouter({ routes, createHistory, routerStateSelector })`
140 |
141 | A Redux store enhancer that adds router state to the store.
142 |
143 | ### `routerStateReducer(state, action)`
144 |
145 | A reducer that keeps track of Router state.
146 |
147 | ### ``
148 |
149 | A component that renders a React Router app using router state from a Redux store.
150 |
151 | ### `push(location)`
152 |
153 | An action creator for `history.push()`. [mjackson/history/docs/GettingStarted.md#navigation](https://github.com/mjackson/history/blob/master/docs/GettingStarted.md#navigation)
154 |
155 | Basic example (let say we are at `http://example.com/orders/new`):
156 | ```js
157 | dispatch(push('/orders/' + order.id));
158 | ```
159 | Provided that `order.id` is set and equals `123` it will change browser address bar to `http://example.com/orders/123` and appends this URL to the browser history (without reloading the page).
160 |
161 | A bit more advanced example:
162 | ```js
163 | dispatch(push({
164 | pathname: '/orders',
165 | query: { filter: 'shipping' }
166 | }));
167 | ```
168 | This will change the browser address bar to `http://example.com/orders?filter=shipping`.
169 |
170 | **NOTE:** clicking back button will change address bar back to `http://example.com/orders/new` but will **not** change page content
171 |
172 | ### `replace(location)`
173 |
174 | An action creator for `history.replace()`. [mjackson/history/docs/GettingStarted.md#navigation](https://github.com/mjackson/history/blob/master/docs/GettingStarted.md#navigation)
175 |
176 | Works similar to the `push` except that it doesn't create new browser history entry.
177 |
178 | **NOTE:** Referring to the `push` example: clicking back button will change address bar back to the URL before `http://example.com/orders/new` and will change page content.
179 |
180 | ### `go(n)` `goBack()` `goForward()`
181 |
182 | ```js
183 | // Go back to the previous entry in browser history.
184 | // These lines are synonymous.
185 | history.go(-1);
186 | history.goBack();
187 |
188 | // Go forward to the next entry in browser history.
189 | // These lines are synonymous.
190 | history.go(1);
191 | history.goForward();
192 | ```
193 |
194 | ## Handling authentication via a higher order component
195 |
196 | @joshgeller threw together a good example on how to handle user authentication via a higher order component. Check out [joshgeller/react-redux-jwt-auth-example](https://github.com/joshgeller/react-redux-jwt-auth-example)
197 |
198 | ## Bonus: Reacting to state changes with redux-rx
199 |
200 | This library pairs well with [redux-rx](https://github.com/acdlite/redux-rx) to trigger route transitions in response to state changes. Here's a simple example of redirecting to a new page after a successful login:
201 |
202 | ```js
203 | const LoginPage = createConnector(props$, state$, dispatch$, () => {
204 | const actionCreators$ = bindActionCreators(actionCreators, dispatch$);
205 | const push$ = actionCreators$.map(ac => ac.push);
206 |
207 | // Detect logins
208 | const didLogin$ = state$
209 | .distinctUntilChanged(state => state.loggedIn)
210 | .filter(state => state.loggedIn);
211 |
212 | // Redirect on login!
213 | const redirect$ = didLogin$
214 | .withLatestFrom(
215 | push$,
216 | // Use query parameter as redirect path
217 | (state, push) => () => push(state.router.query.redirect || '/')
218 | )
219 | .do(go => go());
220 |
221 | return combineLatest(
222 | props$, actionCreators$, redirect$,
223 | (props, actionCreators) => ({
224 | ...props,
225 | ...actionCreators
226 | });
227 | });
228 | ```
229 |
230 | A more complete example is forthcoming.
231 |
--------------------------------------------------------------------------------
/src/__tests__/ReduxRouter-test.js:
--------------------------------------------------------------------------------
1 | import {
2 | push,
3 | ReduxRouter,
4 | reduxReactRouter,
5 | routerStateReducer,
6 | } from '../';
7 |
8 | import * as server from '../server';
9 |
10 | import React, { Component, PropTypes } from 'react';
11 | import { renderToString } from 'react-dom/server';
12 | import {
13 | renderIntoDocument,
14 | findRenderedComponentWithType,
15 | findRenderedDOMComponentWithTag,
16 | Simulate
17 | } from 'react-addons-test-utils';
18 | import { Provider, connect } from 'react-redux';
19 | import { createStore, combineReducers } from 'redux';
20 | import createHistory from 'history/lib/createMemoryHistory';
21 | import { Link, Route, RouterContext } from 'react-router';
22 | import jsdom from 'mocha-jsdom';
23 | import sinon from 'sinon';
24 |
25 | @connect(state => state.router)
26 | class App extends Component {
27 | static propTypes = {
28 | children: PropTypes.node,
29 | location: PropTypes.object
30 | }
31 |
32 | render() {
33 | const { location, children } = this.props;
34 | return (
35 |
36 |
{`Pathname: ${location.pathname}`}
37 | {children}
38 |
39 | );
40 | }
41 | }
42 |
43 | class Parent extends Component {
44 | static propTypes = {
45 | children: PropTypes.node
46 | }
47 |
48 | render() {
49 | return (
50 |
51 |
52 | {this.props.children}
53 |
54 | );
55 | }
56 | }
57 |
58 | class Child extends Component {
59 | render() {
60 | return (
61 |
62 | );
63 | }
64 | }
65 |
66 | function redirectOnEnter(pathname) {
67 | return (routerState, replace) => replace(null, pathname);
68 | }
69 |
70 | const routes = (
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | );
79 |
80 |
81 | describe('', () => {
82 | jsdom();
83 |
84 | function renderApp() {
85 | const reducer = combineReducers({
86 | router: routerStateReducer
87 | });
88 |
89 | const history = createHistory();
90 | const store = reduxReactRouter({
91 | history
92 | })(createStore)(reducer);
93 |
94 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
95 |
96 | return renderIntoDocument(
97 |
98 |
99 | {routes}
100 |
101 |
102 | );
103 | }
104 |
105 | it('renders a React Router app using state from a Redux ', () => {
106 | const tree = renderApp();
107 |
108 | const child = findRenderedComponentWithType(tree, Child);
109 | expect(child.props.location.pathname).to.equal('/parent/child/123');
110 | expect(child.props.location.query).to.eql({ key: 'value' });
111 | expect(child.props.params).to.eql({ id: '123' });
112 | });
113 |
114 | it('only renders once on initial load', () => {
115 | const reducer = combineReducers({
116 | router: routerStateReducer
117 | });
118 |
119 | const history = createHistory();
120 | const store = reduxReactRouter({
121 | history
122 | })(createStore)(reducer);
123 |
124 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
125 |
126 | const historySpy = sinon.spy();
127 | history.listen(() => historySpy());
128 |
129 | renderIntoDocument(
130 |
131 |
132 | {routes}
133 |
134 |
135 | );
136 |
137 | expect(historySpy.callCount).to.equal(1);
138 | });
139 |
140 | it('should accept React.Components for "RoutingContext" prop of ReduxRouter', () => {
141 | const reducer = combineReducers({
142 | router: routerStateReducer
143 | });
144 |
145 | const history = createHistory();
146 | const store = reduxReactRouter({
147 | history
148 | })(createStore)(reducer);
149 |
150 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
151 |
152 | const consoleErrorSpy = sinon.spy(console, 'error');
153 |
154 | renderIntoDocument(
155 |
156 |
157 | {routes}
158 |
159 |
160 | );
161 |
162 | console.error.restore(); // eslint-disable-line no-console
163 |
164 | expect(consoleErrorSpy.called).to.be.false;
165 | });
166 |
167 | it('should accept stateless React components for "RoutingContext" prop of ReduxRouter', () => {
168 | const reducer = combineReducers({
169 | router: routerStateReducer
170 | });
171 |
172 | const history = createHistory();
173 | const store = reduxReactRouter({
174 | history
175 | })(createStore)(reducer);
176 |
177 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
178 |
179 | const consoleErrorSpy = sinon.spy(console, 'error');
180 |
181 | renderIntoDocument(
182 |
183 | }>
184 | {routes}
185 |
186 |
187 | );
188 |
189 | console.error.restore(); // eslint-disable-line no-console
190 |
191 | expect(consoleErrorSpy.called).to.be.false;
192 | });
193 |
194 | it('should not accept non-React-components for "RoutingContext" prop of ReduxRouter', () => {
195 | const reducer = combineReducers({
196 | router: routerStateReducer
197 | });
198 |
199 | const history = createHistory();
200 | const store = reduxReactRouter({
201 | history
202 | })(createStore)(reducer);
203 |
204 | store.dispatch(push({ pathname: '/parent/child/123', query: { key: 'value' } }));
205 |
206 | class CustomRouterContext extends React.Component {
207 | render() {
208 | return ;
209 | }
210 | }
211 |
212 | const consoleErrorSpy = sinon.spy(console, 'error');
213 |
214 | const render = () => renderIntoDocument(
215 |
216 |
217 | {routes}
218 |
219 |
220 | );
221 |
222 | const invalidElementTypeErrorMessage = 'Element type is invalid: expected a string (for built-in components) ' +
223 | 'or a class/function (for composite components) but got: object. ' +
224 | 'Check the render method of `ReduxRouterContext`.';
225 |
226 | const routingContextInvalidPropErrorMessage = 'Invalid prop `RoutingContext` of type `object` supplied to `ReduxRouterContext`';
227 |
228 | const routingContextInvalidElementTypeErrorMessage = 'React.createElement: type should not be null, undefined, boolean, or number. ' +
229 | 'It should be a string (for DOM elements) or a ReactClass (for composite components). ' +
230 | 'Check the render method of `ReduxRouterContext`.';
231 |
232 | expect(render).to.throw(invalidElementTypeErrorMessage);
233 |
234 | console.error.restore(); // eslint-disable-line no-console
235 |
236 | expect(consoleErrorSpy.calledTwice).to.be.true;
237 |
238 | expect(consoleErrorSpy.args[0][0]).to.contain(routingContextInvalidPropErrorMessage);
239 | expect(consoleErrorSpy.args[1][0]).to.contain(routingContextInvalidElementTypeErrorMessage);
240 | });
241 |
242 | // does stuff inside `onClick` that makes it difficult to test.
243 | // They work in the example.
244 | // TODO: Refer to React Router tests once they're completed
245 | it.skip('works with ', () => {
246 | const tree = renderApp();
247 |
248 | const child = findRenderedComponentWithType(tree, Child);
249 | expect(child.props.location.pathname).to.equal('/parent/child/123');
250 | const link = findRenderedDOMComponentWithTag(tree, 'a');
251 |
252 | Simulate.click(link);
253 | expect(child.props.location.pathname).to.equal('/parent/child/321');
254 | });
255 |
256 | describe('server-side rendering', () => {
257 | it('works', () => {
258 | const reducer = combineReducers({
259 | router: routerStateReducer
260 | });
261 |
262 | const store = server.reduxReactRouter({ routes, createHistory })(createStore)(reducer);
263 | store.dispatch(server.match('/parent/child/850?key=value', (err, redirectLocation, routerState) => {
264 | const output = renderToString(
265 |
266 |
267 |
268 | );
269 | expect(output).to.match(/Pathname: \/parent\/child\/850/);
270 | expect(routerState.location.query).to.eql({ key: 'value' });
271 | }));
272 | });
273 |
274 | it('should gracefully handle 404s', () => {
275 | const reducer = combineReducers({
276 | router: routerStateReducer
277 | });
278 |
279 | const store = server.reduxReactRouter({ routes, createHistory })(createStore)(reducer);
280 | expect(() => store.dispatch(server.match('/404', () => {})))
281 | .to.not.throw();
282 | });
283 |
284 | it('throws if routes are not passed to store enhancer', () => {
285 | const reducer = combineReducers({
286 | router: routerStateReducer
287 | });
288 |
289 | expect(() => server.reduxReactRouter()(createStore)(reducer))
290 | .to.throw(
291 | 'When rendering on the server, routes must be passed to the '
292 | + 'reduxReactRouter() store enhancer; routes as a prop or as children '
293 | + 'of is not supported. To deal with circular '
294 | + 'dependencies between routes and the store, use the '
295 | + 'option getRoutes(store).'
296 | );
297 | });
298 |
299 | it('throws if createHistory is not passed to store enhancer', () => {
300 | const reducer = combineReducers({
301 | router: routerStateReducer
302 | });
303 |
304 | expect(() => server.reduxReactRouter({ routes })(createStore)(reducer))
305 | .to.throw(
306 | 'When rendering on the server, createHistory must be passed to the '
307 | + 'reduxReactRouter() store enhancer'
308 | );
309 | });
310 |
311 | it('handles redirects', () => {
312 | const reducer = combineReducers({
313 | router: routerStateReducer
314 | });
315 |
316 | const store = server.reduxReactRouter({ routes, createHistory })(createStore)(reducer);
317 | store.dispatch(server.match('/redirect', (error, redirectLocation) => {
318 | expect(error).to.be.null;
319 | expect(redirectLocation.pathname).to.equal('/parent/child/850');
320 | }));
321 | });
322 | });
323 |
324 | describe('dynamic route switching', () => {
325 | it('updates routes wnen receives new props', () => {
326 | const newRoutes = (
327 |
328 | );
329 |
330 | const reducer = combineReducers({
331 | router: routerStateReducer
332 | });
333 |
334 | const history = createHistory();
335 | const store = reduxReactRouter({ history })(createStore)(reducer);
336 |
337 | class RouterContainer extends Component {
338 | state = { routes }
339 |
340 | render() {
341 | return (
342 |
343 |
344 |
345 | );
346 | }
347 | }
348 |
349 | store.dispatch(push({ pathname: '/parent/child' }));
350 | const tree = renderIntoDocument();
351 |
352 |
353 | expect(store.getState().router.params).to.eql({});
354 | tree.setState({ routes: newRoutes });
355 | expect(store.getState().router.params).to.eql({ route: 'child' });
356 | });
357 | });
358 | });
359 |
--------------------------------------------------------------------------------