redux-first-router
Version:
think of your app in states not routes (and, yes, while keeping the address bar in sync)
693 lines (575 loc) • 22.6 kB
Flow
// @flow
import type { StoreEnhancer } from 'redux'
import createBrowserHistory from 'rudy-history/createBrowserHistory'
import createMemoryHistory from 'rudy-history/createMemoryHistory'
import { stripTrailingSlash, addLeadingSlash } from 'rudy-history/PathUtils'
import pathToAction from './pure-utils/pathToAction'
import { nestHistory } from './pure-utils/nestAction'
import isLocationAction from './pure-utils/isLocationAction'
import isRedirectAction from './pure-utils/isRedirectAction'
import isServer from './pure-utils/isServer'
import isReactNative from './pure-utils/isReactNative'
import changePageTitle, { getDocument } from './pure-utils/changePageTitle'
import attemptCallRouteThunk from './pure-utils/attemptCallRouteThunk'
import createThunk from './pure-utils/createThunk'
import pathnamePlusSearch from './pure-utils/pathnamePlusSearch'
import canUseDom from './pure-utils/canUseDom'
import {
clearBlocking,
createConfirm,
confirmUI,
setDisplayConfirmLeave,
getUserConfirmation
} from './pure-utils/confirmLeave'
import historyCreateAction from './action-creators/historyCreateAction'
import middlewareCreateAction from './action-creators/middlewareCreateAction'
import middlewareCreateNotFoundAction from './action-creators/middlewareCreateNotFoundAction'
import createLocationReducer, {
getInitialState
} from './reducer/createLocationReducer'
import { NOT_FOUND, ADD_ROUTES } from './index'
import type {
Dispatch as Next,
RoutesMap,
Route,
Options,
Action,
ActionMetaLocation,
ReceivedAction,
Location,
LocationState,
History,
HistoryLocation,
Document,
Store
} from './flow-types'
const __DEV__ = process.env.NODE_ENV !== 'production'
/** PRIMARY EXPORT - `connectRoutes(history, routeMap, options)`:
*
* PURPOSE: to provide set-it-forget-it syncing of actions to the address bar and vice
* versa, using the pairing of action types to express-style routePaths bi-directionally.
*
* EXAMPLE:
* with routeMap `{ FOO: '/foo/:paramName' }`,
*
* pathname '/foo/bar' would become:
* `{ type: 'FOO', payload: { paramName: 'bar' } }`
*
* AND
*
* action `{ type: 'FOO', payload: { paramName: 'bar' } }`
* becomes: pathname '/foo/bar'
*
*
* HOW: Firstly, the middleware listens to received actions and then converts them to the
* pathnames it applies to the address bar (via `history.push({ pathname })`. It also formats
* the action to be location-aware, primarily by including a matching pathname, which the
* location reducer listens to, and which user reducers can also make use of.
*
* However, user reducers typically only need to be concerned with the type
* and payload like they are accustomed to. That's the whole purpose of this package.
* The idea is by matching action types to routePaths, it's set it and forget it!
*
* Secondly, a history listener listens to URL changes and dispatches actions with
* types and payloads that match the pathname. Hurray! Browse back/next buttons now work!
*
* Both the history listener and middleware are made to not get into each other's way, i.e.
* avoiding double dispatching and double address bar changes.
*
*
* VERY IMPORTANT NOTE ON SSR: if you're wondering, `connectRoutes()` when called returns
* functions in a closure that provide access to variables in a private
* "per instance" fashion in order to be used in SSR without leaking
* state between SSR requests :).
*
* As much as possible has been refactored out of this file into pure or
* near-pure utility functions.
*/
export default (routesMap: RoutesMap = {}, options: Options = {}) => {
if (__DEV__) {
if (options.restoreScroll && typeof options.restoreScroll !== 'function') {
throw new Error(
`[redux-first-router] invalid \`restoreScroll\` option. Using
https://github.com/faceyspacey/redux-first-router-restore-scroll
please call \`restoreScroll\` and assign it the option key
of the same name.`
)
}
}
/** INTERNAL ENCLOSED STATE (PER INSTANCE FOR SSR!) */
const {
notFoundPath = '/not-found',
scrollTop = false,
location,
title,
onBeforeChange,
onAfterChange,
onBackNext,
restoreScroll,
initialDispatch: shouldPerformInitialDispatch = true,
querySerializer,
displayConfirmLeave,
extra
}: Options = options
// The options must be initialized ASAP to prevent empty options being
// received in `getOptions` after the initial events emitted
_options = options
setDisplayConfirmLeave(displayConfirmLeave)
if (options.basename) {
options.basename = stripTrailingSlash(addLeadingSlash(options.basename))
}
const isBrowser = canUseDom && process.env.NODE_ENV !== 'test'
const standard = isBrowser ? createBrowserHistory : createMemoryHistory
const createHistory = options.createHistory || standard
const entries = options.initialEntries || '/' // fyi only memoryHistory needs initialEntries
const initialEntries = typeof entries === 'string' ? [entries] : entries
const history = createHistory({
basename: options.basename,
initialEntries,
getUserConfirmation
})
// very important: used for comparison to determine address bar changes
let currentPath: string = pathnamePlusSearch(history.location)
let prevLocation: Location = {
// maintains previous location state in location reducer
pathname: '',
type: '',
payload: {}
}
const selectLocationState =
typeof location === 'function'
? location
: location ? state => state[location] : state => state.location
const selectTitleState =
typeof title === 'function'
? title
: title ? state => state[title] : state => state.title
const scrollBehavior = restoreScroll && restoreScroll(history)
const initialAction = pathToAction(currentPath, routesMap, querySerializer)
const { type, payload, meta }: ReceivedAction = initialAction
const INITIAL_LOCATION_STATE: LocationState = getInitialState(
currentPath,
meta,
type,
payload,
routesMap,
history
)
let prevState = INITIAL_LOCATION_STATE // used only to pass as 1st arg to `scrollBehavior.updateScroll` if used
let nextState = {} // used as 2nd arg to `scrollBehavior.updateScroll` and to change `document.title`
let prevLength = 1 // used by `historyCreateAction` to calculate if moving along history.entries track
const reducer = createLocationReducer(INITIAL_LOCATION_STATE, routesMap)
const initialBag = { action: initialAction, extra }
const thunk = createThunk(routesMap, selectLocationState, initialBag)
const initialDispatch = () => _initialDispatch && _initialDispatch()
const windowDocument: Document = getDocument() // get plain object for window.document if server side
let navigators
let patchNavigators
let actionToNavigation
let navigationToAction
// this value is used to hold temp state between consecutive runs through
// the middleware (i.e. from new dispatches triggered within the middleware)
let tempVal
if (options.navigators) {
// redux-first-router-navigation reformats the `navigators` option
// to have the navigators nested one depth deeper, so as to include
// the various helper functions from its package
if (__DEV__ && !options.navigators.navigators) {
throw new Error(
`[redux-first-router] invalid \`navigators\` option. Pass your map
of navigators to the default import from 'redux-first-router-navigation'.
Don't forget: the keys are your redux state keys.`
)
}
navigators = options.navigators.navigators
patchNavigators = options.navigators.patchNavigators
actionToNavigation = options.navigators.actionToNavigation
navigationToAction = options.navigators.navigationToAction
patchNavigators(navigators)
}
/** MIDDLEWARE
* 1) dispatches actions with location info in the `meta` key by matching the received action
* type + payload to express style routePaths (which also results in location reducer state updating)
* 2) changes the address bar url and page title if the currentPathName changes, while
* avoiding collisions with simultaneous browser history changes
*/
const middleware = (store: Store) => (next: Next) => (action: Object) => {
// We have chosen to not change routes on errors, while letting other middleware
// handle it. Perhaps in the future we will explicitly handle it (as an option)
if (action.error) return next(action)
// code-splitting functionliaty to add routes after store is initially configured
if (action.type === ADD_ROUTES) {
const { type } = selectLocationState(store.getState())
const route = routesMap[type]
routesMap = { ...routesMap, ...action.payload.routes }
const result = next(action)
const nextRoute = routesMap[type]
if (route !== nextRoute) {
if (_confirm !== null) {
clearBlocking()
}
if (typeof nextRoute === 'object' && nextRoute.confirmLeave) {
_confirm = createConfirm(
nextRoute.confirmLeave,
store,
selectLocationState,
history,
querySerializer,
() => (_confirm = null)
)
}
}
return result
}
// navigation transformation specific to React Navigation
let navigationAction
if (navigators && action.type.indexOf('Navigation/') === 0) {
({ navigationAction, action } = navigationToAction(
navigators,
store,
routesMap,
action
))
}
const route = routesMap[action.type]
// We now support "routes" without paths for the purpose of dispatching thunks according
// to the same idiom as full-fledged routes. The purpose is uniformity of async actions.
// The URLs will NOT change.
if (typeof route === 'object' && !route.path) {
const nextAction = next(action)
attemptCallRouteThunk(
store.dispatch,
store.getState,
route,
selectLocationState,
{ action: nextAction, extra }
)
return nextAction
}
// START THE TYPICAL FLOW:
if (action.type === NOT_FOUND && !isLocationAction(action)) {
// user decided to dispatch `NOT_FOUND`, so we fill in the missing location info
action = middlewareCreateNotFoundAction(
action,
selectLocationState(store.getState()),
prevLocation,
history,
notFoundPath
)
}
else if (route && !isLocationAction(action)) {
// THE MAGIC: dispatched action matches a connected type, so we generate a
// location-aware action and also as a result update location reducer state.
action = middlewareCreateAction(
action,
routesMap,
prevLocation,
history,
notFoundPath,
querySerializer
)
}
if (navigators) {
action = actionToNavigation(navigators, action, navigationAction, route)
}
// DISPATCH LIFECYLE:
let skip
if ((route || action.type === NOT_FOUND) && action.meta) {
// satisify flow with `action.meta` check
skip = _beforeRouteChange(store, history, action)
}
if (skip) return
const nextAction = next(action)
if (route || action.type === NOT_FOUND) {
_afterRouteChange(store, route, nextAction)
}
return nextAction
}
const _beforeRouteChange = (
store: Store,
history: History,
action: Action
) => {
const location = action.meta.location
if (_confirm) {
const message = _confirm(location.current)
if (message) {
confirmUI(message, store, action)
return true // skip if there's a message to show in the confirm UI
}
_confirm = null
}
if (onBeforeChange) {
let skip
const redirectAwareDispatch = (action: Action) => {
if (isRedirectAction(action)) {
skip = true
prevLocation = location.current
const nextPath = pathnamePlusSearch(location.current)
const isHistoryChange = nextPath === currentPath
// this insures a `history.push` is called instead of `history.replace`
// even though it's a redirect, since unlike route changes triggered
// from the browser buttons, the URL did not change yet.
if (!isHistoryChange && !isServer()) {
tempVal = 'onBeforeChange'
}
}
return store.dispatch(action)
}
const bag = { action, extra }
onBeforeChange(redirectAwareDispatch, store.getState, bag)
if (skip) return true
}
prevState = selectLocationState(store.getState())
prevLocation = location.current
prevLength = history.length
// addressbar updated before action dispatched like in history.listener
_middlewareAttemptChangeUrl(location, history)
// now we can finally set the history on the action since we get its
// value from the `history` whose value only changes after `push()`
if (isReactNative()) {
location.history = nestHistory(history)
}
}
const _afterRouteChange = (store: Store, route: Route, action: Action) => {
const dispatch = store.dispatch
const state = store.getState()
const kind = selectLocationState(state).kind
const title = selectTitleState(state)
const bag = { action, extra }
nextState = selectLocationState(state)
if (typeof route === 'object') {
let skip = false
const redirectAwareDispatch = (action: Action) => {
if (isRedirectAction(action)) skip = true
return store.dispatch(action)
}
attemptCallRouteThunk(
redirectAwareDispatch,
store.getState,
route,
selectLocationState,
bag
)
if (skip) return
}
if (onAfterChange) {
onAfterChange(dispatch, store.getState, bag)
}
if (!isServer()) {
if (kind) {
if (typeof onBackNext === 'function' && /back|next|pop/.test(kind)) {
onBackNext(dispatch, store.getState, bag)
}
setTimeout(() => {
changePageTitle(windowDocument, title)
if (scrollTop) {
return window.scrollTo(0, 0)
}
_updateScroll(false)
})
}
if (typeof route === 'object' && route.confirmLeave) {
_confirm = createConfirm(
route.confirmLeave,
store,
selectLocationState,
history,
querySerializer,
() => (_confirm = null)
)
}
}
}
const _middlewareAttemptChangeUrl = (
location: ActionMetaLocation,
history: History
) => {
// IMPORTANT: insure history hasn't already handled location change
const nextPath = pathnamePlusSearch(location.current)
if (nextPath !== currentPath) {
// keep currentPath up to date for comparison to prevent double dispatches
// between BROWSER back/forward button usage vs middleware-generated actions
currentPath = nextPath // IMPORTANT: must happen before history.push() (to prevent double handling)
// for React Native, in the case `back` or `next` is
// not called directly, `middlewareCreateAction` may emulate
// `history` backNext actions to support features such
// as scroll restoration. In those cases, we need to prevent
// pushing new routes on to the entries array. `stealth` is
// a React Navigation feature for changing StackNavigators
// without triggering other navigators (such as a TabNavigator)
// to change as well. It allows you to reset hidden StackNavigators.
const { kind } = location
const manuallyInvoked = kind && /back|next|pop|stealth/.test(kind)
if (!manuallyInvoked) {
const isRedirect = kind === 'redirect' && tempVal !== 'onBeforeChange'
const method = isRedirect ? 'replace' : 'push'
history[method](currentPath) // change address bar corresponding to matched actions from middleware
}
}
}
/** ENHANCER
* 1) dispatches actions with types and payload extracted from the URL pattern
* when the browser history changes
* 2) on load of the app dispatches an action corresponding to the initial url
*/
const enhancer: StoreEnhancer<*, *> = createStore => (
reducer,
preloadedState,
enhancer
): Store => {
// routesMap stored in location reducer will be stringified as it goes from the server to client
// and as a result functions in route objects will be removed--here's how we insure we bring them back
if (!isServer() && preloadedState && selectLocationState(preloadedState)) {
selectLocationState(preloadedState).routesMap = routesMap
}
const store = createStore(reducer, preloadedState, enhancer)
const state = store.getState()
const location = state && selectLocationState(state)
if (!location || !location.pathname) {
throw new Error(
`[redux-first-router] you must provide the key of the location
reducer state and properly assigned the location reducer to that key.`
)
}
history.listen(_historyAttemptDispatchAction.bind(null, store))
// dispatch the first location-aware action so initial app state is based on the url on load
if (!location.hasSSR || isServer()) {
// only dispatch on client before SSR is setup, which passes state on to the client
_initialDispatch = () => {
const action = historyCreateAction(
currentPath,
routesMap,
prevLocation,
history,
'load',
querySerializer
)
store.dispatch(action)
}
if (shouldPerformInitialDispatch !== false) {
_initialDispatch()
}
}
else {
// set correct prevLocation on client that has SSR so that it will be
// assigned to `action.meta.location.prev` and the corresponding state
prevLocation = location
const route = routesMap[location.type]
if (typeof route === 'object' && route.confirmLeave) {
_confirm = createConfirm(
route.confirmLeave,
store,
selectLocationState,
history,
querySerializer,
() => (_confirm = null)
)
}
}
// update the scroll position after initial rendering of page
if (!isServer()) setTimeout(() => _updateScroll(false))
return store
}
const _historyAttemptDispatchAction = (
store: Store,
location: HistoryLocation,
historyAction: string
) => {
// IMPORTANT: insure middleware hasn't already handled location change:
const nextPath = pathnamePlusSearch(location)
if (nextPath !== currentPath) {
const kind = historyAction === 'REPLACE' ? 'redirect' : historyAction
// THE MAGIC: parse the address bar path into a matched action
const action = historyCreateAction(
nextPath,
routesMap,
prevLocation,
history,
kind.toLowerCase(),
querySerializer,
currentPath,
prevLength
)
currentPath = nextPath // IMPORTANT: must happen before dispatch (to prevent double handling)
store.dispatch(action) // dispatch route type + payload corresponding to browser back/forward usage
}
}
/* SIDE_EFFECTS - client-only state that must escape closure */
_history = history
_scrollBehavior = scrollBehavior
_selectLocationState = selectLocationState
let _initialDispatch
let _confirm = null
_updateScroll = (performedByUser: boolean = true) => {
if (scrollBehavior) {
if (performedByUser || !scrollBehavior.manual) {
scrollBehavior.updateScroll(prevState, nextState)
}
}
else if (__DEV__ && performedByUser) {
throw new Error(
`[redux-first-router] you must set the \`restoreScroll\` option before
you can call \`updateScroll\``
)
}
}
/* RETURN */
return {
reducer,
middleware,
enhancer,
thunk,
initialDispatch,
// returned only for tests (not for use in application code)
_middlewareAttemptChangeUrl,
_afterRouteChange,
_historyAttemptDispatchAction,
windowDocument,
history
}
}
/** SIDE EFFECTS:
* Client code needs a simple `push`,`back` + `next` functions because it's convenient for
* prototyping. It will not harm SSR, so long as you don't use it server side. So if you use it, that means DO NOT
* simulate clicking links server side--and dont do that, dispatch actions to setup state instead.
*
* THE IDIOMATIC WAY: instead use https://github.com/faceyspacey/redux-first-router-link 's `<Link />`
* component to generate SEO friendly urls. As its `href` prop, you pass it a path, array of path
* segments or action, and internally it will use `connectRoutes` to change the address bar and
* dispatch the correct final action from middleware.
*
* NOTE ON BACK FUNCTIONALITY: The better way to accomplish a back button is to use your redux state to determine
* the previous URL. The location reducer will also contain relevant info. But if you must,
* this is here for convenience and it basically simulates the user pressing the browser
* back button, which of course the system picks up and parses into an action.
*/
let _history
let _scrollBehavior
let _updateScroll
let _selectLocationState
let _options
export const push = (pathname: string) => _history.push(pathname)
export const replace = (pathname: string) => _history.replace(pathname)
export const back = () => _history.goBack()
export const next = () => _history.goForward()
export const go = (n: number) => _history.go(n)
export const canGo = (n: number) => _history.canGo(n)
export const canGoBack = (): boolean =>
!!(_history.entries && _history.entries[_history.index - 1])
export const canGoForward = (): boolean =>
!!(_history.entries && _history.entries[_history.index + 1])
export const prevPath = (): ?string => {
const entry = _history.entries[_history.index - 1]
return entry && entry.pathname
}
export const nextPath = (): ?string => {
const entry = _history.entries[_history.index + 1]
return entry && entry.pathname
}
export const history = () => _history
export const scrollBehavior = () => _scrollBehavior
export const updateScroll = () => _updateScroll && _updateScroll()
export const selectLocationState = (state: Object) =>
_selectLocationState(state)
export const getOptions = (): Options => _options || {}