diff --git a/.babelrc b/.babelrc index 3c3b968..2d4d503 100644 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,3 @@ { - "presets": ["es2015"], - "plugins": ["transform-object-assign"] + "presets": ["es2015", "stage-2"] } diff --git a/package.json b/package.json index bbfda95..b2672ef 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,8 @@ "babel-core": "^6.2.1", "babel-eslint": "^4.1.6", "babel-loader": "^6.2.0", - "babel-plugin-transform-object-assign": "^6.0.14", "babel-preset-es2015": "^6.1.2", + "babel-preset-stage-2": "^6.3.13", "eslint": "^1.10.3", "eslint-config-rackt": "^1.1.1", "expect": "^1.13.0", @@ -64,8 +64,5 @@ "redux": "^3.0.4", "redux-devtools": "^2.1.5", "webpack": "^1.12.9" - }, - "dependencies": { - "deep-equal": "^1.0.1" } } diff --git a/src/index.js b/src/index.js index 91b14bd..06cc5ca 100644 --- a/src/index.js +++ b/src/index.js @@ -1,61 +1,40 @@ -import deepEqual from 'deep-equal' - // Constants export const UPDATE_PATH = '@@router/UPDATE_PATH' const SELECT_STATE = state => state.routing -export function pushPath(path, state, { avoidRouterUpdate = false } = {}) { +export function pushPath(path, state, key) { return { type: UPDATE_PATH, - payload: { - path: path, - state: state, - replace: false, - avoidRouterUpdate: !!avoidRouterUpdate - } + payload: { path, state, key, replace: false } } } -export function replacePath(path, state, { avoidRouterUpdate = false } = {}) { +export function replacePath(path, state, key) { return { type: UPDATE_PATH, - payload: { - path: path, - state: state, - replace: true, - avoidRouterUpdate: !!avoidRouterUpdate - } + payload: { path, state, key, replace: true } } } // Reducer let initialState = { - changeId: 1, path: undefined, state: undefined, - replace: false + replace: false, + key: undefined } -function update(state=initialState, { type, payload }) { +export function routeReducer(state=initialState, { type, payload }) { if(type === UPDATE_PATH) { - return Object.assign({}, state, { - path: payload.path, - changeId: state.changeId + (payload.avoidRouterUpdate ? 0 : 1), - state: payload.state, - replace: payload.replace - }) + return payload } + return state } // Syncing - -function locationsAreEqual(a, b) { - return a != null && b != null && a.path === b.path && deepEqual(a.state, b.state) -} - function createPath(location) { const { pathname, search, hash } = location let result = pathname @@ -66,84 +45,75 @@ function createPath(location) { return result } -export function syncReduxAndRouter(history, store, selectRouterState = SELECT_STATE) { - const getRouterState = () => selectRouterState(store.getState()) - - // 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. It's possible for this to happen when something - // reloads the entire app state such as redux devtools. - let lastRoute = undefined - - if(!getRouterState()) { - throw new Error( - 'Cannot sync router: route state does not exist (`state.routing` by default). ' + - 'Did you install the routing reducer?' - ) - } +export function syncHistory(history) { + let unsubscribeHistory, currentKey, unsubscribeStore + let connected = false - const unsubscribeHistory = history.listen(location => { - const route = { - path: createPath(location), - state: location.state - } + function middleware(store) { + unsubscribeHistory = history.listen(location => { + const path = createPath(location) + const { state, key } = location + currentKey = key - if (!lastRoute) { - // `initialState` *should* represent the current location when - // the app loads, but we cannot get the current location when it - // is defined. What happens is `history.listen` is called - // immediately when it is registered, and it updates the app - // state with an UPDATE_PATH action. This causes problem when - // users are listening to UPDATE_PATH actions just for - // *changes*, and with redux devtools because "revert" will use - // `initialState` and it won't revert to the original URL. - // Instead, we specialize the first route notification and do - // different things based on it. - initialState = { - changeId: 1, - path: route.path, - state: route.state, - replace: false - } + const method = location.action === 'REPLACE' ? replacePath : pushPath + store.dispatch(method(path, state, key)) + }) - // Also set `lastRoute` so that the store subscriber doesn't - // trigger an unnecessary `pushState` on load - lastRoute = initialState + connected = true - store.dispatch(pushPath(route.path, route.state, { avoidRouterUpdate: true })); - } else if(!locationsAreEqual(getRouterState(), route)) { - // The above check avoids dispatching an action if the store is - // already up-to-date - const method = location.action === 'REPLACE' ? replacePath : pushPath - store.dispatch(method(route.path, route.state, { avoidRouterUpdate: true })) - } - }) + return next => action => { + if (action.type !== UPDATE_PATH) { + next(action) + return + } - const unsubscribeStore = store.subscribe(() => { - let routing = getRouterState() + const { payload } = action + if (payload.key || !connected) { + // Either this came from the history, or else we're not forwarding + // location actions to history. + next(action) + return + } - // Only trigger history update if this is a new change or the - // location has changed. - if(lastRoute.changeId !== routing.changeId || - !locationsAreEqual(lastRoute, routing)) { + const { replace, state, path } = payload + // FIXME: ???! `path` and `pathname` are _not_ synonymous. + const method = replace ? 'replaceState' : 'pushState' - lastRoute = routing - const method = routing.replace ? 'replace' : 'push' - history[method]({ - pathname: routing.path, - state: routing.state - }) + history[method](state, path) } + } - }) + middleware.syncHistoryToStore = + (store, selectRouterState = SELECT_STATE) => { + const getRouterState = () => selectRouterState(store.getState()) + const { + key: initialKey, state: initialState, path: initialPath + } = getRouterState() + + unsubscribeStore = store.subscribe(() => { + let { key, state, path } = getRouterState() + + // If we're resetting to the beginning, use the saved values. + if (key === undefined) { + key = initialKey + state = initialState + path = initialPath + } + + if (key !== currentKey) { + history.pushState(state, path) + } + }) + } - return function unsubscribe() { + middleware.unsubscribe = () => { unsubscribeHistory() - unsubscribeStore() + if (unsubscribeStore) { + unsubscribeStore() + } + + connected = false } -} -export { update as routeReducer } + return middleware +} diff --git a/test/browser/index.js b/test/browser/index.js index 5310155..faa49a3 100644 --- a/test/browser/index.js +++ b/test/browser/index.js @@ -2,4 +2,4 @@ import { createHashHistory, createHistory } from 'history' import createTests from '../createTests.js' createTests(createHashHistory, 'Hash History', () => window.location = '#/') -createTests(createHistory, 'Browser History', () => window.history.replaceState(null, null, '/')) +createTests(createHistory, 'Browser History', () => window.history.replaceState(null, null, '/'), true) diff --git a/test/createTests.js b/test/createTests.js index ccc04f5..26d6da4 100644 --- a/test/createTests.js +++ b/test/createTests.js @@ -1,8 +1,8 @@ /*eslint-env mocha */ import expect from 'expect' -import { pushPath, replacePath, UPDATE_PATH, routeReducer, syncReduxAndRouter } from '../src/index' -import { createStore, combineReducers, compose } from 'redux' +import { pushPath, replacePath, UPDATE_PATH, routeReducer, syncHistory } from '../src/index' +import { applyMiddleware, createStore, combineReducers, compose } from 'redux' import { devTools } from 'redux-devtools' import { ActionCreators } from 'redux-devtools/lib/devTools' import { useBasename } from 'history' @@ -11,33 +11,34 @@ expect.extend({ toContainRoute({ path, state = undefined, - replace = false, - changeId = undefined + replace = false }) { const routing = this.actual.getState().routing expect(routing.path).toEqual(path) expect(routing.state).toEqual(state) expect(routing.replace).toEqual(replace) - - if (changeId !== undefined) { - expect(routing.changeId).toEqual(changeId) - } } }) function createSyncedHistoryAndStore(createHistory) { - const store = createStore(combineReducers({ + const history = createHistory() + const middleware = syncHistory(history) + const { unsubscribe } = middleware + + const createStoreWithMiddleware = applyMiddleware(middleware)(createStore) + const store = createStoreWithMiddleware(combineReducers({ routing: routeReducer })) - const history = createHistory() - const unsubscribe = syncReduxAndRouter(history, store) + return { history, store, unsubscribe } } const defaultReset = () => {} -module.exports = function createTests(createHistory, name, reset = defaultReset) { +module.exports = function createTests( + createHistory, name, reset = defaultReset, supportsBasename = false +) { describe(name, () => { beforeEach(reset) @@ -50,17 +51,17 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) path: '/foo', replace: false, state: { bar: 'baz' }, - avoidRouterUpdate: false + key: undefined } }) - expect(pushPath('/foo', undefined, { avoidRouterUpdate: true })).toEqual({ + expect(pushPath('/foo', undefined, 'foo')).toEqual({ type: UPDATE_PATH, payload: { path: '/foo', state: undefined, replace: false, - avoidRouterUpdate: true + key: 'foo' } }) }) @@ -74,27 +75,27 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) path: '/foo', replace: true, state: { bar: 'baz' }, - avoidRouterUpdate: false + key: undefined } }) - expect(replacePath('/foo', undefined, { avoidRouterUpdate: true })).toEqual({ + expect(replacePath('/foo', undefined, 'foo')).toEqual({ type: UPDATE_PATH, payload: { path: '/foo', state: undefined, replace: true, - avoidRouterUpdate: true + key: 'foo' } }) - expect(replacePath('/foo', undefined, { avoidRouterUpdate: false })).toEqual({ + expect(replacePath('/foo', undefined, undefined)).toEqual({ type: UPDATE_PATH, payload: { path: '/foo', state: undefined, replace: true, - avoidRouterUpdate: false + key: undefined } }) }) @@ -102,8 +103,7 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) describe('routeReducer', () => { const state = { - path: '/foo', - changeId: 1 + path: '/foo' } it('updates the path', () => { @@ -115,9 +115,7 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) } })).toEqual({ path: '/bar', - replace: false, - state: undefined, - changeId: 2 + replace: false }) }) @@ -126,30 +124,11 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) type: UPDATE_PATH, payload: { path: '/bar', - replace: true, - avoidRouterUpdate: false - } - })).toEqual({ - path: '/bar', - replace: true, - state: undefined, - changeId: 2 - }) - }) - - it('respects `avoidRouterUpdate` flag', () => { - expect(routeReducer(state, { - type: UPDATE_PATH, - payload: { - path: '/bar', - replace: false, - avoidRouterUpdate: true + replace: true } })).toEqual({ path: '/bar', - replace: false, - state: undefined, - changeId: 1 + replace: true }) }) }) @@ -163,16 +142,23 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) beforeEach(() => { history = createHistory() - const finalCreateStore = compose(devTools())(createStore) + + // Set initial URL before syncing + history.push('/foo') + + const middleware = syncHistory(history) + unsubscribe = middleware.unsubscribe + + const finalCreateStore = compose( + applyMiddleware(middleware), + devTools() + )(createStore) store = finalCreateStore(combineReducers({ routing: routeReducer })) devToolsStore = store.devToolsStore - // Set initial URL before syncing - history.push('/foo') - - unsubscribe = syncReduxAndRouter(history, store) + middleware.syncHistoryToStore(store) }) afterEach(() => { @@ -198,7 +184,7 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) historyUnsubscribe() }) - it('handles toggle after store change', () => { + it('handles toggle after history change', () => { let currentPath const historyUnsubscribe = history.listen(location => { currentPath = location.pathname @@ -331,13 +317,14 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) expect(store).toContainRoute({ path: '/foo', replace: false, - state: undefined + state: null }) + // history changes this from PUSH to REPLACE store.dispatch(pushPath('/foo', { bar: 'baz' })) expect(store).toContainRoute({ path: '/foo', - replace: false, + replace: true, state: { bar: 'baz' } }) @@ -348,25 +335,26 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) state: { bar: 'foo' } }) + // history changes this from PUSH to REPLACE store.dispatch(pushPath('/bar')) expect(store).toContainRoute({ path: '/bar', - replace: false, - state: undefined + replace: true, + state: null }) store.dispatch(pushPath('/bar?query=1')) expect(store).toContainRoute({ path: '/bar?query=1', replace: false, - state: undefined + state: null }) store.dispatch(pushPath('/bar?query=1#hash=2')) expect(store).toContainRoute({ path: '/bar?query=1#hash=2', replace: false, - state: undefined + state: null }) }) @@ -440,7 +428,8 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) store.dispatch(pushPath('/foo')) expect(store).toContainRoute({ - path: '/bar' + path: '/bar', + state: null }) store.dispatch(pushPath('/replace', { bar: 'baz' })) @@ -453,33 +442,7 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) expect(updates).toEqual([ '/', '/bar', '/baz' ]) }) - it('throws if "routing" key is missing with default selectRouteState', () => { - const store = createStore(combineReducers({ - notRouting: routeReducer - })) - const history = createHistory() - expect( - () => syncReduxAndRouter(history, store) - ).toThrow(/Cannot sync router: route state does not exist/) - }) - - it('accepts custom selectRouterState', () => { - const store = createStore(combineReducers({ - notRouting: routeReducer - })) - const history = createHistory() - syncReduxAndRouter(history, store, state => state.notRouting) - history.push('/bar') - expect(store.getState().notRouting.path).toEqual('/bar') - }) - it('returns unsubscribe to stop listening to history and store', () => { - const store = createStore(combineReducers({ - routing: routeReducer - })) - const history = createHistory() - const unsubscribe = syncReduxAndRouter(history, store) - history.push('/foo') expect(store).toContainRoute({ path: '/foo', @@ -488,14 +451,19 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) store.dispatch(pushPath('/bar')) expect(store).toContainRoute({ - path: '/bar' + path: '/bar', + state: null }) unsubscribe() + // Make the teardown a no-op. + unsubscribe = () => {} + history.push('/foo') expect(store).toContainRoute({ - path: '/bar' + path: '/bar', + state: null }) history.listenBefore(() => { @@ -534,22 +502,34 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) }) }) - it('handles basename history option', () => { - const store = createStore(combineReducers({ - routing: routeReducer - })) - const history = useBasename(createHistory)({ basename: '/foobar' }) - syncReduxAndRouter(history, store) + describe('basename support', () => { + let history, store, unsubscribe - store.dispatch(pushPath('/bar')) - expect(store).toContainRoute({ - path: '/bar' + beforeEach(() => { + let synced = createSyncedHistoryAndStore( + () => useBasename(createHistory)({ basename: '/foobar' }) + ) + history = synced.history + store = synced.store + unsubscribe = synced.unsubscribe }) - history.push('/baz') - expect(store).toContainRoute({ - path: '/baz', - state: null + afterEach(() => { + unsubscribe() + }) + + it('handles basename history option', () => { + store.dispatch(pushPath('/bar')) + expect(store).toContainRoute({ + path: '/bar', + state: null + }) + + history.push('/baz') + expect(store).toContainRoute({ + path: '/baz', + state: null + }) }) }) })