From 7cdc72a5bf69e62f6943f57d36b68033949e49f6 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 20:07:44 -0500 Subject: [PATCH 01/28] Extract the action creators and middleware. Getting the easy parts done first. --- src/actions.js | 26 +++++++++++ src/index.js | 117 +++------------------------------------------- src/middleware.js | 17 +++++++ 3 files changed, 49 insertions(+), 111 deletions(-) create mode 100644 src/actions.js create mode 100644 src/middleware.js diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000..3406b48 --- /dev/null +++ b/src/actions.js @@ -0,0 +1,26 @@ +/** + * This action type will be dispatched by the history actions below. + * If you're writing a middleware to watch for navigation events, be sure to + * look for actions of this type. + */ +export const UPDATE_LOCATION = '@@router/UPDATE_LOCATION' + +function updateLocation(method) { + return (...args) => ({ + type: UPDATE_LOCATION, + payload: { method, args } + }) +} + +/** + * These actions correspond to the history API. + * The associated routerMiddleware will capture these events before they get to + * your reducer and reissue them as the matching function on your history. + */ +export const push = updateLocation('push') +export const replace = updateLocation('replace') +export const go = updateLocation('go') +export const goBack = updateLocation('goBack') +export const goForward = updateLocation('goForward') + +export const routeActions = { push, replace, go, goBack, goForward } diff --git a/src/index.js b/src/index.js index 8c62ec8..861513a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,112 +1,7 @@ -// Constants +export { + UPDATE_LOCATION, + push, replace, go, goBack, goForward, + routeActions +} from './actions' -export const TRANSITION = '@@router/TRANSITION' -export const UPDATE_LOCATION = '@@router/UPDATE_LOCATION' - -const SELECT_LOCATION = state => state.routing.location - -function transition(method) { - return (...args) => ({ - type: TRANSITION, - payload: { method, args } - }) -} - -export const push = transition('push') -export const replace = transition('replace') -export const go = transition('go') -export const goBack = transition('goBack') -export const goForward = transition('goForward') - -export const routeActions = { push, replace, go, goBack, goForward } - -function updateLocation(location) { - return { - type: UPDATE_LOCATION, - payload: location - } -} - -// Reducer - -const initialState = { - location: undefined -} - -export function routeReducer(state = initialState, { type, payload: location }) { - if (type !== UPDATE_LOCATION) { - return state - } - - return { ...state, location } -} - -// Syncing - -export function syncHistory(history) { - let unsubscribeHistory, currentKey, unsubscribeStore - let connected = false, syncing = false - - history.listen(location => { initialState.location = location })() - - function middleware(store) { - unsubscribeHistory = history.listen(location => { - currentKey = location.key - if (syncing) { - // Don't dispatch a new action if we're replaying location. - return - } - - store.dispatch(updateLocation(location)) - }) - - connected = true - - return next => action => { - if (action.type !== TRANSITION || !connected) { - return next(action) - } - - const { payload: { method, args } } = action - history[method](...args) - } - } - - middleware.listenForReplays = - (store, selectLocationState = SELECT_LOCATION) => { - const getLocationState = () => selectLocationState(store.getState()) - const initialLocation = getLocationState() - - unsubscribeStore = store.subscribe(() => { - const location = getLocationState() - - // 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.key === initialLocation.key) { - history.replace(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 - } - }) - } - - middleware.unsubscribe = () => { - unsubscribeHistory() - if (unsubscribeStore) { - unsubscribeStore() - } - - connected = false - } - - return middleware -} +export routerMiddleware from './middleware' diff --git a/src/middleware.js b/src/middleware.js new file mode 100644 index 0000000..0e83bab --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,17 @@ +import UPDATE_LOCATION from './actions' + +/** + * This middleware captures UPDATE_LOCATION actions to redirect to the + * provided history object. This will prevent these actions from reaching your + * reducer or any middleware that comes after this one. + */ +export default function routerMiddleware(history) { + return () => next => action => { + if (action.type !== UPDATE_LOCATION) { + return next(action) + } + + const { payload: { method, args } } = action + history[method](...args) + } +} From 294693881252c1ded770e11a9f175308fe110c0e Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 20:48:28 -0500 Subject: [PATCH 02/28] Pull in @gaearon's history syncer. See https://github.com/rackt/redux/pull/1362 Some small modifications and commenting added. Absolutely needs tests! --- src/index.js | 2 + src/sync.js | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/sync.js diff --git a/src/index.js b/src/index.js index 861513a..3c3947b 100644 --- a/src/index.js +++ b/src/index.js @@ -5,3 +5,5 @@ export { } from './actions' export routerMiddleware from './middleware' + +export { LOCATION_CHANGE, routerReducer, syncHistoryWithStore } from './sync' diff --git a/src/sync.js b/src/sync.js new file mode 100644 index 0000000..1be858e --- /dev/null +++ b/src/sync.js @@ -0,0 +1,150 @@ +/** + * This action type will be dispatched when your history + * receives a location change. + */ +export const LOCATION_CHANGE = '@@router/LOCATION_CHANGE' + +const initialState = { + locationBeforeTransitions: null +} + +const defaultSelectLocationState = state => state.routing + +/** + * This reducer will update the state with the most recent location history + * has transitioned to. This may not be in sync with the router, particularly + * if you have asynchronously-loaded routes, so reading from and relying on + * this state it is discouraged. + */ +export function routerReducer(state = initialState, { type, locationBeforeTransitions }) { + if (type === LOCATION_CHANGE) { + return { ...state, locationBeforeTransitions } + } + + return state +} + +/** + * This function synchronizes your history state with the Redux store. + * Location changes flow from history to the store. An enhanced history is + * returned with a listen method that responds to store updates for location. + * + * When this history is provided to the router, this means the location data + * will flow like this: + * history.push -> store.dispatch -> enhancedHistory.listen -> router + * This ensures that when the store state changes due to a replay or other + * event, the router will be updated appropriately and can transition to the + * correct router state. + */ +export function syncHistoryWithStore(history, store, { + selectLocationState = defaultSelectLocationState, + adjustUrlOnReplay = true +} = {}) { + // Ensure that the reducer is mounted on the store and functioning properly. + if (typeof selectLocationState(store.getState()) === 'undefined') { + throw new Error( + 'Expected the routing state to be available either as `state.routing` ' + + 'or as the custom expression you can specify as `selectLocationState` ' + + 'in the `syncHistoryWithStore()` options. ' + + 'Ensure you have added the `routerReducer` to your store\'s ' + + 'reducers via `combineReducers` or whatever method you use to isolate ' + + 'your reducers.' + ) + } + + let initialLocation + let currentLocation + let isTimeTraveling + let unsubscribeFromStore + let unsubscribeFromHistory + + // What does the store say about current location? + const getLocationInStore = (useInitialIfEmpty) => { + const locationState = selectLocationState(store.getState()) + return locationState.locationBeforeTransitions || + (useInitialIfEmpty ? initialLocation : undefined) + } + + // If the store is replayed, update the URL in the browser to match. + if (adjustUrlOnReplay) { + const handleStoreChange = () => { + const locationInStore = getLocationInStore(true) + if (currentLocation === locationInStore) { + return + } + + // Update address bar to reflect store state + isTimeTraveling = true + currentLocation = locationInStore + history.transitionTo(Object.assign({}, + locationInStore, + { action: 'PUSH' } + )) + isTimeTraveling = false + } + + unsubscribeFromStore = store.subscribe(handleStoreChange) + handleStoreChange() + } + + // Whenever location changes, dispatch an action to get it in the store + const handleLocationChange = (location) => { + // ... unless we just caused that location change + if (isTimeTraveling) { + return + } + + // Remember where we are + currentLocation = location + + // Are we being called for the first time? + if (!initialLocation) { + // Remember as a fallback in case state is reset + initialLocation = location + + // Respect persisted location, if any + if (getLocationInStore()) { + return + } + } + + // Tell the store to update by dispatching an action + store.dispatch({ + type: LOCATION_CHANGE, + locationBeforeTransitions: location + }) + } + unsubscribeFromHistory = history.listen(handleLocationChange) + + // The enhanced history uses store as source of truth + return Object.assign({}, history, { + // The listeners are subscribed to the store instead of history + listen(listener) { + // History listeners expect a synchronous call + listener(getLocationInStore(true)) + + // Keep track of whether we unsubscribed, as Redux store + // only applies changes in subscriptions on next dispatch + let unsubscribed = false + const unsubscribeFromStore = store.subscribe(() => { + if (!unsubscribed) { + listener(getLocationInStore(true)) + } + }) + + // Let user unsubscribe later + return () => { + unsubscribed = true + unsubscribeFromStore() + } + }, + + // It also provides a way to destroy internal listeners + dispose() { + if (adjustUrlOnReplay) { + unsubscribeFromStore() + } + unsubscribeFromHistory() + } + }) +} From 21b511b6e76bd458ee2d98897b7fa54776392a0a Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 20:48:58 -0500 Subject: [PATCH 03/28] Go with Babel Stage 1 for export extensions. --- .babelrc | 2 +- package.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.babelrc b/.babelrc index 2d4d503..63d065b 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,3 @@ { - "presets": ["es2015", "stage-2"] + "presets": ["es2015", "stage-1"] } diff --git a/package.json b/package.json index a4ebdbf..73659c7 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "babel-core": "^6.2.1", "babel-eslint": "^4.1.6", "babel-loader": "^6.2.0", - "babel-preset-es2015": "^6.1.2", - "babel-preset-stage-2": "^6.3.13", + "babel-preset-es2015": "^6.3.13", + "babel-preset-stage-1": "^6.3.13", "eslint": "^1.10.3", "eslint-config-rackt": "^1.1.1", "eslint-plugin-react": "^3.15.0", From fbe174f0ddd94f8adcfca471fd6c51ceb8aafdc0 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 20:55:10 -0500 Subject: [PATCH 04/28] Update the example against the new API. Not that bad. In fact, it's mostly stuff removed! :) --- examples/basic/app.js | 18 +++++++----------- examples/basic/components/App.js | 13 +++---------- examples/basic/package.json | 31 +++++++++++++++---------------- 3 files changed, 25 insertions(+), 37 deletions(-) diff --git a/examples/basic/app.js b/examples/basic/app.js index 00f5b3d..a26eb1b 100644 --- a/examples/basic/app.js +++ b/examples/basic/app.js @@ -6,18 +6,15 @@ import React from 'react' import ReactDOM from 'react-dom' import { applyMiddleware, compose, createStore, combineReducers } from 'redux' import { Provider } from 'react-redux' -import { Router, Route, IndexRoute } from 'react-router' -import createHistory from 'history/lib/createHashHistory' -import { syncHistory, routeReducer } from 'react-router-redux' +import { Router, Route, IndexRoute, hashHistory } from 'react-router' +import { syncHistoryWithStore, routerReducer } from 'react-router-redux' import * as reducers from './reducers' import { App, Home, Foo, Bar } from './components' -const history = createHistory() -const middleware = syncHistory(history) const reducer = combineReducers({ ...reducers, - routing: routeReducer + routing: routerReducer }) const DevTools = createDevTools( @@ -27,12 +24,11 @@ const DevTools = createDevTools( ) -const finalCreateStore = compose( - applyMiddleware(middleware), +const store = createStore( + reducer, DevTools.instrument() -)(createStore) -const store = finalCreateStore(reducer) -middleware.listenForReplays(store) +) +const history = syncHistoryWithStore(hashHistory, store) ReactDOM.render( diff --git a/examples/basic/components/App.js b/examples/basic/components/App.js index 2facee0..22a42a2 100644 --- a/examples/basic/components/App.js +++ b/examples/basic/components/App.js @@ -1,9 +1,7 @@ import React from 'react' -import { Link } from 'react-router' -import { connect } from 'react-redux' -import { routeActions } from 'react-router-redux' +import { Link, hashHistory } from 'react-router' -function App({ push, children }) { +export default function App({ children }) { return (
@@ -16,14 +14,9 @@ function App({ push, children }) { Bar
- +
{children}
) } - -export default connect( - null, - routeActions -)(App) diff --git a/examples/basic/package.json b/examples/basic/package.json index bbebb67..f83f42e 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -4,28 +4,27 @@ "repository": "rackt/react-router-redux", "license": "MIT", "dependencies": { - "history": "^1.14.0", - "react": "^0.14.2", - "react-dom": "^0.14.2", - "react-redux": "^4.0.0", - "react-router": "^1.0.0", - "redux": "^3.0.4", - "react-router-redux": "^2.1.0" + "react": "^0.14.7", + "react-dom": "^0.14.7", + "react-redux": "^4.3.0", + "react-router": "^2.0.0-rc5", + "redux": "^3.2.1", + "react-router-redux": "^3.0.0" }, "devDependencies": { - "babel-core": "^6.1.21", - "babel-eslint": "^5.0.0-beta6", - "babel-loader": "^6.2.0", - "babel-preset-es2015": "^6.1.18", - "babel-preset-react": "^6.1.18", + "babel-core": "^6.4.5", + "babel-eslint": "^5.0.0-beta9", + "babel-loader": "^6.2.2", + "babel-preset-es2015": "^6.3.13", + "babel-preset-react": "^6.3.13", "babel-preset-stage-1": "^6.3.13", "eslint": "^1.10.3", "eslint-config-rackt": "^1.1.1", - "eslint-plugin-react": "^3.15.0", - "redux-devtools": "^3.0.0", + "eslint-plugin-react": "^3.16.1", + "redux-devtools": "^3.1.0", "redux-devtools-dock-monitor": "^1.0.1", - "redux-devtools-log-monitor": "^1.0.1", - "webpack": "^1.12.6" + "redux-devtools-log-monitor": "^1.0.4", + "webpack": "^1.12.13" }, "scripts": { "start": "webpack --watch" From 88bc0de17e1a18f7770c589eb4560a18144db9c0 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 22:25:07 -0500 Subject: [PATCH 05/28] Minor reorg. Split the reducer to its own file. --- .gitignore | 1 + src/index.js | 6 +++--- src/reducer.js | 23 +++++++++++++++++++++++ src/sync.js | 26 ++------------------------ 4 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 src/reducer.js diff --git a/.gitignore b/.gitignore index 7c4bc80..b2cc0b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ lib node_modules coverage +*.log diff --git a/src/index.js b/src/index.js index 3c3947b..787d912 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,9 @@ +export syncHistoryWithStore from './sync' +export { LOCATION_CHANGE, routerReducer } from './reducer' + export { UPDATE_LOCATION, push, replace, go, goBack, goForward, routeActions } from './actions' - export routerMiddleware from './middleware' - -export { LOCATION_CHANGE, routerReducer, syncHistoryWithStore } from './sync' diff --git a/src/reducer.js b/src/reducer.js new file mode 100644 index 0000000..7ad688d --- /dev/null +++ b/src/reducer.js @@ -0,0 +1,23 @@ +/** + * This action type will be dispatched when your history + * receives a location change. + */ +export const LOCATION_CHANGE = '@@router/LOCATION_CHANGE' + +const initialState = { + locationBeforeTransitions: null +} + +/** + * This reducer will update the state with the most recent location history + * has transitioned to. This may not be in sync with the router, particularly + * if you have asynchronously-loaded routes, so reading from and relying on + * this state it is discouraged. + */ +export function routerReducer(state = initialState, { type, locationBeforeTransitions }) { + if (type === LOCATION_CHANGE) { + return { ...state, locationBeforeTransitions } + } + + return state +} diff --git a/src/sync.js b/src/sync.js index 1be858e..bfb0fe1 100644 --- a/src/sync.js +++ b/src/sync.js @@ -1,29 +1,7 @@ -/** - * This action type will be dispatched when your history - * receives a location change. - */ -export const LOCATION_CHANGE = '@@router/LOCATION_CHANGE' - -const initialState = { - locationBeforeTransitions: null -} +import { LOCATION_CHANGE } from './reducer' const defaultSelectLocationState = state => state.routing -/** - * This reducer will update the state with the most recent location history - * has transitioned to. This may not be in sync with the router, particularly - * if you have asynchronously-loaded routes, so reading from and relying on - * this state it is discouraged. - */ -export function routerReducer(state = initialState, { type, locationBeforeTransitions }) { - if (type === LOCATION_CHANGE) { - return { ...state, locationBeforeTransitions } - } - - return state -} - /** * This function synchronizes your history state with the Redux store. * Location changes flow from history to the store. An enhanced history is @@ -36,7 +14,7 @@ export function routerReducer(state = initialState, { type, locationBeforeTransi * event, the router will be updated appropriately and can transition to the * correct router state. */ -export function syncHistoryWithStore(history, store, { +export default function syncHistoryWithStore(history, store, { selectLocationState = defaultSelectLocationState, adjustUrlOnReplay = true } = {}) { From a2ec57bb6d43fa547a550607bf91fc5f45c3144c Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 22:25:33 -0500 Subject: [PATCH 06/28] Fix linting in example. --- examples/basic/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/basic/app.js b/examples/basic/app.js index a26eb1b..76bfa5b 100644 --- a/examples/basic/app.js +++ b/examples/basic/app.js @@ -4,7 +4,7 @@ import DockMonitor from 'redux-devtools-dock-monitor' import React from 'react' import ReactDOM from 'react-dom' -import { applyMiddleware, compose, createStore, combineReducers } from 'redux' +import { createStore, combineReducers } from 'redux' import { Provider } from 'react-redux' import { Router, Route, IndexRoute, hashHistory } from 'react-router' import { syncHistoryWithStore, routerReducer } from 'react-router-redux' From 36845f5fd1843df5944f08c34b400f1d9cda5931 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 22:45:07 -0500 Subject: [PATCH 07/28] Hollow out the old tests and fill in something basic. --- package.json | 2 +- src/sync.js | 2 +- test/browser/index.js | 6 +- test/createTests.js | 530 +++--------------------------------------- test/node/index.js | 4 +- 5 files changed, 39 insertions(+), 505 deletions(-) diff --git a/package.json b/package.json index 73659c7..83b9760 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "eslint-config-rackt": "^1.1.1", "eslint-plugin-react": "^3.15.0", "expect": "^1.13.0", - "history": "^1.14.0", "isparta": "^4.0.0", "isparta-loader": "^2.0.0", "karma": "^0.13.3", @@ -62,6 +61,7 @@ "karma-webpack": "^1.7.0", "mocha": "^2.3.4", "react": "^0.14.3", + "react-router": "^2.0.0-rc5", "redux": "^3.0.4", "redux-devtools": "^3.0.0", "redux-devtools-dock-monitor": "^1.0.1", diff --git a/src/sync.js b/src/sync.js index bfb0fe1..c422840 100644 --- a/src/sync.js +++ b/src/sync.js @@ -118,7 +118,7 @@ export default function syncHistoryWithStore(history, store, { }, // It also provides a way to destroy internal listeners - dispose() { + unsubscribe() { if (adjustUrlOnReplay) { unsubscribeFromStore() } diff --git a/test/browser/index.js b/test/browser/index.js index 5310155..93e51c7 100644 --- a/test/browser/index.js +++ b/test/browser/index.js @@ -1,5 +1,5 @@ -import { createHashHistory, createHistory } from 'history' +import { hashHistory, browserHistory } from 'react-router' import createTests from '../createTests.js' -createTests(createHashHistory, 'Hash History', () => window.location = '#/') -createTests(createHistory, 'Browser History', () => window.history.replaceState(null, null, '/')) +createTests(hashHistory, 'Hash History', () => window.location = '#/') +createTests(browserHistory, 'Browser History', () => window.history.replaceState(null, null, '/')) diff --git a/test/createTests.js b/test/createTests.js index dcfbfad..18dab62 100644 --- a/test/createTests.js +++ b/test/createTests.js @@ -1,12 +1,19 @@ /*eslint-env mocha */ import expect from 'expect' -import { - routeActions, TRANSITION, UPDATE_LOCATION, routeReducer, syncHistory -} from '../src/index' -import { applyMiddleware, createStore, combineReducers, compose } from 'redux' -import { ActionCreators, instrument } from 'redux-devtools' -import { useBasename, useQueries } from 'history' + +import { createStore, combineReducers } from 'redux' +// import { ActionCreators, instrument } from 'redux-devtools' +// import { useBasename, useQueries } from 'history' + +import syncHistoryWithStore from '../src/sync' +import { routerReducer } from '../src/reducer' +// import { +// UPDATE_LOCATION, +// push, replace, go, goBack, goForward, +// routeActions +// } from '../src/actions' +// import routerMiddleware from '../src/middleware' expect.extend({ toContainLocation({ @@ -14,10 +21,11 @@ expect.extend({ search = '', hash = '', state = null, - query, + query = {}, action = 'PUSH' }) { - const { location } = this.actual.getState().routing + const { locationBeforeTransitions } = this.actual.getState().routing + const location = locationBeforeTransitions expect(location.pathname).toEqual(pathname) expect(location.search).toEqual(search) @@ -28,263 +36,35 @@ expect.extend({ } }) -function createSyncedHistoryAndStore(createHistory) { - const history = createHistory() - const middleware = syncHistory(history) - const { unsubscribe } = middleware - const createStoreWithMiddleware = applyMiddleware(middleware)(createStore) - const store = createStoreWithMiddleware(combineReducers({ - routing: routeReducer +function createSyncedHistoryAndStore(testHistory) { + + const store = createStore(combineReducers({ + routing: routerReducer })) + const history = syncHistoryWithStore(testHistory, store) - return { history, store, unsubscribe } + return { history, store } } const defaultReset = () => {} -const { push, replace, go, goBack, goForward } = routeActions - -module.exports = function createTests(createHistory, name, reset = defaultReset) { +module.exports = function createTests(testHistory, name, reset = defaultReset) { describe(name, () => { beforeEach(reset) - describe('routeActions', () => { - - describe('push', () => { - it('creates actions', () => { - expect(push('/foo')).toEqual({ - type: TRANSITION, - payload: { - method: 'push', - args: [ '/foo' ] - } - }) - - expect(push({ pathname: '/foo', state: { the: 'state' } })).toEqual({ - type: TRANSITION, - payload: { - method: 'push', - args: [ { - pathname: '/foo', - state: { the: 'state' } - } ] - } - }) - - expect(push('/foo', 'baz', 123)).toEqual({ - type: TRANSITION, - payload: { - method: 'push', - args: [ '/foo' , 'baz', 123 ] - } - }) - }) - }) - - describe('replace', () => { - it('creates actions', () => { - expect(replace('/foo')).toEqual({ - type: TRANSITION, - payload: { - method: 'replace', - args: [ '/foo' ] - } - }) - - expect(replace({ pathname: '/foo', state: { the: 'state' } })).toEqual({ - type: TRANSITION, - payload: { - method: 'replace', - args: [ { - pathname: '/foo', - state: { the: 'state' } - } ] - } - }) - }) - }) - - describe('go', () => { - it('creates actions', () => { - expect(go(1)).toEqual({ - type: TRANSITION, - payload: { - method: 'go', - args: [ 1 ] - } - }) - }) - }) - - describe('goBack', () => { - it('creates actions', () => { - expect(goBack()).toEqual({ - type: TRANSITION, - payload: { - method: 'goBack', - args: [] - } - }) - }) - }) - - describe('goForward', () => { - it('creates actions', () => { - expect(goForward()).toEqual({ - type: TRANSITION, - payload: { - method: 'goForward', - args: [] - } - }) - }) - }) - - }) - - describe('routeReducer', () => { - const state = { - location: { - pathname: '/foo', - action: 'POP' - } - } - - it('updates the path', () => { - expect(routeReducer(state, { - type: UPDATE_LOCATION, - payload: { - path: '/bar', - action: 'PUSH' - } - })).toEqual({ - location: { - path: '/bar', - action: 'PUSH' - } - }) - }) - - it('respects replace', () => { - expect(routeReducer(state, { - type: UPDATE_LOCATION, - payload: { - path: '/bar', - action: 'REPLACE' - } - })).toEqual({ - location: { - path: '/bar', - action: 'REPLACE' - } - }) - }) - }) - - // To ensure that "Revert" and toggling actions work as expected in - // Redux DevTools we need a couple of tests for it. In these tests we - // rely directly on the DevTools, as they implement these actions as - // middleware, and we don't want to implement this ourselves. - describe('devtools', () => { - let history, store, devToolsStore, unsubscribe + describe('syncHistoryWithStore', () => { + let history, store beforeEach(() => { - history = createHistory() - - // Set initial URL before syncing - history.push('/foo') - - const middleware = syncHistory(history) - unsubscribe = middleware.unsubscribe - - const finalCreateStore = compose( - applyMiddleware(middleware), - instrument() - )(createStore) - store = finalCreateStore(combineReducers({ - routing: routeReducer - })) - devToolsStore = store.liftedStore - - middleware.listenForReplays(store) - }) - - afterEach(() => { - unsubscribe() - }) - - it('resets to the initial url', () => { - let currentPath - const historyUnsubscribe = history.listen(location => { - currentPath = location.pathname - }) - - history.push('/bar') - 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.location.pathname).toEqual('/foo') - expect(currentPath).toEqual('/foo') - - historyUnsubscribe() - }) - - it('handles toggle after history change', () => { - let currentPath - const historyUnsubscribe = history.listen(location => { - currentPath = location.pathname - }) - - // DevTools action #2 - history.push('/foo2') - // DevTools action #3 - history.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 - devToolsStore.dispatch(ActionCreators.toggleAction(3)) - expect(currentPath).toEqual('/foo2') - - historyUnsubscribe() - }) - - it('handles toggle after store change', () => { - let currentPath - const historyUnsubscribe = history.listen(location => { - currentPath = location.pathname - }) - - // DevTools action #2 - store.dispatch(push('/foo2')) - // DevTools action #3 - 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 - devToolsStore.dispatch(ActionCreators.toggleAction(3)) - expect(currentPath).toEqual('/foo2') - - historyUnsubscribe() - }) - }) - - describe('syncReduxAndRouter', () => { - let history, store, unsubscribe - - beforeEach(() => { - let synced = createSyncedHistoryAndStore(createHistory) + let synced = createSyncedHistoryAndStore(testHistory) history = synced.history store = synced.store - unsubscribe = synced.unsubscribe }) afterEach(() => { - unsubscribe() + history.unsubscribe() }) it('syncs router -> redux', () => { @@ -302,7 +82,7 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) expect(store).toContainLocation({ pathname: '/foo', state: { bar: 'baz' }, - action: 'REPLACE' // Converted by history. + action: 'PUSH' }) history.replace('/bar') @@ -320,7 +100,8 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) history.push('/bar?query=1') expect(store).toContainLocation({ pathname: '/bar', - search: '?query=1' + search: '?query=1', + query: { query: '1' } }) history.push('/bar#baz') @@ -337,6 +118,7 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) expect(store).toContainLocation({ pathname: '/bar', search: '?query=1', + query: { query: '1' }, state: { bar: 'baz' }, action: 'REPLACE' }) @@ -350,261 +132,13 @@ module.exports = function createTests(createHistory, name, reset = defaultReset) expect(store).toContainLocation({ pathname: '/bar', search: '?query=1', + query: { query: '1' }, hash: '#hash=2', state: { bar: 'baz' }, action: 'REPLACE' }) }) - - it('syncs redux -> router', () => { - expect(store).toContainLocation({ - pathname: '/', - action: 'POP' - }) - - store.dispatch(push('/foo')) - expect(store).toContainLocation({ - pathname: '/foo' - }) - - store.dispatch(push({ pathname: '/foo', state: { bar: 'baz' } })) - expect(store).toContainLocation({ - pathname: '/foo', - state: { bar: 'baz' }, - action: 'REPLACE' // Converted by history. - }) - - store.dispatch(replace({ pathname: '/bar', state: { bar: 'foo' } })) - expect(store).toContainLocation({ - pathname: '/bar', - state: { bar: 'foo' }, - action: 'REPLACE' - }) - - store.dispatch(push('/bar')) - expect(store).toContainLocation({ - pathname: '/bar', - action: 'REPLACE' // Converted by history. - }) - - store.dispatch(push('/bar?query=1')) - expect(store).toContainLocation({ - pathname: '/bar', - search: '?query=1' - }) - - store.dispatch(push('/bar?query=1#hash=2')) - expect(store).toContainLocation({ - pathname: '/bar', - search: '?query=1', - hash: '#hash=2' - }) - }) - - it('updates the router even if path is the same', () => { - const updates = [] - const historyUnsubscribe = history.listen(location => { - updates.push(location.pathname) - }) - - store.dispatch(push('/foo')) - store.dispatch(push('/foo')) - store.dispatch(replace('/foo')) - - expect(updates).toEqual([ '/', '/foo', '/foo', '/foo' ]) - - historyUnsubscribe() - }) - - it('does not update the router for other state changes', () => { - const state = store.getState() - - store.dispatch({ - type: 'RANDOM_ACTION', - payload: { - payload: { - value: 5 - } - } - }) - - expect(state).toEqual(store.getState()) - }) - - it('only updates the router once when dispatching from `listenBefore`', () => { - history.listenBefore(location => { - expect(location.pathname).toEqual('/foo') - store.dispatch({ - type: 'RANDOM_ACTION', - payload: { - payload: { - value: 5 - } - } - }) - }) - - const updates = [] - history.listen(location => { - updates.push(location.pathname) - }) - - store.dispatch(push('/foo')) - - expect(updates).toEqual([ '/', '/foo' ]) - }) - - it('allows updating the route from within `listenBefore`', () => { - history.listenBefore(location => { - if(location.pathname === '/foo') { - store.dispatch(push('/bar')) - } - else if(location.pathname === '/replace') { - store.dispatch(replace({ pathname: '/baz', state: { foo: 'bar' } })) - } - }) - - const updates = [] - history.listen(location => { - updates.push(location.pathname) - }) - - store.dispatch(push('/foo')) - expect(store).toContainLocation({ - pathname: '/bar' - }) - - store.dispatch(push({ pathname: '/replace', state: { bar: 'baz' } })) - expect(store).toContainLocation({ - pathname: '/baz', - state: { foo: 'bar' }, - action: 'REPLACE' - }) - - expect(updates).toEqual([ '/', '/bar', '/baz' ]) - }) - - it('returns unsubscribe to stop listening to history and store', () => { - history.push('/foo') - expect(store).toContainLocation({ - pathname: '/foo' - }) - - store.dispatch(push('/bar')) - expect(store).toContainLocation({ - pathname: '/bar' - }) - - unsubscribe() - - // Make the teardown a no-op. - unsubscribe = () => {} - - history.push('/foo') - expect(store).toContainLocation({ - pathname: '/bar' - }) - - history.listenBefore(() => { - throw new Error() - }) - expect( - () => store.dispatch(push('/foo')) - ).toNotThrow() - }) - - it('only triggers history once when updating path via store', () => { - const updates = [] - const historyUnsubscribe = history.listen(location => { - updates.push(location.pathname) - }) - - store.dispatch(push('/bar')) - store.dispatch(push('/baz')) - expect(updates).toEqual([ '/', '/bar', '/baz' ]) - - historyUnsubscribe() - }) - - it('only triggers store once when updating path via store', () => { - const updates = [] - const storeUnsubscribe = store.subscribe(() => { - updates.push(store.getState().routing.location.pathname) - }) - - store.dispatch(push('/bar')) - store.dispatch(push('/baz')) - store.dispatch(replace('/foo')) - expect(updates).toEqual([ '/bar', '/baz', '/foo' ]) - - storeUnsubscribe() - }) - }) - - 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 - }) - - afterEach(() => { - unsubscribe() - }) - - it('handles basename history option', () => { - store.dispatch(push('/bar')) - expect(store).toContainLocation({ - pathname: '/bar' - }) - - history.push('/baz') - expect(store).toContainLocation({ - pathname: '/baz' - }) - }) - }) }) } diff --git a/test/node/index.js b/test/node/index.js index fe092ba..f1b4b07 100644 --- a/test/node/index.js +++ b/test/node/index.js @@ -1,4 +1,4 @@ -import { createMemoryHistory } from 'history' +import { createMemoryHistory } from 'react-router' import createTests from '../createTests.js' -createTests(createMemoryHistory, 'Memory History') +createTests(createMemoryHistory(), 'Memory History') From 10e34d2c654062b92a27100ec03e65c2388bab21 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 23:32:47 -0500 Subject: [PATCH 08/28] Update build script for multi-file setup. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 83b9760..b7aecb1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ ], "license": "MIT", "scripts": { - "build": "mkdir -p lib && babel ./src/index.js --out-file ./lib/index.js", + "build": "mkdir -p lib && babel ./src -d lib", "lint": "eslint examples src test", "test": "npm run lint && npm run test:node && npm run test:browser", "test:node": "mocha --compilers js:babel-core/register --recursive ./test/node", From 4c17b0e588386b5f1c16aaf28ef88dd292c5e7cc Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Thu, 4 Feb 2016 23:38:47 -0500 Subject: [PATCH 09/28] Clean up some testing framework stuff. Fix Firefox with babel-polyfill. --- karma.conf.js | 29 ++++++++++++++++------------- package.json | 4 +++- test/browser/index.js | 2 ++ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/karma.conf.js b/karma.conf.js index 027abcb..20567c2 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,12 +1,13 @@ -var path = require('path'); -var webpack = require('webpack'); +'use strict' + +const path = require('path') module.exports = function (config) { - var runCoverage = process.env.COVERAGE === 'true'; + let runCoverage = process.env.COVERAGE === 'true' - var coverageLoaders = []; - var coverageReporters = []; + let coverageLoaders = [] + let coverageReporters = [] if (runCoverage) { coverageLoaders.push({ @@ -15,7 +16,7 @@ module.exports = function (config) { loader: 'isparta' }), - coverageReporters.push('coverage'); + coverageReporters.push('coverage') } config.set({ @@ -39,12 +40,14 @@ module.exports = function (config) { module: { preLoaders: [ { + loader: 'babel', test: /\.js$/, - exclude: [ - path.resolve('node_modules/') - ], - loader: 'babel' - }, + include: [ + path.resolve('src/'), + path.resolve('test/') + ] + + } ].concat(coverageLoaders) } }, @@ -59,5 +62,5 @@ module.exports = function (config) { { type: 'json', subdir: 'browser-coverage', file: 'coverage.json' } ] } - }); -}; + }) +} diff --git a/package.json b/package.json index b7aecb1..a316cff 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "build": "mkdir -p lib && babel ./src -d lib", "lint": "eslint examples src test", "test": "npm run lint && npm run test:node && npm run test:browser", - "test:node": "mocha --compilers js:babel-core/register --recursive ./test/node", + "test:node": "mocha --compilers js:babel-register --recursive ./test/node", "test:browser": "karma start", "test:cov": "npm run test:cov:browser && npm run test:cov:node && npm run test:cov:report", "test:cov:node": "babel-node $(npm bin)/isparta cover $(npm bin)/_mocha report --dir ./coverage/node-coverage -- --recursive ./test/node", @@ -41,8 +41,10 @@ "babel-core": "^6.2.1", "babel-eslint": "^4.1.6", "babel-loader": "^6.2.0", + "babel-polyfill": "^6.3.14", "babel-preset-es2015": "^6.3.13", "babel-preset-stage-1": "^6.3.13", + "babel-register": "^6.4.3", "eslint": "^1.10.3", "eslint-config-rackt": "^1.1.1", "eslint-plugin-react": "^3.15.0", diff --git a/test/browser/index.js b/test/browser/index.js index 93e51c7..e246613 100644 --- a/test/browser/index.js +++ b/test/browser/index.js @@ -1,3 +1,5 @@ +import 'babel-polyfill' + import { hashHistory, browserHistory } from 'react-router' import createTests from '../createTests.js' From c61c92c88cf4c6fcbc8bba6ad610c848bab14d0a Mon Sep 17 00:00:00 2001 From: Kai Curry Date: Fri, 5 Feb 2016 19:02:41 -0600 Subject: [PATCH 10/28] Use spread instead Object.assign. Following #259 --- src/sync.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/sync.js b/src/sync.js index c422840..6724f5c 100644 --- a/src/sync.js +++ b/src/sync.js @@ -54,10 +54,10 @@ export default function syncHistoryWithStore(history, store, { // Update address bar to reflect store state isTimeTraveling = true currentLocation = locationInStore - history.transitionTo(Object.assign({}, - locationInStore, - { action: 'PUSH' } - )) + history.transitionTo({ + ...locationInStore, + action: 'PUSH' + }) isTimeTraveling = false } @@ -95,7 +95,8 @@ export default function syncHistoryWithStore(history, store, { unsubscribeFromHistory = history.listen(handleLocationChange) // The enhanced history uses store as source of truth - return Object.assign({}, history, { + return { + ...history, // The listeners are subscribed to the store instead of history listen(listener) { // History listeners expect a synchronous call @@ -124,5 +125,5 @@ export default function syncHistoryWithStore(history, store, { } unsubscribeFromHistory() } - }) + } } From 4afd74fb79086049182d4d72e92ebe5c57ac6cd5 Mon Sep 17 00:00:00 2001 From: Kai Curry Date: Fri, 5 Feb 2016 20:10:22 -0600 Subject: [PATCH 11/28] Confirm location change before listener invokation --- src/sync.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/sync.js b/src/sync.js index 6724f5c..143be33 100644 --- a/src/sync.js +++ b/src/sync.js @@ -99,15 +99,22 @@ export default function syncHistoryWithStore(history, store, { ...history, // The listeners are subscribed to the store instead of history listen(listener) { + // Copy of last location. + let lastPublishedLocation = getLocationInStore(true) // History listeners expect a synchronous call - listener(getLocationInStore(true)) + listener(lastPublishedLocation) // Keep track of whether we unsubscribed, as Redux store // only applies changes in subscriptions on next dispatch let unsubscribed = false const unsubscribeFromStore = store.subscribe(() => { + const currentLocation = getLocationInStore(true) + if (currentLocation === lastPublishedLocation) { + return + } + lastPublishedLocation = currentLocation if (!unsubscribed) { - listener(getLocationInStore(true)) + listener(lastPublishedLocation) } }) From fc12f7009a5d9674f9b028f32c3a1c4a02a891f0 Mon Sep 17 00:00:00 2001 From: Kai Curry Date: Fri, 5 Feb 2016 22:08:25 -0600 Subject: [PATCH 12/28] Begin updating example in README. Re #259 --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5f5484a..28a2540 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ View the [CHANGELOG](https://github.com/rackt/react-router-redux/blob/master/CHA Read the [API docs](#api) farther down this page. -**Note:** We are [currently discussing some major changes](https://github.com/rackt/react-router-redux/issues/257) to the library. [React Router's API in 2.0](https://github.com/rackt/react-router/blob/master/upgrade-guides/v2.0.0.md) is significantly improved and obseletes the need for things like action creators and reading location state from the Redux. This library is still critical to enable things like time traveling and persisting state, so we're not going anywhere. But in many cases, you may not need this library and can simply use the provided React Router APIs. Go check them out and drop some technical debt. :smile: +**Note:** We are [currently discussing some major changes](https://github.com/rackt/react-router-redux/issues/257) to the library. [React Router's API in 2.0](https://github.com/rackt/react-router/blob/master/upgrade-guides/v2.0.0.md) is significantly improved and makes things like action creators and reading location state from Redux obsolete. This library is still critical to enable things like time traveling and persisting state, so we're not going anywhere. But in many cases, you may not need this library and can simply use the provided React Router APIs. Go check them out and drop some technical debt. :smile: ### Usage @@ -44,25 +44,25 @@ import ReactDOM from 'react-dom' import { createStore, combineReducers, applyMiddleware } from 'redux' import { Provider } from 'react-redux' import { Router, Route, browserHistory } from 'react-router' -import { syncHistory, routeReducer } from 'react-router-redux' +import { syncHistoryWithStore, routerReducer } from 'react-router-redux' + import reducers from '/reducers' const reducer = combineReducers(Object.assign({}, reducers, { - routing: routeReducer + routing: routerReducer })) -// Sync dispatched route actions to the history -const reduxRouterMiddleware = syncHistory(browserHistory) -const createStoreWithMiddleware = applyMiddleware(reduxRouterMiddleware)(createStore) +const store = createStore(reducer) -const store = createStoreWithMiddleware(reducer) +// Sync dispatched route actions to the history +const history = syncHistoryWithStore(browserHistory, store) // Required for replaying actions from devtools to work reduxRouterMiddleware.listenForReplays(store) ReactDOM.render( - + @@ -73,7 +73,7 @@ ReactDOM.render( ) ``` -Now you can read from `state.routing.location.pathname` to get the URL. It's far more likely that you want to change the URL more often, however. You can use the `push` action creator that we provide: +Now you can read from `state.routing.locationBeforeTransitions.pathname` to get the URL. It's far more likely that you want to change the URL more often, however. You can use the `push` action creator that we provide: ```js import { routeActions } from 'react-router-redux' From 1a8c80012234868541d32a38dc10b1425587393b Mon Sep 17 00:00:00 2001 From: Kai Curry Date: Fri, 5 Feb 2016 22:24:43 -0600 Subject: [PATCH 13/28] README syncHistory becomes syncHistoryWithStore --- README.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 28a2540..ed5492d 100644 --- a/README.md +++ b/README.md @@ -132,21 +132,11 @@ _Have an example to add? Send us a PR!_ ### API -#### `syncHistory(history: History) => ReduxMiddleware` +#### `history = syncHistoryWithStore(history: History, store)` -Call this to create a middleware that can be applied with Redux's `applyMiddleware` to allow actions to call history methods. The middleware will look for route actions created by `push`, `replace`, etc. and applies them to the history. +We now sync by enhancing the history instance to listen for navigation events and dispatch those into the store. The enhanced history has its listen method overridden to respond to store changes, rather than directly to navigation events. When this history is provided to , the router will listen to it and receive these store changes. This means if we time travel with the store, the router will receive those store changes and update based on the location in the store, instead of what the browser says. Normal navigation events (hitting your browser back/forward buttons, telling a history singleton to push a location) flow through the history's listener like normal, so all the usual stuff works A-OK. -#### `ReduxMiddleware.listenForReplays(store: ReduxStore, selectLocationState?: function)` - -By default, the syncing logic will not respond to replaying of actions, which means it won't work with projects like redux-devtools. Call this function on the middleware object returned from `syncHistory` and give it the store to listen to, and it will properly work with action replays. Obviously, you would do that after you have created the store and everything else has been set up. - -Supply an optional function `selectLocationState` to customize where to find the location state on your app state. It defaults to `state => state.routing.location`, so you would install the reducer under the name "routing". Feel free to change this to whatever you like. - -#### `ReduxMiddleware.unsubscribe()` - -Call this on the middleware returned from `syncHistory` to stop the syncing process set up by `listenForReplays`. - -#### `routeReducer` +#### `routerReducer` A reducer function that keeps track of the router state. You must add this reducer to your app reducers when creating the store. It will return a `location` property in state. If you use `combineReducers`, it will be nested under wherever property you add it to (`state.routing` in the example above). From 379dc9c511b2f04bbf25285e0deda31c5e65f2a9 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Sat, 6 Feb 2016 10:34:20 -0500 Subject: [PATCH 14/28] Add back route action tests. --- test/createTests.js | 5 -- test/node/actions.js | 102 +++++++++++++++++++++++++++++ test/node/{index.js => history.js} | 0 3 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 test/node/actions.js rename test/node/{index.js => history.js} (100%) diff --git a/test/createTests.js b/test/createTests.js index 18dab62..65f654b 100644 --- a/test/createTests.js +++ b/test/createTests.js @@ -8,11 +8,6 @@ import { createStore, combineReducers } from 'redux' import syncHistoryWithStore from '../src/sync' import { routerReducer } from '../src/reducer' -// import { -// UPDATE_LOCATION, -// push, replace, go, goBack, goForward, -// routeActions -// } from '../src/actions' // import routerMiddleware from '../src/middleware' expect.extend({ diff --git a/test/node/actions.js b/test/node/actions.js new file mode 100644 index 0000000..14f151e --- /dev/null +++ b/test/node/actions.js @@ -0,0 +1,102 @@ +/* eslint-env mocha */ + +import expect from 'expect' + +import { + UPDATE_LOCATION, + push, replace, go, goBack, goForward +} from '../../src/actions' + +describe('routeActions', () => { + + describe('push', () => { + it('creates actions', () => { + expect(push('/foo')).toEqual({ + type: UPDATE_LOCATION, + payload: { + method: 'push', + args: [ '/foo' ] + } + }) + + expect(push({ pathname: '/foo', state: { the: 'state' } })).toEqual({ + type: UPDATE_LOCATION, + payload: { + method: 'push', + args: [ { + pathname: '/foo', + state: { the: 'state' } + } ] + } + }) + + expect(push('/foo', 'baz', 123)).toEqual({ + type: UPDATE_LOCATION, + payload: { + method: 'push', + args: [ '/foo' , 'baz', 123 ] + } + }) + }) + }) + + describe('replace', () => { + it('creates actions', () => { + expect(replace('/foo')).toEqual({ + type: UPDATE_LOCATION, + payload: { + method: 'replace', + args: [ '/foo' ] + } + }) + + expect(replace({ pathname: '/foo', state: { the: 'state' } })).toEqual({ + type: UPDATE_LOCATION, + payload: { + method: 'replace', + args: [ { + pathname: '/foo', + state: { the: 'state' } + } ] + } + }) + }) + }) + + describe('go', () => { + it('creates actions', () => { + expect(go(1)).toEqual({ + type: UPDATE_LOCATION, + payload: { + method: 'go', + args: [ 1 ] + } + }) + }) + }) + + describe('goBack', () => { + it('creates actions', () => { + expect(goBack()).toEqual({ + type: UPDATE_LOCATION, + payload: { + method: 'goBack', + args: [] + } + }) + }) + }) + + describe('goForward', () => { + it('creates actions', () => { + expect(goForward()).toEqual({ + type: UPDATE_LOCATION, + payload: { + method: 'goForward', + args: [] + } + }) + }) + }) + +}) diff --git a/test/node/index.js b/test/node/history.js similarity index 100% rename from test/node/index.js rename to test/node/history.js From def06ff72d8399f6ae1854e4b1ccdb63ef6cbfb9 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Sat, 6 Feb 2016 10:43:36 -0500 Subject: [PATCH 15/28] Add reducer tests. Ensure actions are FSA. --- src/reducer.js | 4 +-- src/sync.js | 2 +- test/node/reducer.js | 60 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 test/node/reducer.js diff --git a/src/reducer.js b/src/reducer.js index 7ad688d..1d9a6c0 100644 --- a/src/reducer.js +++ b/src/reducer.js @@ -14,9 +14,9 @@ const initialState = { * if you have asynchronously-loaded routes, so reading from and relying on * this state it is discouraged. */ -export function routerReducer(state = initialState, { type, locationBeforeTransitions }) { +export function routerReducer(state = initialState, { type, payload }) { if (type === LOCATION_CHANGE) { - return { ...state, locationBeforeTransitions } + return { ...state, locationBeforeTransitions: payload } } return state diff --git a/src/sync.js b/src/sync.js index 143be33..935eb32 100644 --- a/src/sync.js +++ b/src/sync.js @@ -89,7 +89,7 @@ export default function syncHistoryWithStore(history, store, { // Tell the store to update by dispatching an action store.dispatch({ type: LOCATION_CHANGE, - locationBeforeTransitions: location + payload: location }) } unsubscribeFromHistory = history.listen(handleLocationChange) diff --git a/test/node/reducer.js b/test/node/reducer.js new file mode 100644 index 0000000..afa4e64 --- /dev/null +++ b/test/node/reducer.js @@ -0,0 +1,60 @@ +/* eslint-env mocha */ + +import expect from 'expect' + +import { LOCATION_CHANGE, routerReducer } from '../../src/reducer' + +describe('routerReducer', () => { + const state = { + locationBeforeTransitions: { + pathname: '/foo', + action: 'POP' + } + } + + it('updates the path', () => { + expect(routerReducer(state, { + type: LOCATION_CHANGE, + payload: { + path: '/bar', + action: 'PUSH' + } + })).toEqual({ + locationBeforeTransitions: { + path: '/bar', + action: 'PUSH' + } + }) + }) + + it('works with initialState', () => { + expect(routerReducer(undefined, { + type: LOCATION_CHANGE, + payload: { + path: '/bar', + action: 'PUSH' + } + })).toEqual({ + locationBeforeTransitions: { + path: '/bar', + action: 'PUSH' + } + }) + }) + + + it('respects replace', () => { + expect(routerReducer(state, { + type: LOCATION_CHANGE, + payload: { + path: '/bar', + action: 'REPLACE' + } + })).toEqual({ + locationBeforeTransitions: { + path: '/bar', + action: 'REPLACE' + } + }) + }) +}) From 2b177f5bcbb91d56075ff255dbd4056ca4db7fd6 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Sat, 6 Feb 2016 11:12:50 -0500 Subject: [PATCH 16/28] Add middleware test. Reorg test suite. --- package.json | 2 +- src/middleware.js | 2 +- test/.eslintrc | 5 +++ test/{createTests.js => _createSyncTest.js} | 2 -- test/{node/actions.js => actions.spec.js} | 4 +-- test/browser/index.js | 2 +- test/middleware.spec.js | 34 +++++++++++++++++++++ test/{node/reducer.js => reducer.spec.js} | 4 +-- test/{node/history.js => sync.spec.js} | 2 +- tests.webpack.js | 4 +-- 10 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 test/.eslintrc rename test/{createTests.js => _createSyncTest.js} (99%) rename test/{node/actions.js => actions.spec.js} (97%) create mode 100644 test/middleware.spec.js rename test/{node/reducer.js => reducer.spec.js} (91%) rename test/{node/history.js => sync.spec.js} (70%) diff --git a/package.json b/package.json index a316cff..558a028 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "build": "mkdir -p lib && babel ./src -d lib", "lint": "eslint examples src test", "test": "npm run lint && npm run test:node && npm run test:browser", - "test:node": "mocha --compilers js:babel-register --recursive ./test/node", + "test:node": "mocha --compilers js:babel-register --recursive ./test/*.spec.js", "test:browser": "karma start", "test:cov": "npm run test:cov:browser && npm run test:cov:node && npm run test:cov:report", "test:cov:node": "babel-node $(npm bin)/isparta cover $(npm bin)/_mocha report --dir ./coverage/node-coverage -- --recursive ./test/node", diff --git a/src/middleware.js b/src/middleware.js index 0e83bab..cacb5b5 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -1,4 +1,4 @@ -import UPDATE_LOCATION from './actions' +import { UPDATE_LOCATION } from './actions' /** * This middleware captures UPDATE_LOCATION actions to redirect to the diff --git a/test/.eslintrc b/test/.eslintrc new file mode 100644 index 0000000..7eeefc3 --- /dev/null +++ b/test/.eslintrc @@ -0,0 +1,5 @@ +{ + "env": { + "mocha": true + } +} diff --git a/test/createTests.js b/test/_createSyncTest.js similarity index 99% rename from test/createTests.js rename to test/_createSyncTest.js index 65f654b..8af7282 100644 --- a/test/createTests.js +++ b/test/_createSyncTest.js @@ -1,5 +1,3 @@ -/*eslint-env mocha */ - import expect from 'expect' import { createStore, combineReducers } from 'redux' diff --git a/test/node/actions.js b/test/actions.spec.js similarity index 97% rename from test/node/actions.js rename to test/actions.spec.js index 14f151e..14b551f 100644 --- a/test/node/actions.js +++ b/test/actions.spec.js @@ -1,11 +1,9 @@ -/* eslint-env mocha */ - import expect from 'expect' import { UPDATE_LOCATION, push, replace, go, goBack, goForward -} from '../../src/actions' +} from '../src/actions' describe('routeActions', () => { diff --git a/test/browser/index.js b/test/browser/index.js index e246613..e08583c 100644 --- a/test/browser/index.js +++ b/test/browser/index.js @@ -1,7 +1,7 @@ import 'babel-polyfill' import { hashHistory, browserHistory } from 'react-router' -import createTests from '../createTests.js' +import createTests from '../_createSyncTest' createTests(hashHistory, 'Hash History', () => window.location = '#/') createTests(browserHistory, 'Browser History', () => window.history.replaceState(null, null, '/')) diff --git a/test/middleware.spec.js b/test/middleware.spec.js new file mode 100644 index 0000000..ec9569c --- /dev/null +++ b/test/middleware.spec.js @@ -0,0 +1,34 @@ +import expect, { createSpy } from 'expect' + +import { push, replace } from '../src/actions' +import routerMiddleware from '../src/middleware' + +describe('routerMiddleware', () => { + let history, next, dispatch + + beforeEach(() => { + history = { + push: createSpy(), + replace: createSpy() + } + next = createSpy() + + dispatch = routerMiddleware(history)()(next) + }) + + + it('calls the appropriate history method', () => { + dispatch(push('/foo')) + expect(history.push).toHaveBeenCalled() + + dispatch(replace('/foo')) + expect(history.replace).toHaveBeenCalled() + + expect(next).toNotHaveBeenCalled() + }) + + it('ignores other actions', () => { + dispatch({ type: 'FOO' }) + expect(next).toHaveBeenCalled() + }) +}) diff --git a/test/node/reducer.js b/test/reducer.spec.js similarity index 91% rename from test/node/reducer.js rename to test/reducer.spec.js index afa4e64..e764005 100644 --- a/test/node/reducer.js +++ b/test/reducer.spec.js @@ -1,8 +1,6 @@ -/* eslint-env mocha */ - import expect from 'expect' -import { LOCATION_CHANGE, routerReducer } from '../../src/reducer' +import { LOCATION_CHANGE, routerReducer } from '../src/reducer' describe('routerReducer', () => { const state = { diff --git a/test/node/history.js b/test/sync.spec.js similarity index 70% rename from test/node/history.js rename to test/sync.spec.js index f1b4b07..6902eeb 100644 --- a/test/node/history.js +++ b/test/sync.spec.js @@ -1,4 +1,4 @@ import { createMemoryHistory } from 'react-router' -import createTests from '../createTests.js' +import createTests from './_createSyncTest' createTests(createMemoryHistory(), 'Memory History') diff --git a/tests.webpack.js b/tests.webpack.js index 15df5a3..1ef2212 100644 --- a/tests.webpack.js +++ b/tests.webpack.js @@ -1,2 +1,2 @@ -const browserContext = require.context('./test/browser', true, /\.js$/); -browserContext.keys().forEach(browserContext); +const browserContext = require.context('./test/browser') +browserContext.keys().forEach(browserContext) From 2c88667b1716c7bd39060303e3f376b6fa339f17 Mon Sep 17 00:00:00 2001 From: Kai Curry Date: Sat, 6 Feb 2016 11:48:47 -0600 Subject: [PATCH 17/28] rename UPDATE_LOCATION to CALL_HISTORY_METHOD --- README.md | 8 ++++---- src/actions.js | 4 ++-- src/index.js | 2 +- src/middleware.js | 6 +++--- test/actions.spec.js | 18 +++++++++--------- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ed5492d..ef01061 100644 --- a/README.md +++ b/README.md @@ -83,14 +83,14 @@ function MyComponent({ dispatch }) { } ``` -This will change the state, which will trigger a change in react-router. Additionally, if you want to respond to the path update action, just handle the `UPDATE_LOCATION` constant that we provide: +This will change the state, which will trigger a change in react-router. Additionally, if you want to respond to the path update action, just handle the `CALL_HISTORY_METHOD` constant that we provide: ```js -import { UPDATE_LOCATION } from 'react-router-redux' +import { CALL_HISTORY_METHOD } from 'react-router-redux' function update(state, action) { switch(action.type) { - case UPDATE_LOCATION: + case CALL_HISTORY_METHOD: // do something here } } @@ -142,7 +142,7 @@ A reducer function that keeps track of the router state. You must add this reduc **Warning:** It is a bad pattern to use `react-redux`'s `connect` decorator to map the state from this reducer to props on your `Route` components. This can lead to infinite loops and performance problems. `react-router` already provides this for you via `this.props.location`. -#### `UPDATE_LOCATION` +#### `CALL_HISTORY_METHOD` An action type that you can listen for in your reducers to be notified of route updates. diff --git a/src/actions.js b/src/actions.js index 3406b48..d72fcc1 100644 --- a/src/actions.js +++ b/src/actions.js @@ -3,11 +3,11 @@ * If you're writing a middleware to watch for navigation events, be sure to * look for actions of this type. */ -export const UPDATE_LOCATION = '@@router/UPDATE_LOCATION' +export const CALL_HISTORY_METHOD = '@@router/CALL_HISTORY_METHOD' function updateLocation(method) { return (...args) => ({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method, args } }) } diff --git a/src/index.js b/src/index.js index 787d912..cce64cd 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,7 @@ export syncHistoryWithStore from './sync' export { LOCATION_CHANGE, routerReducer } from './reducer' export { - UPDATE_LOCATION, + CALL_HISTORY_METHOD, push, replace, go, goBack, goForward, routeActions } from './actions' diff --git a/src/middleware.js b/src/middleware.js index cacb5b5..ad5d5ab 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -1,13 +1,13 @@ -import { UPDATE_LOCATION } from './actions' +import { CALL_HISTORY_METHOD } from './actions' /** - * This middleware captures UPDATE_LOCATION actions to redirect to the + * This middleware captures CALL_HISTORY_METHOD actions to redirect to the * provided history object. This will prevent these actions from reaching your * reducer or any middleware that comes after this one. */ export default function routerMiddleware(history) { return () => next => action => { - if (action.type !== UPDATE_LOCATION) { + if (action.type !== CALL_HISTORY_METHOD) { return next(action) } diff --git a/test/actions.spec.js b/test/actions.spec.js index 14b551f..0452ef8 100644 --- a/test/actions.spec.js +++ b/test/actions.spec.js @@ -1,7 +1,7 @@ import expect from 'expect' import { - UPDATE_LOCATION, + CALL_HISTORY_METHOD, push, replace, go, goBack, goForward } from '../src/actions' @@ -10,7 +10,7 @@ describe('routeActions', () => { describe('push', () => { it('creates actions', () => { expect(push('/foo')).toEqual({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method: 'push', args: [ '/foo' ] @@ -18,7 +18,7 @@ describe('routeActions', () => { }) expect(push({ pathname: '/foo', state: { the: 'state' } })).toEqual({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method: 'push', args: [ { @@ -29,7 +29,7 @@ describe('routeActions', () => { }) expect(push('/foo', 'baz', 123)).toEqual({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method: 'push', args: [ '/foo' , 'baz', 123 ] @@ -41,7 +41,7 @@ describe('routeActions', () => { describe('replace', () => { it('creates actions', () => { expect(replace('/foo')).toEqual({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method: 'replace', args: [ '/foo' ] @@ -49,7 +49,7 @@ describe('routeActions', () => { }) expect(replace({ pathname: '/foo', state: { the: 'state' } })).toEqual({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method: 'replace', args: [ { @@ -64,7 +64,7 @@ describe('routeActions', () => { describe('go', () => { it('creates actions', () => { expect(go(1)).toEqual({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method: 'go', args: [ 1 ] @@ -76,7 +76,7 @@ describe('routeActions', () => { describe('goBack', () => { it('creates actions', () => { expect(goBack()).toEqual({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method: 'goBack', args: [] @@ -88,7 +88,7 @@ describe('routeActions', () => { describe('goForward', () => { it('creates actions', () => { expect(goForward()).toEqual({ - type: UPDATE_LOCATION, + type: CALL_HISTORY_METHOD, payload: { method: 'goForward', args: [] From cb9d6b6e2fb1c0bdadcbd1d2dc716816dc365069 Mon Sep 17 00:00:00 2001 From: Tim Dorr Date: Sun, 7 Feb 2016 01:52:37 -0500 Subject: [PATCH 18/28] Redone docs. --- README.md | 150 +++++++++++++++++++++---------------------- src/actions.js | 2 +- src/index.js | 2 +- test/actions.spec.js | 2 +- 4 files changed, 76 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index ef01061..e2b4558 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,34 @@ [![npm version](https://img.shields.io/npm/v/react-router-redux.svg?style=flat-square)](https://www.npmjs.com/package/react-router-redux) [![npm downloads](https://img.shields.io/npm/dm/react-router-redux.svg?style=flat-square)](https://www.npmjs.com/package/react-router-redux) [![build status](https://img.shields.io/travis/rackt/react-router-redux/master.svg?style=flat-square)](https://travis-ci.org/rackt/react-router-redux) -**Let react-router do all the work** :sparkles: +> **Keep your router in sync with application state** :sparkles: _Formerly known as redux-simple-router_ -[Redux](https://github.com/rackt/redux) is awesome. [React Router](https://github.com/rackt/react-router) is cool. The problem is that react-router manages an important piece of your application state: the URL. If you are using redux, you want your app state to fully represent your UI; if you snapshotted the app state, you should be able to load it up later and see the same thing. +You're a smart person. You use [Redux](https://github.com/rackt/redux) to manage your application state. You use [React Router](https://github.com/rackt/react-router) to do routing. All is good. -react-router does a great job of mapping the current URL to a component tree, and continually does so with any URL changes. This is very useful, but we really want to store this state in redux as well. +But the two libraries don't coordinate. You want to do time travel with your application state, but React Router doesn't navigate between pages when you replay actions. It controls an important part of application state: the URL. -The entire state that we are interested in boils down to one thing: the URL. This is an extremely simple library that just puts the URL in redux state and keeps it in sync with any react-router changes. Additionally, you can change the URL via redux and react-router will change accordingly. +This library helps you keep that bit of state in sync with your Redux store. We keep a copy of the current location hidden in state. When you rewind your application state with a tool like [Redux DevTools](https://github.com/gaearon/redux-devtools), that state change is propagated to React Router so it can adjust the component tree accordingly. You can jump around in state, rewinding, replaying, and resetting as much as you'd like, and this library will ensure the two stay in sync at all times. + +## Installation ``` -npm install react-router-redux +npm install --save react-router-redux ``` If you want to install the next major version, use `react-router-redux@next`. Run `npm dist-tag ls react-router-redux` to see what `next` is aliased to. -View the [CHANGELOG](https://github.com/rackt/react-router-redux/blob/master/CHANGELOG.md) for recent changes. - -Read the [API docs](#api) farther down this page. - -**Note:** We are [currently discussing some major changes](https://github.com/rackt/react-router-redux/issues/257) to the library. [React Router's API in 2.0](https://github.com/rackt/react-router/blob/master/upgrade-guides/v2.0.0.md) is significantly improved and makes things like action creators and reading location state from Redux obsolete. This library is still critical to enable things like time traveling and persisting state, so we're not going anywhere. But in many cases, you may not need this library and can simply use the provided React Router APIs. Go check them out and drop some technical debt. :smile: - -### Usage +## How It Works -The idea of this library is to use react-router's functionality exactly like its documentation tells you to. You can access all of its APIs in routing components. Additionally, you can use redux like you normally would, with a single app state. +This library allows you to use React Router's APIs as they are documented. And, you can use redux like you normally would, with a single app state. The library simply enhances a history instance to allow it to synchronize any changes it receives into application state. -[redux](https://github.com/rackt/redux) (`store.routing`)  ↔  [**react-router-redux**](https://github.com/rackt/react-router-redux)  ↔  [history](https://github.com/rackt/history) (`history.location`)  ↔  [react-router](https://github.com/rackt/react-router) +[history](https://github.com/rackt/history) + `store` ([redux](https://github.com/rackt/redux)) → [**react-router-redux**](https://github.com/rackt/react-router-redux) → enhanced [history](https://github.com/rackt/history) → [react-router](https://github.com/rackt/react-router) -We only store current URL and state, whereas redux-router stores the entire location object from react-router. You can read it, and also change it with an action. - -### Tutorial +## Tutorial Let's take a look at a simple example. -**Note:** This example uses `react-router`'s 2.0 API, which is currently released under version 2.0.0-rc5. - ```js import React from 'react' import ReactDOM from 'react-dom' @@ -48,20 +40,20 @@ import { syncHistoryWithStore, routerReducer } from 'react-router-redux' import reducers from '/reducers' -const reducer = combineReducers(Object.assign({}, reducers, { - routing: routerReducer -})) - -const store = createStore(reducer) +// Add the reducer to your store on the `routing` key +const store = createStore( + combineReducers({ + ...reducers, + routing: routerReducer + }) +) -// Sync dispatched route actions to the history +// Create an enhanced history that syncs navigation events with the store const history = syncHistoryWithStore(browserHistory, store) -// Required for replaying actions from devtools to work -reduxRouterMiddleware.listenForReplays(store) - ReactDOM.render( + /* Tell the Router to use our enhanced history */ @@ -73,31 +65,23 @@ ReactDOM.render( ) ``` -Now you can read from `state.routing.locationBeforeTransitions.pathname` to get the URL. It's far more likely that you want to change the URL more often, however. You can use the `push` action creator that we provide: +Now any time you navigate, which can come from pressing browser buttons or navigating in your application code, the enhanced history will first pass the new location through the Redux store and then on to React Router to update the component tree. If you time travel, it will also pass the new state to React Router to update the component tree again. -```js -import { routeActions } from 'react-router-redux' +#### How do I watch for navigation events, such as for analytics? -function MyComponent({ dispatch }) { - return