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..31b682b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,149 +1,112 @@ -import deepEqual from 'deep-equal' - // Constants -export const UPDATE_PATH = '@@router/UPDATE_PATH' +export const TRANSITION = '@@router/TRANSITION' +export const UPDATE_LOCATION = '@@router/UPDATE_LOCATION' + const SELECT_STATE = state => state.routing -export function pushPath(path, state, { avoidRouterUpdate = false } = {}) { - return { - type: UPDATE_PATH, - payload: { - path: path, - state: state, - replace: false, - avoidRouterUpdate: !!avoidRouterUpdate - } - } +function transition(method) { + return arg => ({ + type: TRANSITION, + method, arg + }) } -export function replacePath(path, state, { avoidRouterUpdate = false } = {}) { +export const push = transition('push') +export const replace = transition('replace') + +// TODO: Add go, goBack, goForward. + +function updateLocation(location) { return { - type: UPDATE_PATH, - payload: { - path: path, - state: state, - replace: true, - avoidRouterUpdate: !!avoidRouterUpdate - } + type: UPDATE_LOCATION, + location } } // Reducer -let initialState = { - changeId: 1, - path: undefined, - state: undefined, - replace: false +const initialState = { + location: undefined } -function update(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 - }) +export function routeReducer(state = initialState, { type, location }) { + if (type !== UPDATE_LOCATION) { + return state } - return state + + return { location } } // Syncing -function locationsAreEqual(a, b) { - return a != null && b != null && a.path === b.path && deepEqual(a.state, b.state) -} +export function syncHistory(history) { + let unsubscribeHistory, currentKey, unsubscribeStore + let connected = false, syncing = false -function createPath(location) { - const { pathname, search, hash } = location - let result = pathname - if (search) - result += search - if (hash) - result += hash - return result -} + function middleware(store) { + unsubscribeHistory = history.listen(location => { + currentKey = location.key + if (syncing) { + // Don't dispatch a new action if we're replaying location. + return + } -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?' - ) - } + store.dispatch(updateLocation(location)) + }) - const unsubscribeHistory = history.listen(location => { - const route = { - path: createPath(location), - state: location.state - } + connected = true - 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 + return next => action => { + if (action.type !== TRANSITION || !connected) { + next(action) + return } - // Also set `lastRoute` so that the store subscriber doesn't - // trigger an unnecessary `pushState` on load - lastRoute = initialState + // FIXME: Is it correct to swallow the TRANSITION action here and replace + // it with UPDATE_LOCATION instead? We could also use the same type in + // both places instead and just set the location on the action. - 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 })) + const { method, arg } = action + history[method](arg) } - }) - - const unsubscribeStore = store.subscribe(() => { - let routing = getRouterState() - - // Only trigger history update if this is a new change or the - // location has changed. - if(lastRoute.changeId !== routing.changeId || - !locationsAreEqual(lastRoute, routing)) { + } - lastRoute = routing - const method = routing.replace ? 'replace' : 'push' - history[method]({ - pathname: routing.path, - state: routing.state + middleware.syncHistoryToStore = + (store, selectRouterState = SELECT_STATE) => { + const getRouterState = () => selectRouterState(store.getState()) + const { location: initialLocation } = getRouterState() + + unsubscribeStore = store.subscribe(() => { + const { location } = getRouterState() + + // If we're resetting to the beginning, use the saved initial value. We + // need to dispatch a new action at this point to populate the store + // appropriately. + if (!location) { + history.transitionTo(initialLocation) + return + } + + // Otherwise, if we need to update the history location, do so without + // dispatching a new action, as we're just bringing history in sync + // with the store. + if (location.key !== currentKey) { + syncing = true + history.transitionTo(location) + syncing = false + } }) } - }) - - return function unsubscribe() { + middleware.unsubscribe = () => { unsubscribeHistory() - unsubscribeStore() + if (unsubscribeStore) { + unsubscribeStore() + } + + connected = false } -} -export { update as routeReducer } + return middleware +} diff --git a/test/createTests.js b/test/createTests.js index ccc04f5..b8ad8bd 100644 --- a/test/createTests.js +++ b/test/createTests.js @@ -1,37 +1,43 @@ /*eslint-env mocha */ import expect from 'expect' -import { pushPath, replacePath, UPDATE_PATH, routeReducer, syncReduxAndRouter } from '../src/index' -import { createStore, combineReducers, compose } from 'redux' +import { + push, replace, TRANSITION, UPDATE_LOCATION, 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' +import { useBasename, useQueries } from 'history' expect.extend({ - toContainRoute({ - path, - state = undefined, - replace = false, - changeId = undefined + toContainLocation({ + pathname, + search = '', + hash = '', + state = null, + query, + action = 'PUSH' }) { - const routing = this.actual.getState().routing + const { location } = 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) - } + expect(location.pathname).toEqual(pathname) + expect(location.search).toEqual(search) + expect(location.state).toEqual(state) + expect(location.query).toEqual(query) + expect(location.action).toEqual(action) } }) 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 } } @@ -42,59 +48,39 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) beforeEach(reset) - describe('pushPath', () => { + describe('push', () => { it('creates actions', () => { - expect(pushPath('/foo', { bar: 'baz' })).toEqual({ - type: UPDATE_PATH, - payload: { - path: '/foo', - replace: false, - state: { bar: 'baz' }, - avoidRouterUpdate: false - } - }) - - expect(pushPath('/foo', undefined, { avoidRouterUpdate: true })).toEqual({ - type: UPDATE_PATH, - payload: { - path: '/foo', - state: undefined, - replace: false, - avoidRouterUpdate: true + expect(push('/foo')).toEqual({ + type: TRANSITION, + method: 'push', + arg: '/foo' + }) + + expect(push({ pathname: '/foo', state: { the: 'state' } })).toEqual({ + type: TRANSITION, + method: 'push', + arg: { + pathname: '/foo', + state: { the: 'state' } } }) }) }) - describe('replacePath', () => { + describe('replace', () => { it('creates actions', () => { - expect(replacePath('/foo', { bar: 'baz' })).toEqual({ - type: UPDATE_PATH, - payload: { - path: '/foo', - replace: true, - state: { bar: 'baz' }, - avoidRouterUpdate: false - } - }) - - expect(replacePath('/foo', undefined, { avoidRouterUpdate: true })).toEqual({ - type: UPDATE_PATH, - payload: { - path: '/foo', - state: undefined, - replace: true, - avoidRouterUpdate: true - } - }) - - expect(replacePath('/foo', undefined, { avoidRouterUpdate: false })).toEqual({ - type: UPDATE_PATH, - payload: { - path: '/foo', - state: undefined, - replace: true, - avoidRouterUpdate: false + expect(replace('/foo')).toEqual({ + type: TRANSITION, + method: 'replace', + arg: '/foo' + }) + + expect(replace({ pathname: '/foo', state: { the: 'state' } })).toEqual({ + type: TRANSITION, + method: 'replace', + arg: { + pathname: '/foo', + state: { the: 'state' } } }) }) @@ -102,54 +88,39 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) describe('routeReducer', () => { const state = { - path: '/foo', - changeId: 1 + location: { + pathname: '/foo', + action: 'POP' + } } it('updates the path', () => { expect(routeReducer(state, { - type: UPDATE_PATH, - payload: { + type: UPDATE_LOCATION, + location: { path: '/bar', - replace: false + action: 'PUSH' } })).toEqual({ - path: '/bar', - replace: false, - state: undefined, - changeId: 2 + location: { + path: '/bar', + action: 'PUSH' + } }) }) it('respects replace', () => { expect(routeReducer(state, { - type: UPDATE_PATH, - payload: { + type: UPDATE_LOCATION, + location: { path: '/bar', - replace: true, - avoidRouterUpdate: false + action: 'REPLACE' } })).toEqual({ - path: '/bar', - replace: true, - state: undefined, - changeId: 2 - }) - }) - - it('respects `avoidRouterUpdate` flag', () => { - expect(routeReducer(state, { - type: UPDATE_PATH, - payload: { + location: { path: '/bar', - replace: false, - avoidRouterUpdate: true + action: 'REPLACE' } - })).toEqual({ - path: '/bar', - replace: false, - state: undefined, - changeId: 1 }) }) }) @@ -163,16 +134,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(() => { @@ -186,19 +164,19 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) }) history.push('/bar') - store.dispatch(pushPath('/baz')) + store.dispatch(push('/baz')) // By calling reset we expect DevTools to re-play the initial state // and the history to update to the initial path devToolsStore.dispatch(ActionCreators.reset()) - expect(store.getState().routing.path).toEqual('/foo') + expect(store.getState().routing.location.pathname).toEqual('/foo') expect(currentPath).toEqual('/foo') historyUnsubscribe() }) - it('handles toggle after store change', () => { + it('handles toggle after history change', () => { let currentPath const historyUnsubscribe = history.listen(location => { currentPath = location.pathname @@ -224,9 +202,9 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) }) // DevTools action #2 - store.dispatch(pushPath('/foo2')) + store.dispatch(push('/foo2')) // DevTools action #3 - store.dispatch(pushPath('/foo3')) + store.dispatch(push('/foo3')) // When we toggle an action, the devtools will revert the action // and we therefore expect the history to update to the previous path @@ -252,121 +230,116 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) }) it('syncs router -> redux', () => { - expect(store).toContainRoute({ - path: '/', - state: null + expect(store).toContainLocation({ + pathname: '/', + action: 'POP' }) history.push('/foo') - expect(store).toContainRoute({ - path: '/foo', - replace: false, - state: null + expect(store).toContainLocation({ + pathname: '/foo' }) history.push({ state: { bar: 'baz' }, pathname: '/foo' }) - expect(store).toContainRoute({ - path: '/foo', - replace: true, - state: { bar: 'baz' } + expect(store).toContainLocation({ + pathname: '/foo', + state: { bar: 'baz' }, + action: 'REPLACE' // Converted by history. }) history.replace('/bar') - expect(store).toContainRoute({ - path: '/bar', - replace: true, - state: null + expect(store).toContainLocation({ + pathname: '/bar', + action: 'REPLACE' }) history.push('/bar') - expect(store).toContainRoute({ - path: '/bar', - replace: true, - state: null + expect(store).toContainLocation({ + pathname: '/bar', + action: 'REPLACE' // Converted by history. }) history.push('/bar?query=1') - expect(store).toContainRoute({ - path: '/bar?query=1', - replace: false, - state: null + expect(store).toContainLocation({ + pathname: '/bar', + search: '?query=1' }) history.push('/bar#baz') - expect(store).toContainRoute({ - path: '/bar#baz', - replace: false, - state: null + expect(store).toContainLocation({ + pathname: '/bar', + hash: '#baz' }) - history.replace({ - state: { bar: 'baz' }, - pathname: '/bar?query=1' - }) - expect(store).toContainRoute({ - path: '/bar?query=1', - replace: true, + history.replace({ + pathname: '/bar', + search: '?query=1', state: { bar: 'baz' } }) + expect(store).toContainLocation({ + pathname: '/bar', + search: '?query=1', + state: { bar: 'baz' }, + action: 'REPLACE' + }) history.replace({ - state: { bar: 'baz' }, - pathname: '/bar?query=1#hash=2' - }) - expect(store).toContainRoute({ - path: '/bar?query=1#hash=2', - replace: true, + pathname: '/bar', + search: '?query=1', + hash: '#hash=2', state: { bar: 'baz' } }) + expect(store).toContainLocation({ + pathname: '/bar', + search: '?query=1', + hash: '#hash=2', + state: { bar: 'baz' }, + action: 'REPLACE' + }) }) it('syncs redux -> router', () => { - expect(store).toContainRoute({ - path: '/', - replace: false, - state: null + expect(store).toContainLocation({ + pathname: '/', + action: 'POP' }) - store.dispatch(pushPath('/foo')) - expect(store).toContainRoute({ - path: '/foo', - replace: false, - state: undefined + store.dispatch(push('/foo')) + expect(store).toContainLocation({ + pathname: '/foo' }) - store.dispatch(pushPath('/foo', { bar: 'baz' })) - expect(store).toContainRoute({ - path: '/foo', - replace: false, - state: { bar: 'baz' } + store.dispatch(push({ pathname: '/foo', state: { bar: 'baz' } })) + expect(store).toContainLocation({ + pathname: '/foo', + state: { bar: 'baz' }, + action: 'REPLACE' // Converted by history. }) - store.dispatch(replacePath('/bar', { bar: 'foo' })) - expect(store).toContainRoute({ - path: '/bar', - replace: true, - state: { bar: 'foo' } + store.dispatch(replace({ pathname: '/bar', state: { bar: 'foo' } })) + expect(store).toContainLocation({ + pathname: '/bar', + state: { bar: 'foo' }, + action: 'REPLACE' }) - store.dispatch(pushPath('/bar')) - expect(store).toContainRoute({ - path: '/bar', - replace: false, - state: undefined + store.dispatch(push('/bar')) + expect(store).toContainLocation({ + pathname: '/bar', + action: 'REPLACE' // Converted by history. }) - store.dispatch(pushPath('/bar?query=1')) - expect(store).toContainRoute({ - path: '/bar?query=1', - replace: false, - state: undefined + store.dispatch(push('/bar?query=1')) + expect(store).toContainLocation({ + pathname: '/bar', + search: '?query=1' }) - store.dispatch(pushPath('/bar?query=1#hash=2')) - expect(store).toContainRoute({ - path: '/bar?query=1#hash=2', - replace: false, - state: undefined + store.dispatch(push('/bar?query=1#hash=2')) + expect(store).toContainLocation({ + pathname: '/bar', + search: '?query=1', + hash: '#hash=2' }) }) @@ -376,9 +349,9 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) updates.push(location.pathname) }) - store.dispatch(pushPath('/foo')) - store.dispatch(pushPath('/foo')) - store.dispatch(replacePath('/foo')) + store.dispatch(push('/foo')) + store.dispatch(push('/foo')) + store.dispatch(replace('/foo')) expect(updates).toEqual([ '/', '/foo', '/foo', '/foo' ]) @@ -418,7 +391,7 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) updates.push(location.pathname) }) - store.dispatch(pushPath('/foo')) + store.dispatch(push('/foo')) expect(updates).toEqual([ '/', '/foo' ]) }) @@ -426,10 +399,10 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) it('allows updating the route from within `listenBefore`', () => { history.listenBefore(location => { if(location.pathname === '/foo') { - store.dispatch(pushPath('/bar')) + store.dispatch(push('/bar')) } else if(location.pathname === '/replace') { - store.dispatch(replacePath('/baz', { foo: 'bar' })) + store.dispatch(replace({ pathname: '/baz', state: { foo: 'bar' } })) } }) @@ -438,71 +411,47 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) updates.push(location.pathname) }) - store.dispatch(pushPath('/foo')) - expect(store).toContainRoute({ - path: '/bar' + store.dispatch(push('/foo')) + expect(store).toContainLocation({ + pathname: '/bar' }) - store.dispatch(pushPath('/replace', { bar: 'baz' })) - expect(store).toContainRoute({ - path: '/baz', + store.dispatch(push({ pathname: '/replace', state: { bar: 'baz' } })) + expect(store).toContainLocation({ + pathname: '/baz', state: { foo: 'bar' }, - replace: true + action: 'REPLACE' }) 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', - state: null + expect(store).toContainLocation({ + pathname: '/foo' }) - store.dispatch(pushPath('/bar')) - expect(store).toContainRoute({ - path: '/bar' + store.dispatch(push('/bar')) + expect(store).toContainLocation({ + pathname: '/bar' }) unsubscribe() + // Make the teardown a no-op. + unsubscribe = () => {} + history.push('/foo') - expect(store).toContainRoute({ - path: '/bar' + expect(store).toContainLocation({ + pathname: '/bar' }) history.listenBefore(() => { throw new Error() }) expect( - () => store.dispatch(pushPath('/foo')) + () => store.dispatch(push('/foo')) ).toNotThrow() }) @@ -512,8 +461,8 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) updates.push(location.pathname) }) - store.dispatch(pushPath('/bar')) - store.dispatch(pushPath('/baz')) + store.dispatch(push('/bar')) + store.dispatch(push('/baz')) expect(updates).toEqual([ '/', '/bar', '/baz' ]) historyUnsubscribe() @@ -522,34 +471,81 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) it('only triggers store once when updating path via store', () => { const updates = [] const storeUnsubscribe = store.subscribe(() => { - updates.push(store.getState().routing.path) + updates.push(store.getState().routing.location.pathname) }) - store.dispatch(pushPath('/bar')) - store.dispatch(pushPath('/baz')) - store.dispatch(replacePath('/foo')) + store.dispatch(push('/bar')) + store.dispatch(push('/baz')) + store.dispatch(replace('/foo')) expect(updates).toEqual([ '/bar', '/baz', '/foo' ]) storeUnsubscribe() }) }) - it('handles basename history option', () => { - const store = createStore(combineReducers({ - routing: routeReducer - })) - const history = useBasename(createHistory)({ basename: '/foobar' }) - syncReduxAndRouter(history, store) + describe('query support', () => { + let history, store, unsubscribe + + beforeEach(() => { + const synced = createSyncedHistoryAndStore(useQueries(createHistory)) + history = synced.history + store = synced.store + unsubscribe = synced.unsubscribe + }) + + afterEach(() => { + unsubscribe() + }) + + it('handles location queries', () => { + store.dispatch(push({ pathname: '/bar', query: { the: 'query' } })) + expect(store).toContainLocation({ + pathname: '/bar', + query: { the: 'query' }, + search: '?the=query' + }) + + history.push({ pathname: '/baz', query: { other: 'query' } }) + expect(store).toContainLocation({ + pathname: '/baz', + query: { other: 'query' }, + search: '?other=query' + }) + + store.dispatch(push('/foo')) + expect(store).toContainLocation({ + pathname: '/foo', + query: {} + }) + }) + }) + + describe('basename support', () => { + let history, store, unsubscribe + + beforeEach(() => { + const synced = createSyncedHistoryAndStore( + () => useBasename(createHistory)({ basename: '/foobar' }) + ) + history = synced.history + store = synced.store + unsubscribe = synced.unsubscribe + }) - store.dispatch(pushPath('/bar')) - expect(store).toContainRoute({ - path: '/bar' + afterEach(() => { + unsubscribe() }) - history.push('/baz') - expect(store).toContainRoute({ - path: '/baz', - state: null + it('handles basename history option', () => { + store.dispatch(push('/bar')) + expect(store).toContainLocation({ + pathname: '/bar' + }) + + history.push('/baz') + expect(store).toContainLocation({ + pathname: '/baz' + }) }) }) })