From 4bb250d9e94b18bc0bf0b5deb0cce5f41c556e92 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 4 Feb 2016 02:06:24 +0000 Subject: [PATCH 1/4] RFC: syncHistoryWithStore --- examples/real-world/containers/App.js | 12 +- examples/real-world/containers/RepoPage.js | 4 +- examples/real-world/containers/Root.dev.js | 6 +- examples/real-world/containers/Root.prod.js | 6 +- examples/real-world/containers/UserPage.js | 4 +- examples/real-world/index.js | 7 +- examples/real-world/package.json | 1 - examples/real-world/reducers/index.js | 5 +- .../real-world/store/configureStore.dev.js | 13 +-- .../real-world/store/configureStore.prod.js | 4 +- examples/real-world/syncHistoryWithStore.js | 103 ++++++++++++++++++ 11 files changed, 130 insertions(+), 35 deletions(-) create mode 100644 examples/real-world/syncHistoryWithStore.js diff --git a/examples/real-world/containers/App.js b/examples/real-world/containers/App.js index 5c98257f7f..f7db3cd2b8 100644 --- a/examples/real-world/containers/App.js +++ b/examples/real-world/containers/App.js @@ -1,6 +1,6 @@ import React, { Component, PropTypes } from 'react' import { connect } from 'react-redux' -import { push } from 'react-router-redux' +import { browserHistory } from 'react-router' import Explore from '../components/Explore' import { resetErrorMessage } from '../actions' @@ -17,7 +17,7 @@ class App extends Component { } handleChange(nextValue) { - this.props.push(`/${nextValue}`) + browserHistory.push(`/${nextValue}`) } renderErrorMessage() { @@ -56,20 +56,18 @@ App.propTypes = { // Injected by React Redux errorMessage: PropTypes.string, resetErrorMessage: PropTypes.func.isRequired, - push: PropTypes.func.isRequired, inputValue: PropTypes.string.isRequired, // Injected by React Router children: PropTypes.node } -function mapStateToProps(state) { +function mapStateToProps(state, ownProps) { return { errorMessage: state.errorMessage, - inputValue: state.routing.location.pathname.substring(1) + inputValue: ownProps.location.pathname.substring(1) } } export default connect(mapStateToProps, { - resetErrorMessage, - push + resetErrorMessage })(App) diff --git a/examples/real-world/containers/RepoPage.js b/examples/real-world/containers/RepoPage.js index c38d9cd4f2..e5e62c7c30 100644 --- a/examples/real-world/containers/RepoPage.js +++ b/examples/real-world/containers/RepoPage.js @@ -72,8 +72,8 @@ RepoPage.propTypes = { loadStargazers: PropTypes.func.isRequired } -function mapStateToProps(state, props) { - const { login, name } = props.params +function mapStateToProps(state, ownProps) { + const { login, name } = ownProps.params const { pagination: { stargazersByRepo }, entities: { users, repos } diff --git a/examples/real-world/containers/Root.dev.js b/examples/real-world/containers/Root.dev.js index e8726bfb0a..b0d828a01a 100644 --- a/examples/real-world/containers/Root.dev.js +++ b/examples/real-world/containers/Root.dev.js @@ -2,15 +2,15 @@ import React, { Component, PropTypes } from 'react' import { Provider } from 'react-redux' import routes from '../routes' import DevTools from './DevTools' -import { Router, browserHistory } from 'react-router' +import { Router } from 'react-router' export default class Root extends Component { render() { - const { store } = this.props + const { store, history } = this.props return (
- +
diff --git a/examples/real-world/containers/Root.prod.js b/examples/real-world/containers/Root.prod.js index adc514de9e..b8a363b7f1 100644 --- a/examples/real-world/containers/Root.prod.js +++ b/examples/real-world/containers/Root.prod.js @@ -1,14 +1,14 @@ import React, { Component, PropTypes } from 'react' import { Provider } from 'react-redux' import routes from '../routes' -import { Router, browserHistory } from 'react-router' +import { Router } from 'react-router' export default class Root extends Component { render() { - const { store } = this.props + const { store, history } = this.props return ( - + ) } diff --git a/examples/real-world/containers/UserPage.js b/examples/real-world/containers/UserPage.js index d5c7f8877f..29a25f0220 100644 --- a/examples/real-world/containers/UserPage.js +++ b/examples/real-world/containers/UserPage.js @@ -72,8 +72,8 @@ UserPage.propTypes = { loadStarred: PropTypes.func.isRequired } -function mapStateToProps(state, props) { - const { login } = props.params +function mapStateToProps(state, ownProps) { + const { login } = ownProps.params const { pagination: { starredByUser }, entities: { users, repos } diff --git a/examples/real-world/index.js b/examples/real-world/index.js index 3a8de9d2ef..62c8db48f7 100644 --- a/examples/real-world/index.js +++ b/examples/real-world/index.js @@ -1,12 +1,17 @@ import 'babel-polyfill' import React from 'react' import { render } from 'react-dom' +import { browserHistory } from 'react-router' import Root from './containers/Root' import configureStore from './store/configureStore' +import { syncHistoryWithStore } from './syncHistoryWithStore' const store = configureStore() +const history = syncHistoryWithStore(browserHistory, store, { + adjustUrlOnReplay: true +}) render( - , + , document.getElementById('root') ) diff --git a/examples/real-world/package.json b/examples/real-world/package.json index c08901d7f5..166df80c4e 100644 --- a/examples/real-world/package.json +++ b/examples/real-world/package.json @@ -24,7 +24,6 @@ "react-dom": "^0.14.7", "react-redux": "^4.2.1", "react-router": "2.0.0-rc5", - "react-router-redux": "^2.1.0", "redux": "^3.2.1", "redux-logger": "^2.4.0", "redux-thunk": "^1.0.3" diff --git a/examples/real-world/reducers/index.js b/examples/real-world/reducers/index.js index d45315fcb3..df80beffb3 100644 --- a/examples/real-world/reducers/index.js +++ b/examples/real-world/reducers/index.js @@ -1,7 +1,7 @@ import * as ActionTypes from '../actions' import merge from 'lodash/merge' import paginate from './paginate' -import { routeReducer } from 'react-router-redux' +import { reducer as routing } from '../syncHistoryWithStore' import { combineReducers } from 'redux' // Updates an entity cache in response to any action with response.entities. @@ -50,8 +50,7 @@ const rootReducer = combineReducers({ entities, pagination, errorMessage, - routing: routeReducer + routing }) - export default rootReducer diff --git a/examples/real-world/store/configureStore.dev.js b/examples/real-world/store/configureStore.dev.js index 4db7c98edd..c081a7b347 100644 --- a/examples/real-world/store/configureStore.dev.js +++ b/examples/real-world/store/configureStore.dev.js @@ -1,27 +1,20 @@ import { createStore, applyMiddleware, compose } from 'redux' -import { syncHistory } from 'react-router-redux' -import { browserHistory } from 'react-router' -import DevTools from '../containers/DevTools' import thunk from 'redux-thunk' -import api from '../middleware/api' import createLogger from 'redux-logger' +import api from '../middleware/api' import rootReducer from '../reducers' - -const reduxRouterMiddleware = syncHistory(browserHistory) +import DevTools from '../containers/DevTools' export default function configureStore(initialState) { const store = createStore( rootReducer, initialState, compose( - applyMiddleware(thunk, api, reduxRouterMiddleware, createLogger()), + applyMiddleware(thunk, api, createLogger()), DevTools.instrument() ) ) - // Required for replaying actions from devtools to work - reduxRouterMiddleware.listenForReplays(store) - if (module.hot) { // Enable Webpack hot module replacement for reducers module.hot.accept('../reducers', () => { diff --git a/examples/real-world/store/configureStore.prod.js b/examples/real-world/store/configureStore.prod.js index a45088f0e0..3a9b853caf 100644 --- a/examples/real-world/store/configureStore.prod.js +++ b/examples/real-world/store/configureStore.prod.js @@ -1,6 +1,4 @@ import { createStore, applyMiddleware } from 'redux' -import { syncHistory } from 'react-router-redux' -import { browserHistory } from 'react-router' import thunk from 'redux-thunk' import api from '../middleware/api' import rootReducer from '../reducers' @@ -9,6 +7,6 @@ export default function configureStore(initialState) { return createStore( rootReducer, initialState, - applyMiddleware(thunk, api, syncHistory(browserHistory)) + applyMiddleware(thunk, api) ) } diff --git a/examples/real-world/syncHistoryWithStore.js b/examples/real-world/syncHistoryWithStore.js new file mode 100644 index 0000000000..d39c119031 --- /dev/null +++ b/examples/real-world/syncHistoryWithStore.js @@ -0,0 +1,103 @@ +export const WILL_NAVIGATE = '@@react-router/WILL_NAVIGATE' + +const initialState = { + locationBeforeTransitions: null +} + +// Mount this reducer to handle location changes +export const reducer = (state = initialState, action) => { + switch (action.type) { + case WILL_NAVIGATE: + // Use a descriptive name to make it less tempting to reach into it + return { + locationBeforeTransitions: action.locationBeforeTransitions + } + default: + return state + } +} + +export function syncHistoryWithStore(history, store, { + // Specify where you mounted the reducer + selectLocationState = state => state.routing, + adjustUrlOnReplay = false +} = {}) { + 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) + } + + // Whenever store changes due to time travel, keep address bar in sync + const handleStoreChange = () => { + const locationInStore = getLocationInStore(true) + if (currentLocation === locationInStore) { + return + } + + // Update address bar to reflect store state + isTimeTraveling = true + currentLocation = locationInStore + history.replace(locationInStore) + isTimeTraveling = false + } + + if (adjustUrlOnReplay) { + 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: WILL_NAVIGATE, + 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) { + return store.subscribe(() => + listener(getLocationInStore(true)) + ) + }, + + // It also provides a way to destroy internal listeners + dispose() { + if (adjustUrlOnReplay) { + unsubscribeFromStore() + } + unsubscribeFromHistory() + } + }) +} From e271f55a471939ff0bea341e22c837914ca8b995 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 4 Feb 2016 02:48:50 +0000 Subject: [PATCH 2/4] Fix unsubscribe logic --- examples/real-world/syncHistoryWithStore.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/examples/real-world/syncHistoryWithStore.js b/examples/real-world/syncHistoryWithStore.js index d39c119031..955f10cb40 100644 --- a/examples/real-world/syncHistoryWithStore.js +++ b/examples/real-world/syncHistoryWithStore.js @@ -87,9 +87,23 @@ export function syncHistoryWithStore(history, store, { return Object.assign({}, history, { // The listeners are subscribed to the store instead of history listen(listener) { - return store.subscribe(() => - listener(getLocationInStore(true)) - ) + // 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 From 0db894c91617329d965b444314cf9c4ce916a7ed Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 4 Feb 2016 02:54:44 +0000 Subject: [PATCH 3/4] Fail hard on missing reducer --- examples/real-world/syncHistoryWithStore.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/examples/real-world/syncHistoryWithStore.js b/examples/real-world/syncHistoryWithStore.js index 955f10cb40..448c2c4937 100644 --- a/examples/real-world/syncHistoryWithStore.js +++ b/examples/real-world/syncHistoryWithStore.js @@ -22,6 +22,17 @@ export function syncHistoryWithStore(history, store, { selectLocationState = state => state.routing, adjustUrlOnReplay = false } = {}) { + // Fail early if the reducer is not mounted + 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` ' + + 'named argument in the `syncHistoryWithStore()` options. Did you ' + + 'forget to put the `reducer` exported from `syncHistoryWithStore` into ' + + 'your `combineReducers()` call?' + ) + } + let initialLocation let currentLocation let isTimeTraveling From 2e42dfa96c2fb0397a51bd68323d9c37783fe804 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 4 Feb 2016 15:56:34 +0000 Subject: [PATCH 4/4] Preserve location key when time traveling --- examples/real-world/syncHistoryWithStore.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/real-world/syncHistoryWithStore.js b/examples/real-world/syncHistoryWithStore.js index 448c2c4937..afe9de7d06 100644 --- a/examples/real-world/syncHistoryWithStore.js +++ b/examples/real-world/syncHistoryWithStore.js @@ -56,7 +56,10 @@ export function syncHistoryWithStore(history, store, { // Update address bar to reflect store state isTimeTraveling = true currentLocation = locationInStore - history.replace(locationInStore) + history.transitionTo(Object.assign({}, + locationInStore, + { action: 'PUSH' } + )) isTimeTraveling = false }