643 lines
18 KiB
Plaintext
643 lines
18 KiB
Plaintext
// 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
|
|
__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
|
|
}
|
|
return {
|
|
...state,
|
|
key: createRandomKey(),
|
|
[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?.key) {
|
|
win.history.replaceState(
|
|
{
|
|
[stateIndexKey]: 0,
|
|
key: createRandomKey(),
|
|
},
|
|
'',
|
|
)
|
|
}
|
|
|
|
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('?')
|
|
|
|
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: createRandomKey() },
|
|
}
|
|
}
|
|
|
|
// Thanks co-pilot!
|
|
function createRandomKey() {
|
|
return (Math.random() + 1).toString(36).substring(7)
|
|
}
|