@tanstack/history
Version:
Modern and scalable routing for React applications
650 lines (580 loc) • 18.4 kB
text/typescript
// While the public API was clearly inspired by the "history" npm package,
// This implementation attempts to be more lightweight by
// making assumptions about the way TanStack Router works
export interface NavigateOptions {
ignoreBlocker?: boolean
}
type SubscriberHistoryAction =
| {
type: Exclude<HistoryAction, 'GO'>
}
| {
type: 'GO'
index: number
}
type SubscriberArgs = {
location: HistoryLocation
action: SubscriberHistoryAction
}
export interface RouterHistory {
location: HistoryLocation
length: number
subscribers: Set<(opts: SubscriberArgs) => void>
subscribe: (cb: (opts: SubscriberArgs) => void) => () => void
push: (path: string, state?: any, navigateOpts?: NavigateOptions) => void
replace: (path: string, state?: any, navigateOpts?: NavigateOptions) => void
go: (index: number, navigateOpts?: NavigateOptions) => void
back: (navigateOpts?: NavigateOptions) => void
forward: (navigateOpts?: NavigateOptions) => void
canGoBack: () => boolean
createHref: (href: string) => string
block: (blocker: NavigationBlocker) => () => void
flush: () => void
destroy: () => void
notify: (action: SubscriberHistoryAction) => void
_ignoreSubscribers?: boolean
}
export interface HistoryLocation extends ParsedPath {
state: ParsedHistoryState
}
export interface ParsedPath {
href: string
pathname: string
search: string
hash: string
}
export interface HistoryState {}
export type ParsedHistoryState = HistoryState & {
key?: string // TODO: Remove in v2 - use __TSR_key instead
__TSR_key?: string
__TSR_index: number
}
type ShouldAllowNavigation = any
export type HistoryAction = 'PUSH' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO'
export type BlockerFnArgs = {
currentLocation: HistoryLocation
nextLocation: HistoryLocation
action: HistoryAction
}
export type BlockerFn = (
args: BlockerFnArgs,
) => Promise<ShouldAllowNavigation> | ShouldAllowNavigation
export type NavigationBlocker = {
blockerFn: BlockerFn
enableBeforeUnload?: (() => boolean) | boolean
}
type TryNavigateArgs = {
task: () => void
type: 'PUSH' | 'REPLACE' | 'BACK' | 'FORWARD' | 'GO'
navigateOpts?: NavigateOptions
} & (
| {
type: 'PUSH' | 'REPLACE'
path: string
state: any
}
| {
type: 'BACK' | 'FORWARD' | 'GO'
}
)
const stateIndexKey = '__TSR_index'
const popStateEvent = 'popstate'
const beforeUnloadEvent = 'beforeunload'
export function createHistory(opts: {
getLocation: () => HistoryLocation
getLength: () => number
pushState: (path: string, state: any) => void
replaceState: (path: string, state: any) => void
go: (n: number) => void
back: (ignoreBlocker: boolean) => void
forward: (ignoreBlocker: boolean) => void
createHref: (path: string) => string
flush?: () => void
destroy?: () => void
onBlocked?: () => void
getBlockers?: () => Array<NavigationBlocker>
setBlockers?: (blockers: Array<NavigationBlocker>) => void
// Avoid notifying on forward/back/go, used for browser history as we already get notified by the popstate event
notifyOnIndexChange?: boolean
}): RouterHistory {
let location = opts.getLocation()
const subscribers = new Set<(opts: SubscriberArgs) => void>()
const notify = (action: SubscriberHistoryAction) => {
location = opts.getLocation()
subscribers.forEach((subscriber) => subscriber({ location, action }))
}
const handleIndexChange = (action: SubscriberHistoryAction) => {
if (opts.notifyOnIndexChange ?? true) notify(action)
else location = opts.getLocation()
}
const tryNavigation = async ({
task,
navigateOpts,
...actionInfo
}: TryNavigateArgs) => {
const ignoreBlocker = navigateOpts?.ignoreBlocker ?? false
if (ignoreBlocker) {
task()
return
}
const blockers = opts.getBlockers?.() ?? []
const isPushOrReplace =
actionInfo.type === 'PUSH' || actionInfo.type === 'REPLACE'
if (typeof document !== 'undefined' && blockers.length && isPushOrReplace) {
for (const blocker of blockers) {
const nextLocation = parseHref(actionInfo.path, actionInfo.state)
const isBlocked = await blocker.blockerFn({
currentLocation: location,
nextLocation,
action: actionInfo.type,
})
if (isBlocked) {
opts.onBlocked?.()
return
}
}
}
task()
}
return {
get location() {
return location
},
get length() {
return opts.getLength()
},
subscribers,
subscribe: (cb: (opts: SubscriberArgs) => void) => {
subscribers.add(cb)
return () => {
subscribers.delete(cb)
}
},
push: (path, state, navigateOpts) => {
const currentIndex = location.state[stateIndexKey]
state = assignKeyAndIndex(currentIndex + 1, state)
tryNavigation({
task: () => {
opts.pushState(path, state)
notify({ type: 'PUSH' })
},
navigateOpts,
type: 'PUSH',
path,
state,
})
},
replace: (path, state, navigateOpts) => {
const currentIndex = location.state[stateIndexKey]
state = assignKeyAndIndex(currentIndex, state)
tryNavigation({
task: () => {
opts.replaceState(path, state)
notify({ type: 'REPLACE' })
},
navigateOpts,
type: 'REPLACE',
path,
state,
})
},
go: (index, navigateOpts) => {
tryNavigation({
task: () => {
opts.go(index)
handleIndexChange({ type: 'GO', index })
},
navigateOpts,
type: 'GO',
})
},
back: (navigateOpts) => {
tryNavigation({
task: () => {
opts.back(navigateOpts?.ignoreBlocker ?? false)
handleIndexChange({ type: 'BACK' })
},
navigateOpts,
type: 'BACK',
})
},
forward: (navigateOpts) => {
tryNavigation({
task: () => {
opts.forward(navigateOpts?.ignoreBlocker ?? false)
handleIndexChange({ type: 'FORWARD' })
},
navigateOpts,
type: 'FORWARD',
})
},
canGoBack: () => location.state[stateIndexKey] !== 0,
createHref: (str) => opts.createHref(str),
block: (blocker) => {
if (!opts.setBlockers) return () => {}
const blockers = opts.getBlockers?.() ?? []
opts.setBlockers([...blockers, blocker])
return () => {
const blockers = opts.getBlockers?.() ?? []
opts.setBlockers?.(blockers.filter((b) => b !== blocker))
}
},
flush: () => opts.flush?.(),
destroy: () => opts.destroy?.(),
notify,
}
}
function assignKeyAndIndex(index: number, state: HistoryState | undefined) {
if (!state) {
state = {} as HistoryState
}
const key = createRandomKey()
return {
...state,
key, // TODO: Remove in v2 - use __TSR_key instead
__TSR_key: key,
[stateIndexKey]: index,
} as ParsedHistoryState
}
/**
* Creates a history object that can be used to interact with the browser's
* navigation. This is a lightweight API wrapping the browser's native methods.
* It is designed to work with TanStack Router, but could be used as a standalone API as well.
* IMPORTANT: This API implements history throttling via a microtask to prevent
* excessive calls to the history API. In some browsers, calling history.pushState or
* history.replaceState in quick succession can cause the browser to ignore subsequent
* calls. This API smooths out those differences and ensures that your application
* state will *eventually* match the browser state. In most cases, this is not a problem,
* but if you need to ensure that the browser state is up to date, you can use the
* `history.flush` method to immediately flush all pending state changes to the browser URL.
* @param opts
* @param opts.getHref A function that returns the current href (path + search + hash)
* @param opts.createHref A function that takes a path and returns a href (path + search + hash)
* @returns A history instance
*/
export function createBrowserHistory(opts?: {
parseLocation?: () => HistoryLocation
createHref?: (path: string) => string
window?: any
}): RouterHistory {
const win =
opts?.window ??
(typeof document !== 'undefined' ? window : (undefined as any))
const originalPushState = win.history.pushState
const originalReplaceState = win.history.replaceState
let blockers: Array<NavigationBlocker> = []
const _getBlockers = () => blockers
const _setBlockers = (newBlockers: Array<NavigationBlocker>) =>
(blockers = newBlockers)
const createHref = opts?.createHref ?? ((path) => path)
const parseLocation =
opts?.parseLocation ??
(() =>
parseHref(
`${win.location.pathname}${win.location.search}${win.location.hash}`,
win.history.state,
))
// Ensure there is always a key to start
if (!win.history.state?.__TSR_key && !win.history.state?.key) {
const addedKey = createRandomKey()
win.history.replaceState(
{
[stateIndexKey]: 0,
key: addedKey, // TODO: Remove in v2 - use __TSR_key instead
__TSR_key: addedKey,
},
'',
)
}
let currentLocation = parseLocation()
let rollbackLocation: HistoryLocation | undefined
let nextPopIsGo = false
let ignoreNextPop = false
let skipBlockerNextPop = false
let ignoreNextBeforeUnload = false
const getLocation = () => currentLocation
let next:
| undefined
| {
// This is the latest location that we were attempting to push/replace
href: string
// This is the latest state that we were attempting to push/replace
state: any
// This is the latest type that we were attempting to push/replace
isPush: boolean
}
// We need to track the current scheduled update to prevent
// multiple updates from being scheduled at the same time.
let scheduled: Promise<void> | undefined
// This function flushes the next update to the browser history
const flush = () => {
if (!next) {
return
}
// We need to ignore any updates to the subscribers while we update the browser history
history._ignoreSubscribers = true
// Update the browser history
;(next.isPush ? win.history.pushState : win.history.replaceState)(
next.state,
'',
next.href,
)
// Stop ignoring subscriber updates
history._ignoreSubscribers = false
// Reset the nextIsPush flag and clear the scheduled update
next = undefined
scheduled = undefined
rollbackLocation = undefined
}
// This function queues up a call to update the browser history
const queueHistoryAction = (
type: 'push' | 'replace',
destHref: string,
state: any,
) => {
const href = createHref(destHref)
if (!scheduled) {
rollbackLocation = currentLocation
}
// Update the location in memory
currentLocation = parseHref(destHref, state)
// Keep track of the next location we need to flush to the URL
next = {
href,
state,
isPush: next?.isPush || type === 'push',
}
if (!scheduled) {
// Schedule an update to the browser history
scheduled = Promise.resolve().then(() => flush())
}
}
// NOTE: this function can probably be removed
const onPushPop = (type: 'PUSH' | 'REPLACE') => {
currentLocation = parseLocation()
history.notify({ type })
}
const onPushPopEvent = async () => {
if (ignoreNextPop) {
ignoreNextPop = false
return
}
const nextLocation = parseLocation()
const delta =
nextLocation.state[stateIndexKey] - currentLocation.state[stateIndexKey]
const isForward = delta === 1
const isBack = delta === -1
const isGo = (!isForward && !isBack) || nextPopIsGo
nextPopIsGo = false
const action = isGo ? 'GO' : isBack ? 'BACK' : 'FORWARD'
const notify: SubscriberHistoryAction = isGo
? {
type: 'GO',
index: delta,
}
: {
type: isBack ? 'BACK' : 'FORWARD',
}
if (skipBlockerNextPop) {
skipBlockerNextPop = false
} else {
const blockers = _getBlockers()
if (typeof document !== 'undefined' && blockers.length) {
for (const blocker of blockers) {
const isBlocked = await blocker.blockerFn({
currentLocation,
nextLocation,
action,
})
if (isBlocked) {
ignoreNextPop = true
win.history.go(1)
history.notify(notify)
return
}
}
}
}
currentLocation = parseLocation()
history.notify(notify)
}
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (ignoreNextBeforeUnload) {
ignoreNextBeforeUnload = false
return
}
let shouldBlock = false
// If one blocker has a non-disabled beforeUnload, we should block
const blockers = _getBlockers()
if (typeof document !== 'undefined' && blockers.length) {
for (const blocker of blockers) {
const shouldHaveBeforeUnload = blocker.enableBeforeUnload ?? true
if (shouldHaveBeforeUnload === true) {
shouldBlock = true
break
}
if (
typeof shouldHaveBeforeUnload === 'function' &&
shouldHaveBeforeUnload() === true
) {
shouldBlock = true
break
}
}
}
if (shouldBlock) {
e.preventDefault()
return (e.returnValue = '')
}
return
}
const history = createHistory({
getLocation,
getLength: () => win.history.length,
pushState: (href, state) => queueHistoryAction('push', href, state),
replaceState: (href, state) => queueHistoryAction('replace', href, state),
back: (ignoreBlocker) => {
if (ignoreBlocker) skipBlockerNextPop = true
ignoreNextBeforeUnload = true
return win.history.back()
},
forward: (ignoreBlocker) => {
if (ignoreBlocker) skipBlockerNextPop = true
ignoreNextBeforeUnload = true
win.history.forward()
},
go: (n) => {
nextPopIsGo = true
win.history.go(n)
},
createHref: (href) => createHref(href),
flush,
destroy: () => {
win.history.pushState = originalPushState
win.history.replaceState = originalReplaceState
win.removeEventListener(beforeUnloadEvent, onBeforeUnload, {
capture: true,
})
win.removeEventListener(popStateEvent, onPushPopEvent)
},
onBlocked: () => {
// If a navigation is blocked, we need to rollback the location
// that we optimistically updated in memory.
if (rollbackLocation && currentLocation !== rollbackLocation) {
currentLocation = rollbackLocation
}
},
getBlockers: _getBlockers,
setBlockers: _setBlockers,
notifyOnIndexChange: false,
})
win.addEventListener(beforeUnloadEvent, onBeforeUnload, { capture: true })
win.addEventListener(popStateEvent, onPushPopEvent)
win.history.pushState = function (...args: Array<any>) {
const res = originalPushState.apply(win.history, args as any)
if (!history._ignoreSubscribers) onPushPop('PUSH')
return res
}
win.history.replaceState = function (...args: Array<any>) {
const res = originalReplaceState.apply(win.history, args as any)
if (!history._ignoreSubscribers) onPushPop('REPLACE')
return res
}
return history
}
export function createHashHistory(opts?: { window?: any }): RouterHistory {
const win =
opts?.window ??
(typeof document !== 'undefined' ? window : (undefined as any))
return createBrowserHistory({
window: win,
parseLocation: () => {
const hashSplit = win.location.hash.split('#').slice(1)
const pathPart = hashSplit[0] ?? '/'
const searchPart = win.location.search
const hashEntries = hashSplit.slice(1)
const hashPart =
hashEntries.length === 0 ? '' : `#${hashEntries.join('#')}`
const hashHref = `${pathPart}${searchPart}${hashPart}`
return parseHref(hashHref, win.history.state)
},
createHref: (href) =>
`${win.location.pathname}${win.location.search}#${href}`,
})
}
export function createMemoryHistory(
opts: {
initialEntries: Array<string>
initialIndex?: number
} = {
initialEntries: ['/'],
},
): RouterHistory {
const entries = opts.initialEntries
let index = opts.initialIndex
? Math.min(Math.max(opts.initialIndex, 0), entries.length - 1)
: entries.length - 1
const states = entries.map((_entry, index) =>
assignKeyAndIndex(index, undefined),
)
const getLocation = () => parseHref(entries[index]!, states[index])
return createHistory({
getLocation,
getLength: () => entries.length,
pushState: (path, state) => {
// Removes all subsequent entries after the current index to start a new branch
if (index < entries.length - 1) {
entries.splice(index + 1)
states.splice(index + 1)
}
states.push(state)
entries.push(path)
index = Math.max(entries.length - 1, 0)
},
replaceState: (path, state) => {
states[index] = state
entries[index] = path
},
back: () => {
index = Math.max(index - 1, 0)
},
forward: () => {
index = Math.min(index + 1, entries.length - 1)
},
go: (n) => {
index = Math.min(Math.max(index + n, 0), entries.length - 1)
},
createHref: (path) => path,
})
}
export function parseHref(
href: string,
state: ParsedHistoryState | undefined,
): HistoryLocation {
const hashIndex = href.indexOf('#')
const searchIndex = href.indexOf('?')
const addedKey = createRandomKey()
return {
href,
pathname: href.substring(
0,
hashIndex > 0
? searchIndex > 0
? Math.min(hashIndex, searchIndex)
: hashIndex
: searchIndex > 0
? searchIndex
: href.length,
),
hash: hashIndex > -1 ? href.substring(hashIndex) : '',
search:
searchIndex > -1
? href.slice(searchIndex, hashIndex === -1 ? undefined : hashIndex)
: '',
state: state || { [stateIndexKey]: 0, key: addedKey, __TSR_key: addedKey },
}
}
// Thanks co-pilot!
function createRandomKey() {
return (Math.random() + 1).toString(36).substring(7)
}