diff --git a/package.json b/package.json index 6f99485..75ace7b 100644 --- a/package.json +++ b/package.json @@ -36,5 +36,8 @@ "isparta": "^4.0.0", "mocha": "^2.3.4", "redux": "^3.0.4" + }, + "dependencies": { + "deep-equal": "^1.0.1" } } diff --git a/src/index.js b/src/index.js index 31104d3..3401b8a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,16 +1,31 @@ +const deepEqual = require('deep-equal'); -// constants +// Constants const UPDATE_PATH = "@@router/UPDATE_PATH"; const SELECT_STATE = state => state.routing; // Action creator -function updatePath(path, avoidRouterUpdate) { +function pushPath(path, state, opts) { + opts = opts || {}; return { type: UPDATE_PATH, path: path, - avoidRouterUpdate: !!avoidRouterUpdate + state: state, + replace: false, + avoidRouterUpdate: !!opts.avoidRouterUpdate + }; +} + +function replacePath(path, state, opts) { + opts = opts || {}; + return { + type: UPDATE_PATH, + path: path, + state: state, + replace: true, + avoidRouterUpdate: !!opts.avoidRouterUpdate } } @@ -18,16 +33,18 @@ function updatePath(path, avoidRouterUpdate) { const initialState = { changeId: 1, - path: (typeof window !== 'undefined') ? - locationToString(window.location) : - '/' + path: undefined, + state: undefined, + replace: false }; function update(state=initialState, action) { if(action.type === UPDATE_PATH) { return Object.assign({}, state, { path: action.path, - changeId: state.changeId + (action.avoidRouterUpdate ? 0 : 1) + changeId: state.changeId + (action.avoidRouterUpdate ? 0 : 1), + state: action.state, + replace: action.replace }); } return state; @@ -35,8 +52,8 @@ function update(state=initialState, action) { // Syncing -function locationToString(location) { - return location.pathname + location.search + location.hash; +function locationsAreEqual(a, b) { + return a.path === b.path && deepEqual(a.state, b.state); } function syncReduxAndRouter(history, store, selectRouterState = SELECT_STATE) { @@ -51,24 +68,31 @@ function syncReduxAndRouter(history, store, selectRouterState = SELECT_STATE) { } const unsubscribeHistory = history.listen(location => { - const routePath = locationToString(location); + const route = { + path: history.createPath(location), + state: location.state + }; + + // Avoid dispatching an action if the store is already up-to-date, + // even if `history` wouldn't do anything if the location is the same + if(locationsAreEqual(getRouterState(), route)) return; - // Avoid dispatching an action if the store is already up-to-date - if(getRouterState().path !== routePath) { - store.dispatch(updatePath(routePath, { avoidRouterUpdate: true })); - } + store.dispatch(pushPath(route.path, route.state, { avoidRouterUpdate: true })); }); const unsubscribeStore = store.subscribe(() => { const routing = getRouterState(); - // Only update the router once per `updatePath` call. This is + // Only update the router once per `pushPath` call. This is // indicated by the `changeId` state; when that number changes, we - // should call `pushState`. - if(lastChangeId !== routing.changeId) { - lastChangeId = routing.changeId; - history.pushState(null, routing.path); - } + // should update the history. + if(lastChangeId === routing.changeId) return; + + lastChangeId = routing.changeId; + + const method = routing.replace ? 'replaceState' : 'pushState'; + + history[method](routing.state, routing.path); }); return function unsubscribe() { @@ -79,7 +103,8 @@ function syncReduxAndRouter(history, store, selectRouterState = SELECT_STATE) { module.exports = { UPDATE_PATH, - updatePath, + pushPath, + replacePath, syncReduxAndRouter, routeReducer: update }; diff --git a/test/index.js b/test/index.js index 1981f2f..efefe13 100644 --- a/test/index.js +++ b/test/index.js @@ -1,5 +1,5 @@ const expect = require('expect'); -const { updatePath, UPDATE_PATH, routeReducer, syncReduxAndRouter } = require('../src/index'); +const { pushPath, replacePath, UPDATE_PATH, routeReducer, syncReduxAndRouter } = require('../src/index'); const { createStore, combineReducers } = require('redux'); const { createMemoryHistory: createHistory } = require('history'); @@ -12,22 +12,54 @@ function createSyncedHistoryAndStore() { return { history, store }; } -describe('updatePath', () => { +describe('pushPath', () => { it('creates actions', () => { - expect(updatePath('/foo')).toEqual({ + expect(pushPath('/foo', { bar: 'baz' })).toEqual({ type: UPDATE_PATH, path: '/foo', + replace: false, + state: { bar: 'baz' }, avoidRouterUpdate: false }); - expect(updatePath('/foo', { avoidRouterUpdate: true })).toEqual({ + expect(pushPath('/foo', undefined, { avoidRouterUpdate: true })).toEqual({ type: UPDATE_PATH, path: '/foo', + state: undefined, + replace: false, avoidRouterUpdate: true }); }); }); +describe('replacePath', () => { + it('creates actions', () => { + expect(replacePath('/foo', { bar: 'baz' })).toEqual({ + type: UPDATE_PATH, + path: '/foo', + replace: true, + state: { bar: 'baz' }, + avoidRouterUpdate: false + }); + + expect(replacePath('/foo', undefined, { avoidRouterUpdate: true })).toEqual({ + type: UPDATE_PATH, + path: '/foo', + state: undefined, + replace: true, + avoidRouterUpdate: true + }); + + expect(replacePath('/foo', undefined, { avoidRouterUpdate: false })).toEqual({ + type: UPDATE_PATH, + path: '/foo', + state: undefined, + replace: true, + avoidRouterUpdate: false + }); + }); +}); + describe('routeReducer', () => { const state = { path: '/foo', @@ -37,9 +69,26 @@ describe('routeReducer', () => { it('updates the path', () => { expect(routeReducer(state, { type: UPDATE_PATH, - path: '/bar' + path: '/bar', + replace: false + })).toEqual({ + path: '/bar', + replace: false, + state: undefined, + changeId: 2 + }); + }); + + it('respects replace', () => { + expect(routeReducer(state, { + type: UPDATE_PATH, + path: '/bar', + replace: true, + avoidRouterUpdate: false })).toEqual({ path: '/bar', + replace: true, + state: undefined, changeId: 2 }); }); @@ -48,9 +97,12 @@ describe('routeReducer', () => { expect(routeReducer(state, { type: UPDATE_PATH, path: '/bar', + replace: false, avoidRouterUpdate: true })).toEqual({ path: '/bar', + replace: false, + state: undefined, changeId: 1 }); }); @@ -63,13 +115,27 @@ describe('syncReduxAndRouter', () => { history.pushState(null, '/foo'); expect(store.getState().routing.path).toEqual('/foo'); + expect(store.getState().routing.state).toBe(null); + + history.pushState({ bar: 'baz' }, '/foo'); + expect(store.getState().routing.path).toEqual('/foo'); + expect(store.getState().routing.state).toEqual({ bar: 'baz' }); + + history.replaceState(null, '/bar'); + expect(store.getState().routing.path).toEqual('/bar'); + expect(store.getState().routing.state).toBe(null); history.pushState(null, '/bar'); expect(store.getState().routing.path).toEqual('/bar'); + expect(store.getState().routing.state).toBe(null); history.pushState(null, '/bar?query=1'); expect(store.getState().routing.path).toEqual('/bar?query=1'); + history.replaceState({ bar: 'baz' }, '/bar?query=1'); + expect(store.getState().routing.path).toEqual('/bar?query=1'); + expect(store.getState().routing.state).toEqual({ bar: 'baz' }); + history.pushState(null, '/bar?query=1#hash=2'); expect(store.getState().routing.path).toEqual('/bar?query=1#hash=2'); }); @@ -78,31 +144,57 @@ describe('syncReduxAndRouter', () => { const { history, store } = createSyncedHistoryAndStore(); expect(store.getState().routing).toEqual({ path: '/', - changeId: 1 + changeId: 1, + replace: false, + state: undefined }); - store.dispatch(updatePath('/foo')); + store.dispatch(pushPath('/foo')); expect(store.getState().routing).toEqual({ path: '/foo', - changeId: 2 + changeId: 2, + replace: false, + state: undefined }); - store.dispatch(updatePath('/bar')); + store.dispatch(pushPath('/foo', { bar: 'baz' })); + expect(store.getState().routing).toEqual({ + path: '/foo', + changeId: 3, + replace: false, + state: { bar: 'baz' } + }); + + store.dispatch(replacePath('/bar', { bar: 'foo' })); + expect(store.getState().routing).toEqual({ + path: '/bar', + changeId: 4, + replace: true, + state: { bar: 'foo' } + }); + + store.dispatch(pushPath('/bar')); expect(store.getState().routing).toEqual({ path: '/bar', - changeId: 3 + changeId: 5, + replace: false, + state: undefined }); - store.dispatch(updatePath('/bar?query=1')); + store.dispatch(pushPath('/bar?query=1')); expect(store.getState().routing).toEqual({ path: '/bar?query=1', - changeId: 4 + changeId: 6, + replace: false, + state: undefined }); - store.dispatch(updatePath('/bar?query=1#hash=2')); + store.dispatch(pushPath('/bar?query=1#hash=2')); expect(store.getState().routing).toEqual({ path: '/bar?query=1#hash=2', - changeId: 5 + changeId: 7, + replace: false, + state: undefined }); }); @@ -110,19 +202,33 @@ describe('syncReduxAndRouter', () => { const { history, store } = createSyncedHistoryAndStore(); expect(store.getState().routing).toEqual({ path: '/', - changeId: 1 + changeId: 1, + replace: false, + state: undefined }); - store.dispatch(updatePath('/foo')); + store.dispatch(pushPath('/foo')); expect(store.getState().routing).toEqual({ path: '/foo', - changeId: 2 + changeId: 2, + replace: false, + state: undefined }); - store.dispatch(updatePath('/foo')); + store.dispatch(pushPath('/foo')); expect(store.getState().routing).toEqual({ path: '/foo', - changeId: 3 + changeId: 3, + replace: false, + state: undefined + }); + + store.dispatch(replacePath('/foo')); + expect(store.getState().routing).toEqual({ + path: '/foo', + changeId: 4, + replace: true, + state: undefined }); }); @@ -135,7 +241,9 @@ describe('syncReduxAndRouter', () => { expect(store.getState().routing).toEqual({ path: '/', - changeId: 1 + changeId: 1, + replace: false, + state: undefined }); }); @@ -143,7 +251,9 @@ describe('syncReduxAndRouter', () => { const { history, store } = createSyncedHistoryAndStore(); expect(store.getState().routing).toEqual({ path: '/', - changeId: 1 + changeId: 1, + replace: false, + state: undefined }); history.listenBefore(location => { @@ -154,34 +264,120 @@ describe('syncReduxAndRouter', () => { }); }); - store.dispatch(updatePath('/foo')); + store.dispatch(pushPath('/foo')); expect(store.getState().routing).toEqual({ path: '/foo', - changeId: 2 + changeId: 2, + replace: false, + state: undefined }); }); + it('does not unnecessarily update the store', () => { + const { history, store } = createSyncedHistoryAndStore(); + const updates = []; + + const unsubscribe = store.subscribe(() => { + updates.push(store.getState()) + }); + + store.dispatch(pushPath('/foo')); + store.dispatch(pushPath('/foo')); + store.dispatch(pushPath('/foo', { bar: 'baz' })); + store.dispatch(replacePath('/bar')); + store.dispatch(replacePath('/bar', { bar: 'foo' })); + + unsubscribe(); + + expect(updates.length).toBe(5); + expect(updates).toEqual([ + { + routing: { + changeId: 2, + path: '/foo', + state: undefined, + replace: false + } + }, + { + routing: { + changeId: 3, + path: '/foo', + state: undefined, + replace: false + } + }, + { + routing: { + changeId: 4, + path: '/foo', + state: { bar: 'baz' }, + replace: false + } + }, + { + routing: { + changeId: 5, + path: '/bar', + state: undefined, + replace: true + } + }, + { + routing: { + changeId: 6, + path: '/bar', + state: { bar: 'foo' }, + replace: true + } + } + ]); + }); + it('allows updating the route from within `listenBefore`', () => { const { history, store } = createSyncedHistoryAndStore(); expect(store.getState().routing).toEqual({ path: '/', - changeId: 1 + changeId: 1, + replace: false, + state: undefined }); history.listenBefore(location => { if(location.pathname === '/foo') { expect(store.getState().routing).toEqual({ path: '/foo', - changeId: 2 + changeId: 2, + replace: false, + state: undefined + }); + store.dispatch(pushPath('/bar')); + } + else if(location.pathname === '/replace') { + expect(store.getState().routing).toEqual({ + path: '/replace', + changeId: 4, + replace: false, + state: { bar: 'baz' } }); - store.dispatch(updatePath('/bar')); + store.dispatch(replacePath('/baz', { foo: 'bar' })); } }); - store.dispatch(updatePath('/foo')); + store.dispatch(pushPath('/foo')); expect(store.getState().routing).toEqual({ path: '/bar', - changeId: 3 + changeId: 3, + replace: false, + state: undefined + }); + + store.dispatch(pushPath('/replace', { bar: 'baz' })); + expect(store.getState().routing).toEqual({ + path: '/baz', + changeId: 5, + replace: true, + state: { foo: 'bar' } }); }) @@ -215,10 +411,12 @@ describe('syncReduxAndRouter', () => { history.pushState(null, '/foo'); expect(store.getState().routing.path).toEqual('/foo'); - store.dispatch(updatePath('/bar')); + store.dispatch(pushPath('/bar')); expect(store.getState().routing).toEqual({ path: '/bar', - changeId: 2 + changeId: 2, + replace: false, + state: undefined }); unsubscribe(); @@ -230,7 +428,7 @@ describe('syncReduxAndRouter', () => { throw new Error() }); expect( - () => store.dispatch(updatePath('/foo')) + () => store.dispatch(pushPath('/foo')) ).toNotThrow(); }); });