diff --git a/package.json b/package.json index aae8dc0..93bbd07 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "karma-webpack": "^1.7.0", "mocha": "^2.3.4", "redux": "^3.0.4", + "redux-devtools": "^2.1.5", "webpack": "^1.12.9" }, "dependencies": { diff --git a/src/index.js b/src/index.js index 8d62e53..0b33e7b 100644 --- a/src/index.js +++ b/src/index.js @@ -55,12 +55,26 @@ function update(state=initialState, { type, payload }) { // Syncing function locationsAreEqual(a, b) { - return a.path === b.path && deepEqual(a.state, b.state); + return a != null && b != null && a.path === b.path && deepEqual(a.state, b.state); } function syncReduxAndRouter(history, store, selectRouterState = SELECT_STATE) { const getRouterState = () => selectRouterState(store.getState()); - let lastChangeId = 0; + + // Because we're not able to set the initial path in `initialState` we need a + // "hack" to get "Revert" in Redux DevTools to work. We solve this by keeping + // the first route so we can revert to this route when the initial state is + // replayed to reset the state. Basically, we treat the first route as our + // initial state. + let firstRoute = undefined; + + // To properly handle store updates we need to track the last route. This + // route contains a `changeId` which is updated on every `pushPath` and + // `replacePath`. If this id changes we always trigger a history update. + // However, if the id does not change, we check if the location has changed, + // and if it is we trigger a history update. (If these are out of sync it's + // likely because of React DevTools.) + let lastRoute = undefined; if(!getRouterState()) { throw new Error( @@ -75,6 +89,10 @@ function syncReduxAndRouter(history, store, selectRouterState = SELECT_STATE) { state: location.state }; + if (firstRoute === undefined) { + firstRoute = route; + } + // 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; @@ -87,14 +105,22 @@ function syncReduxAndRouter(history, store, selectRouterState = SELECT_STATE) { }); const unsubscribeStore = store.subscribe(() => { - const routing = getRouterState(); + let routing = getRouterState(); - // Only update the router once per `pushPath` call. This is - // indicated by the `changeId` state; when that number changes, we - // should update the history. - if(lastChangeId === routing.changeId) return; + // Treat `firstRoute` as our `initialState` + if(routing === initialState) { + routing = firstRoute; + } + + // Only trigger history update is this is a new change or the location + // has changed. + if(lastRoute !== undefined && + lastRoute.changeId === routing.changeId && + locationsAreEqual(lastRoute, routing)) { + return; + } - lastChangeId = routing.changeId; + lastRoute = routing; const method = routing.replace ? 'replaceState' : 'pushState'; diff --git a/test/createTests.js b/test/createTests.js index 327cf73..b8a8f8a 100644 --- a/test/createTests.js +++ b/test/createTests.js @@ -1,6 +1,8 @@ const expect = require('expect'); const { pushPath, replacePath, UPDATE_PATH, routeReducer, syncReduxAndRouter } = require('../src/index'); -const { createStore, combineReducers } = require('redux'); +const { createStore, combineReducers, compose } = require('redux'); +const { devTools } = require('redux-devtools'); +const { ActionCreators } = require('redux-devtools/lib/devTools'); expect.extend({ toContainRoute({ @@ -149,6 +151,77 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) }); }); + describe('devtools', () => { + let history, store, devToolsStore, unsubscribe; + + beforeEach(() => { + history = createHistory(); + const finalCreateStore = compose(devTools())(createStore); + store = finalCreateStore(combineReducers({ + routing: routeReducer + })); + devToolsStore = store.devToolsStore; + + // Set initial URL before syncing + history.pushState(null, '/foo'); + + unsubscribe = syncReduxAndRouter(history, store); + }); + + afterEach(() => { + unsubscribe(); + }); + + it('resets to the initial url', () => { + let lastPath; + const historyUnsubscribe = history.listen(location => { + lastPath = location.pathname; + }); + + history.pushState(null, '/bar'); + store.dispatch(pushPath('/baz')); + + devToolsStore.dispatch(ActionCreators.reset()); + + expect(store.getState().routing.path).toEqual('/foo'); + expect(lastPath).toEqual('/foo'); + }); + + it('handles toggle after store change', () => { + let lastPath; + const historyUnsubscribe = history.listen(location => { + lastPath = location.pathname; + }); + + // action 2 + history.pushState(null, '/foo2'); + // action 3 + history.pushState(null, '/foo3'); + + devToolsStore.dispatch(ActionCreators.toggleAction(3)); + expect(lastPath).toEqual('/foo2'); + + historyUnsubscribe(); + }); + + it('handles toggle after store change', () => { + let lastPath; + const historyUnsubscribe = history.listen(location => { + lastPath = location.pathname; + }); + + // action 2 + store.dispatch(pushPath('/foo2')); + // action 3 + store.dispatch(pushPath('/foo3')); + + devToolsStore.dispatch(ActionCreators.toggleAction(3)); + expect(lastPath).toEqual('/foo2'); + + historyUnsubscribe(); + }); + }); + describe('syncReduxAndRouter', () => { let history, store, unsubscribe;