Skip to content
This repository has been archived by the owner on Oct 26, 2018. It is now read-only.

Commit

Permalink
Use middleware to synchronize store to history
Browse files Browse the repository at this point in the history
  • Loading branch information
taion committed Dec 27, 2015
1 parent a668989 commit a0c2a66
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 205 deletions.
3 changes: 1 addition & 2 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"presets": ["es2015"],
"plugins": ["transform-object-assign"]
"presets": ["es2015", "stage-2"]
}
5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
"babel-core": "^6.2.1",
"babel-eslint": "^4.1.6",
"babel-loader": "^6.2.0",
"babel-plugin-transform-object-assign": "^6.0.14",
"babel-preset-es2015": "^6.1.2",
"babel-preset-stage-2": "^6.3.13",
"eslint": "^1.10.3",
"eslint-config-rackt": "^1.1.1",
"expect": "^1.13.0",
Expand All @@ -64,8 +64,5 @@
"redux": "^3.0.4",
"redux-devtools": "^2.1.5",
"webpack": "^1.12.9"
},
"dependencies": {
"deep-equal": "^1.0.1"
}
}
166 changes: 68 additions & 98 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,61 +1,40 @@
import deepEqual from 'deep-equal'

// Constants

export const UPDATE_PATH = '@@router/UPDATE_PATH'
const SELECT_STATE = state => state.routing

export function pushPath(path, state, { avoidRouterUpdate = false } = {}) {
export function pushPath(path, state, key) {
return {
type: UPDATE_PATH,
payload: {
path: path,
state: state,
replace: false,
avoidRouterUpdate: !!avoidRouterUpdate
}
payload: { path, state, key, replace: false }
}
}

export function replacePath(path, state, { avoidRouterUpdate = false } = {}) {
export function replacePath(path, state, key) {
return {
type: UPDATE_PATH,
payload: {
path: path,
state: state,
replace: true,
avoidRouterUpdate: !!avoidRouterUpdate
}
payload: { path, state, key, replace: true }
}
}

// Reducer

let initialState = {
changeId: 1,
path: undefined,
state: undefined,
replace: false
replace: false,
key: undefined
}

function update(state=initialState, { type, payload }) {
export function routeReducer(state=initialState, { type, payload }) {
if(type === UPDATE_PATH) {
return Object.assign({}, state, {
path: payload.path,
changeId: state.changeId + (payload.avoidRouterUpdate ? 0 : 1),
state: payload.state,
replace: payload.replace
})
return payload
}

return state
}

// Syncing

function locationsAreEqual(a, b) {
return a != null && b != null && a.path === b.path && deepEqual(a.state, b.state)
}

function createPath(location) {
const { pathname, search, hash } = location
let result = pathname
Expand All @@ -66,84 +45,75 @@ function createPath(location) {
return result
}

export function syncReduxAndRouter(history, store, selectRouterState = SELECT_STATE) {
const getRouterState = () => selectRouterState(store.getState())

// To properly handle store updates we need to track the last route.
// This route contains a `changeId` which is updated on every
// `pushPath` and `replacePath`. If this id changes we always
// trigger a history update. However, if the id does not change, we
// check if the location has changed, and if it is we trigger a
// history update. It's possible for this to happen when something
// reloads the entire app state such as redux devtools.
let lastRoute = undefined

if(!getRouterState()) {
throw new Error(
'Cannot sync router: route state does not exist (`state.routing` by default). ' +
'Did you install the routing reducer?'
)
}
export function syncHistory(history) {
let unsubscribeHistory, currentKey, unsubscribeStore
let connected = false

const unsubscribeHistory = history.listen(location => {
const route = {
path: createPath(location),
state: location.state
}
function middleware(store) {
unsubscribeHistory = history.listen(location => {
const path = createPath(location)
const { state, key } = location
currentKey = key

if (!lastRoute) {
// `initialState` *should* represent the current location when
// the app loads, but we cannot get the current location when it
// is defined. What happens is `history.listen` is called
// immediately when it is registered, and it updates the app
// state with an UPDATE_PATH action. This causes problem when
// users are listening to UPDATE_PATH actions just for
// *changes*, and with redux devtools because "revert" will use
// `initialState` and it won't revert to the original URL.
// Instead, we specialize the first route notification and do
// different things based on it.
initialState = {
changeId: 1,
path: route.path,
state: route.state,
replace: false
}
const method = location.action === 'REPLACE' ? replacePath : pushPath
store.dispatch(method(path, state, key))
})

// Also set `lastRoute` so that the store subscriber doesn't
// trigger an unnecessary `pushState` on load
lastRoute = initialState
connected = true

store.dispatch(pushPath(route.path, route.state, { avoidRouterUpdate: true }));
} else if(!locationsAreEqual(getRouterState(), route)) {
// The above check avoids dispatching an action if the store is
// already up-to-date
const method = location.action === 'REPLACE' ? replacePath : pushPath
store.dispatch(method(route.path, route.state, { avoidRouterUpdate: true }))
}
})
return next => action => {
if (action.type !== UPDATE_PATH) {
next(action)
return
}

const unsubscribeStore = store.subscribe(() => {
let routing = getRouterState()
const { payload } = action
if (payload.key || !connected) {
// Either this came from the history, or else we're not forwarding
// location actions to history.
next(action)
return
}

// Only trigger history update if this is a new change or the
// location has changed.
if(lastRoute.changeId !== routing.changeId ||
!locationsAreEqual(lastRoute, routing)) {
const { replace, state, path } = payload
// FIXME: ???! `path` and `pathname` are _not_ synonymous.
const method = replace ? 'replaceState' : 'pushState'

lastRoute = routing
const method = routing.replace ? 'replace' : 'push'
history[method]({
pathname: routing.path,
state: routing.state
})
history[method](state, path)
}
}

})
middleware.syncHistoryToStore =
(store, selectRouterState = SELECT_STATE) => {
const getRouterState = () => selectRouterState(store.getState())
const {
key: initialKey, state: initialState, path: initialPath
} = getRouterState()

unsubscribeStore = store.subscribe(() => {
let { key, state, path } = getRouterState()

// If we're resetting to the beginning, use the saved values.
if (key === undefined) {
key = initialKey
state = initialState
path = initialPath
}

if (key !== currentKey) {
history.pushState(state, path)
}
})
}

return function unsubscribe() {
middleware.unsubscribe = () => {
unsubscribeHistory()
unsubscribeStore()
if (unsubscribeStore) {
unsubscribeStore()
}

connected = false
}
}

export { update as routeReducer }
return middleware
}
2 changes: 1 addition & 1 deletion test/browser/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ import { createHashHistory, createHistory } from 'history'
import createTests from '../createTests.js'

createTests(createHashHistory, 'Hash History', () => window.location = '#/')
createTests(createHistory, 'Browser History', () => window.history.replaceState(null, null, '/'))
createTests(createHistory, 'Browser History', () => window.history.replaceState(null, null, '/'), true)
Loading

0 comments on commit a0c2a66

Please sign in to comment.