-
-
Notifications
You must be signed in to change notification settings - Fork 15.2k
RFC: syncHistoryWithStore() #1362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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( | ||
<Root store={store} />, | ||
<Root store={store} history={history} />, | ||
document.getElementById('root') | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not just name the thing 'routing' in syncHistoryWithStore? I do this exact same thing in my code and my hunch is that other people do as well. 😄 |
||
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
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, { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is when I get to say “thank goodness histories are not classes, I told ya!” |
||
// Specify where you mounted the reducer | ||
selectLocationState = state => state.routing, | ||
adjustUrlOnReplay = false | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since there are no action creators exposed and the state key is named to discourage access, it would seem the primary purpose is to sync up the URL bar after a replay. In that case, should this default to true? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Defaulting to true means that if you hydrate state from e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can detect that, because the initial location on load is a "POP" action. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would I tell between the first POP action and a persisted session that happens to end on a POP? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm confused. Why would it be surprising that restoring your serialized state, which syncs to history, would restore your URL bar? Wouldn't that be an expected behavior? |
||
} = {}) { | ||
// 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 | ||
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.transitionTo(Object.assign({}, | ||
locationInStore, | ||
{ action: 'PUSH' } | ||
)) | ||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Normally history enhancers don't call There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, we're purposefully eavesdropping here. Can make this lazy if necessary although then we have a risk of missing initial actions (I think). |
||
|
||
// 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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TODO (design): can we agree on a standard way of disposing resources required by histories, if any? Then we could call underlying There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The standard way is that we have this cruft in like a half dozen different places by now: https://github.com/rackt/history/blob/v2.0.0-rc3/modules/createBrowserHistory.js#L104-L132. This would be easier if histories were classes, but they're not 😛 |
||
if (adjustUrlOnReplay) { | ||
unsubscribeFromStore() | ||
} | ||
unsubscribeFromHistory() | ||
} | ||
}) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: if you pass
false
DevTools history time travel will still work in UI, it just won't update the address bar.