diff --git a/ADVANCED.md b/ADVANCED.md index 2520d6c5..eff0af5c 100644 --- a/ADVANCED.md +++ b/ADVANCED.md @@ -5,16 +5,19 @@ Make sure to read http://redux.js.org/docs/recipes/ServerRendering.html to understand how the server/client Redux boilerplate works. -Here's what the setup looks like on the server: +Here's what the setup looks like on the server (assuming Node 4 LTS): ```js const express = require('express'); const app = express(); - -const createStore = require('redux').createStore; const routerForExpress = require('redux-little-router') .routerForExpress; +const redux = require('redux'); +const createStore = redux.createStore; +const compose = redux.compose; +const applyMiddleware = redux.applyMiddleware; + const routes = { '/': { '/whatever': { @@ -24,19 +27,27 @@ const routes = { }; app.use('/*', (req, res) => { - // Create the Redux store, passing in the - // Express request to the store enhancer. + // Create the Redux store, passing in the Express + // request to the routerForExpress factory. // // If you're using an Express sub-router, // routerForExpress will infer the basename // from req.baseUrl! + // + const router = routerForExpress({ + routes, + request: req + }) + const store = createStore( state => state, { what: 'ever' }, - routerForExpress({ - routes, - request: req - }) + compose( + router.routerEnhancer, + applyMiddleware( + router.routerMiddleware + ) + ) ); // ...then renderToString() your components as usual, @@ -51,7 +62,7 @@ app.use('/*', (req, res) => { There's not much involved on the client side, post-server render: ```js -import { createStore } from 'redux'; +import { createStore, compose, applyMiddleware } from 'redux'; import { routerForBrowser } from 'redux-little-router'; // The same routes that you used on the server. @@ -64,10 +75,18 @@ const routes = { } }; +const { + routerEnhancer, + routerMiddleware +} = routerForBrowser({ routes }); + const store = createStore( yourReducer, window.__INITIAL_STATE, - routerForBrowser({ routes }) + compose( + routerEnhancer, + applyMiddleware(routerMiddleware) + ) ); // ...then render() your components as usual, diff --git a/README.md b/README.md index 9750693f..65fa2943 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ While React Router is a great, well-supported library, it hoards URL state withi ## Redux usage -To hook into Redux applications, `redux-little-router` uses a store enhancer that wraps the `history` module and adds current and previous router state to your store. The enhancer listens for location changes and dispatches rich actions containing the URL, parameters, and any custom data assigned to the route. It also intercepts navigation actions and calls their equivalent method in `history`. +To hook into Redux applications, `redux-little-router` uses a store enhancer that wraps the `history` module and adds current and previous router state to your store. The enhancer listens for location changes and dispatches rich actions containing the URL, parameters, and any custom data assigned to the route. `redux-little-router` also adds a middleware that intercepts navigation actions and calls their equivalent method in `history`. ### Wiring up the boilerplate @@ -67,16 +67,26 @@ const routes = { } }; -// Install the router into the store for a browser-only environment +// Install the router into the store for a browser-only environment. +// routerForBrowser is a factory method that returns a store +// enhancer and a middleware. +const { + routerEnhancer, + routerMiddleware +} = routerForBrowser({ + // The configured routes. Required. + routes, + // The basename for all routes. Optional. + basename: '/example' +}) + const clientOnlyStore = createStore( yourReducer, initialState, - routerForBrowser({ - // The configured routes. Required. - routes, - // The basename for all routes. Optional. - basename: '/example' - }) + compose( + routerEnhancer, + applyMiddleware(routerMiddleware) + ) ); ``` diff --git a/demo/client/app.js b/demo/client/app.js index 70096875..6081867c 100644 --- a/demo/client/app.js +++ b/demo/client/app.js @@ -2,7 +2,7 @@ import 'normalize.css/normalize.css'; import './global.css'; import { render } from 'react-dom'; -import { createStore, compose } from 'redux'; +import { createStore, compose, applyMiddleware } from 'redux'; import routerForBrowser from '../../src/browser-router'; @@ -10,13 +10,19 @@ import routes from './routes'; import wrap from './wrap'; import Demo from './demo'; +const { + routerEnhancer, + routerMiddleware +} = routerForBrowser({ routes }); + const store = createStore( state => state, // If this is a server render, we grab the // initial state the hbs template inserted window.__INITIAL_STATE || {}, compose( - routerForBrowser({ routes }), + routerEnhancer, + applyMiddleware(routerMiddleware), window.devToolsExtension ? window.devToolsExtension() : f => f ) diff --git a/demo/server/index.js b/demo/server/index.js index bf6f74a6..cc48c83b 100644 --- a/demo/server/index.js +++ b/demo/server/index.js @@ -25,7 +25,7 @@ const encode = require('ent/encode'); const renderToString = require('react-dom/server') .renderToString; -const createStore = require('redux').createStore; +const redux = require('redux'); const routerForExpress = require('../../src') .routerForExpress; @@ -33,6 +33,10 @@ const routes = require('../client/routes').default; const wrap = require('../client/wrap').default; const Root = require('../client/demo').default; +const createStore = redux.createStore; +const compose = redux.compose; +const applyMiddleware = redux.applyMiddleware; + const PORT = 4567; const templateFile = fs.readFileSync(path.join(__dirname, './index.hbs')); @@ -60,13 +64,17 @@ app.use('/favicon.ico', (req, res) => res.end()); app.get('/*', (req, res) => { const initialState = {}; + const router = routerForExpress({ + routes, + request: req + }); const store = createStore( state => state, initialState, - routerForExpress({ - routes, - request: req - }) + compose( + router.routerEnhancer, + applyMiddleware(router.routerMiddleware) + ) ); const content = renderToString(wrap(store)(Root)); diff --git a/src/browser-router.js b/src/browser-router.js index 700223c9..ed0c2195 100644 --- a/src/browser-router.js +++ b/src/browser-router.js @@ -4,6 +4,7 @@ import useBasename from 'history/lib/useBasename'; import useQueries from 'history/lib/useQueries'; import installRouter from './store-enhancer'; +import routerMiddleware from './middleware'; type BrowserRouterArgs = { routes: Object, @@ -27,5 +28,8 @@ export default ({ const location = history .createLocation({ pathname, search }); - return installRouter({ routes, history, location }); + return { + routerEnhancer: installRouter({ routes, history, location }), + routerMiddleware: routerMiddleware({ history }) + }; }; diff --git a/src/express-router.js b/src/express-router.js index 424939a0..a78e99e6 100644 --- a/src/express-router.js +++ b/src/express-router.js @@ -4,6 +4,7 @@ import useBasename from 'history/lib/useBasename'; import useQueries from 'history/lib/useQueries'; import installRouter from './store-enhancer'; +import routerMiddleware from './middleware'; type ServerRouterArgs = { routes: Object, @@ -25,5 +26,8 @@ export default ({ routes, request }: ServerRouterArgs) => { query: request.query }); - return installRouter({ routes, history, location }); + return { + routerEnhancer: installRouter({ routes, history, location }), + routerMiddleware: routerMiddleware({ history }) + }; }; diff --git a/src/index.js b/src/index.js index d1370b51..2453bb8d 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ import routerForBrowser from './browser-router'; import routerForExpress from './express-router'; import createStoreWithRouter from './store-enhancer'; +import routerMiddleware from './middleware'; import { locationDidChange, initializeCurrentLocation } from './action-creators'; import provideRouter, { RouterProvider } from './provider'; @@ -26,6 +27,7 @@ export { // High-level Redux API routerForBrowser, routerForExpress, + routerMiddleware, initializeCurrentLocation, // React API diff --git a/src/wrap-dispatch.js b/src/middleware.js similarity index 53% rename from src/wrap-dispatch.js rename to src/middleware.js index 129a1c57..55397bfe 100644 --- a/src/wrap-dispatch.js +++ b/src/middleware.js @@ -3,27 +3,24 @@ import { GO_BACK, GO_FORWARD } from './action-types'; -export default (store, history) => action => { +export default ({ history }) => () => next => action => { switch (action.type) { case PUSH: history.push(action.payload); - return null; + break; case REPLACE: history.replace(action.payload); - return null; + break; case GO: history.go(action.payload); - return null; + break; case GO_BACK: history.goBack(); - return null; + break; case GO_FORWARD: history.goForward(); - return null; + break; default: - // We return the result of dispatch here - // to retain compatibility with enhancers - // that return a promise from dispatch. - return store.dispatch(action); + next(action); } }; diff --git a/src/store-enhancer.js b/src/store-enhancer.js index 126ea24a..aa3c2cdc 100644 --- a/src/store-enhancer.js +++ b/src/store-enhancer.js @@ -11,7 +11,6 @@ import type { History } from 'history'; import { default as matcherFactory } from './create-matcher'; import attachRouterToReducer from './reducer-enhancer'; import { locationDidChange } from './action-creators'; -import wrapDispatch from './wrap-dispatch'; import validateRoutes from './util/validate-routes'; import flattenRoutes from './util/flatten-routes'; @@ -65,11 +64,8 @@ export default ({ } }); - const dispatch = wrapDispatch(store, history); - return { ...store, - dispatch, // We attach routes here to allow // to access unserializable properties of route results. diff --git a/test/browser-router.spec.js b/test/browser-router.spec.js index 6ad9c924..122e2832 100644 --- a/test/browser-router.spec.js +++ b/test/browser-router.spec.js @@ -11,16 +11,17 @@ chai.use(sinonChai); describe('Browser router', () => { it('creates a browser store enhancer using window.location', () => { + const { routerEnhancer } = routerForBrowser({ + routes, + getLocation: () => ({ + pathname: '/home', + search: '?get=schwifty' + }) + }); const store = createStore( state => state, {}, - routerForBrowser({ - routes, - getLocation: () => ({ - pathname: '/home', - search: '?get=schwifty' - }) - }) + routerEnhancer ); const state = store.getState(); expect(state).to.have.deep.property('router.pathname', '/home'); @@ -30,17 +31,18 @@ describe('Browser router', () => { }); it('supports basenames', () => { + const { routerEnhancer } = routerForBrowser({ + routes, + basename: '/cob-planet', + getLocation: () => ({ + pathname: '/home', + search: '?get=schwifty' + }) + }); const store = createStore( state => state, {}, - routerForBrowser({ - routes, - basename: '/cob-planet', - getLocation: () => ({ - pathname: '/home', - search: '?get=schwifty' - }) - }) + routerEnhancer ); const state = store.getState(); expect(state).to.have.deep.property('router.basename', '/cob-planet'); diff --git a/test/express-router.spec.js b/test/express-router.spec.js index 988a51fb..1372dc14 100644 --- a/test/express-router.spec.js +++ b/test/express-router.spec.js @@ -11,16 +11,17 @@ chai.use(sinonChai); describe('Express router', () => { it('creates a browser store enhancer using window.location', () => { + const { routerEnhancer } = routerForExpress({ + routes, + request: { + path: '/home', + query: { get: 'schwifty' } + } + }); const store = createStore( state => state, {}, - routerForExpress({ - routes, - request: { - path: '/home', - query: { get: 'schwifty' } - } - }) + routerEnhancer ); const state = store.getState(); expect(state).to.have.deep.property('router.pathname', '/home'); @@ -30,17 +31,18 @@ describe('Express router', () => { }); it('supports basenames', () => { + const { routerEnhancer } = routerForExpress({ + routes, + request: { + baseUrl: '/cob-planet', + path: '/home', + query: { get: 'schwifty' } + } + }); const store = createStore( state => state, {}, - routerForExpress({ - routes, - request: { - baseUrl: '/cob-planet', - path: '/home', - query: { get: 'schwifty' } - } - }) + routerEnhancer ); const state = store.getState(); expect(state).to.have.deep.property('router.basename', '/cob-planet'); diff --git a/test/middleware.spec.js b/test/middleware.spec.js new file mode 100644 index 00000000..1e4b6209 --- /dev/null +++ b/test/middleware.spec.js @@ -0,0 +1,99 @@ +import chai, { expect } from 'chai'; +import sinonChai from 'sinon-chai'; + +import { applyMiddleware, createStore } from 'redux'; +import { routerMiddleware } from '../src'; + +import { + PUSH, REPLACE, GO, GO_BACK, GO_FORWARD +} from '../src/action-types'; + +chai.use(sinonChai); + +const REFRAGULATE = 'REFRAGULATE'; + +// Used to test that other middleware can dispatch +// router actions and trigger history updates +const consumerMiddleware = ({ dispatch }) => next => action => { + if (action.type === REFRAGULATE) { + dispatch({ + type: PUSH, + payload: { + pathname: '/' + } + }); + return; + } + + next(action); +}; + +const init = () => { + const historyStub = { + push: sandbox.stub(), + replace: sandbox.stub(), + go: sandbox.stub(), + goBack: sandbox.stub(), + goForward: sandbox.stub() + }; + + const store = createStore( + state => state, + {}, + applyMiddleware( + routerMiddleware({ + history: historyStub + }), + consumerMiddleware + ) + ); + + return { historyStub, store }; +}; + +const actionMethodMap = { + [PUSH]: 'push', + [REPLACE]: 'replace', + [GO]: 'go', + [GO_BACK]: 'goBack', + [GO_FORWARD]: 'goForward' +}; + +describe('Router middleware', () => { + Object.keys(actionMethodMap).forEach(actionType => { + const method = actionMethodMap[actionType]; + + it(`calls history.${method} when intercepting ${actionType}`, () => { + const { historyStub, store } = init(); + store.dispatch({ + type: actionType, + payload: {} + }); + + expect(historyStub[method]).to.have.been.called.twice; + }); + }); + + it('passes normal actions through the dispatch chain', () => { + const { store, historyStub } = init(); + store.dispatch({ + type: 'NOT_MY_ACTION_NOT_MY_PROBLEM', + payload: {} + }); + + Object.keys(actionMethodMap).forEach(actionType => { + const method = actionMethodMap[actionType]; + expect(historyStub[method]).to.not.have.been.called; + }); + }); + + it('passes normal actions through the dispatch chain', () => { + const { store, historyStub } = init(); + store.dispatch({ + type: REFRAGULATE, + payload: {} + }); + + expect(historyStub.push).to.have.been.called.once; + }); +}); diff --git a/test/store-enhancer.spec.js b/test/store-enhancer.spec.js index 562d9388..38fe47a9 100644 --- a/test/store-enhancer.spec.js +++ b/test/store-enhancer.spec.js @@ -1,15 +1,15 @@ import chai, { expect } from 'chai'; import sinonChai from 'sinon-chai'; -import { compose, createStore } from 'redux'; +import { compose, createStore, applyMiddleware } from 'redux'; import { install, combineReducers } from 'redux-loop'; import { - LOCATION_CHANGED, PUSH, REPLACE, - GO, GO_BACK, GO_FORWARD + LOCATION_CHANGED, PUSH } from '../src/action-types'; import createStoreWithRouter from '../src/store-enhancer'; +import routerMiddleware from '../src/middleware'; import defaultRoutes from './fixtures/routes'; @@ -63,7 +63,10 @@ const fakeStore = ({ routes, location, history: historyStub - }) + }), + applyMiddleware( + routerMiddleware({ history: historyStub }) + ) ]; if (isLoop) { @@ -198,44 +201,6 @@ describe('Router store enhancer', () => { expect(store.dispatchSpy).to.be.calledOnce; }); - const actionMethodMap = { - [PUSH]: 'push', - [REPLACE]: 'replace', - [GO]: 'go', - [GO_BACK]: 'goBack', - [GO_FORWARD]: 'goForward' - }; - - Object.keys(actionMethodMap).forEach(actionType => { - const method = actionMethodMap[actionType]; - - it(`calls history.${method} when intercepting ${actionType}`, () => { - const { store, historyStub } = fakeStore(); - store.dispatch({ - type: actionType, - payload: { - pathname: '/nonsense' - } - }); - - expect(historyStub[method]).to.have.been.calledOnce; - }); - }); - - it('passes normal actions through the dispatch chain', () => { - const { store, historyStub } = fakeStore(); - store.dispatch({ - type: 'NOT_MY_ACTION_NOT_MY_PROBLEM', - payload: {} - }); - - Object.keys(actionMethodMap).forEach(actionType => { - const method = actionMethodMap[actionType]; - expect(historyStub[method]).to.not.have.been.called; - }); - expect(store.dispatchSpy).to.be.calledOnce; - }); - it('calls the reducer once for each action', () => { const reducerSpy = sandbox.spy(); const { store } = fakeStore({ reducer: reducerSpy });