Skip to content

Commit

Permalink
feat: Better history throttling
Browse files Browse the repository at this point in the history
  • Loading branch information
tannerlinsley committed Oct 24, 2023
1 parent 806ef33 commit f54d33e
Show file tree
Hide file tree
Showing 6 changed files with 121 additions and 32 deletions.
8 changes: 5 additions & 3 deletions packages/react-router/src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ import {
IsAny,
} from '@tanstack/router-core'

const useLayoutEffect =
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect

declare module '@tanstack/router-core' {
interface RouterOptions<
TRouteTree extends AnyRoute,
Expand Down Expand Up @@ -320,8 +323,7 @@ export type MakeUseMatchRouteOptions<
TTo extends string = '',
TMaskFrom extends RoutePaths<TRouteTree> = '/',
TMaskTo extends string = '',
> = ToOptions<RegisteredRouter['routeTree'], TFrom, TTo, TMaskFrom, TMaskTo> &
MatchRouteOptions
> = ToOptions<AnyRoute, TFrom, TTo, TMaskFrom, TMaskTo> & MatchRouteOptions

export type MakeMatchRouteOptions<
TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
Expand Down Expand Up @@ -538,7 +540,7 @@ export function Navigate<
>(props: NavigateOptions<TRouteTree, TFrom, TTo, TMaskFrom, TMaskTo>): null {
const router = useRouter()

React.useLayoutEffect(() => {
useLayoutEffect(() => {
router.navigate(props as any)
}, [])

Expand Down
9 changes: 1 addition & 8 deletions packages/router-core/src/fileRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,7 @@ export class FileRoute<
>,
'getParentRoute' | 'path' | 'id'
> &
UpdatableRouteOptions<
TLoader,
TSearchSchema,
TFullSearchSchema,
TAllParams,
TRouteContext,
TContext
>,
UpdatableRouteOptions<TLoader, TFullSearchSchema, TAllParams, TContext>,
): Route<
TParentRoute,
TPath,
Expand Down
128 changes: 113 additions & 15 deletions packages/router-core/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface RouterHistory {
forward: () => void
createHref: (href: string) => string
block: (blockerFn: BlockerFn) => () => void
flush: () => void
}

export interface HistoryLocation extends ParsedPath {
Expand Down Expand Up @@ -52,12 +53,13 @@ const stopBlocking = () => {
function createHistory(opts: {
getLocation: () => HistoryLocation
subscriber: false | ((onUpdate: () => void) => () => void)
pushState: (path: string, state: any) => void
replaceState: (path: string, state: any) => void
pushState: (path: string, state: any, onUpdate: () => void) => void
replaceState: (path: string, state: any, onUpdate: () => void) => void
go: (n: number) => void
back: () => void
forward: () => void
createHref: (path: string) => string
flush?: () => void
}): RouterHistory {
let location = opts.getLocation()
let unsub = () => {}
Expand Down Expand Up @@ -116,13 +118,13 @@ function createHistory(opts: {
push: (path: string, state: any) => {
assignKey(state)
queueTask(() => {
opts.pushState(path, state)
opts.pushState(path, state, onUpdate)
})
},
replace: (path: string, state: any) => {
assignKey(state)
queueTask(() => {
opts.replaceState(path, state)
opts.replaceState(path, state, onUpdate)
})
},
go: (index) => {
Expand Down Expand Up @@ -158,6 +160,7 @@ function createHistory(opts: {
}
}
},
flush: () => opts.flush?.(),
}
}

Expand All @@ -171,6 +174,22 @@ function assignKey(state: HistoryState) {
// }
}

/**
* 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?: {
getHref?: () => string
createHref?: (path: string) => string
Expand All @@ -182,24 +201,104 @@ export function createBrowserHistory(opts?: {

const createHref = opts?.createHref ?? ((path) => path)

const getLocation = () => parseLocation(getHref(), window.history.state)
let currentLocation = parseLocation(getHref(), window.history.state)

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
}

// Because we are proactively updating the location
// in memory before actually updating the browser history,
// we need to track when we are doing this so we don't
// notify subscribers twice on the last update.
let tracking = true

// 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 is a wrapper to prevent any of the callback's
// side effects from causing a subscriber notification
const untrack = (fn: () => void) => {
tracking = false
fn()
tracking = true
}

// This function flushes the next update to the browser history
const flush = () => {
// Do not notify subscribers about this push/replace call
untrack(() => {
if (!next) return
window.history[next.isPush ? 'pushState' : 'replaceState'](
next.state,
'',
next.href,
)
// Reset the nextIsPush flag and clear the scheduled update
next = undefined
scheduled = undefined
})
}

// This function queues up a call to update the browser history
const queueHistoryAction = (
type: 'push' | 'replace',
path: string,
state: any,
onUpdate: () => void,
) => {
const href = createHref(path)

// Update the location in memory
currentLocation = parseLocation(href, state)

// Keep track of the next location we need to flush to the URL
next = {
href,
state,
isPush: next?.isPush || type === 'push',
}
// Notify subscribers
onUpdate()

if (!scheduled) {
// Schedule an update to the browser history
scheduled = Promise.resolve().then(() => flush())
}
}

return createHistory({
getLocation,
subscriber: (onUpdate) => {
window.addEventListener(pushStateEvent, onUpdate)
window.addEventListener(popStateEvent, onUpdate)
window.addEventListener(pushStateEvent, () => {
currentLocation = parseLocation(getHref(), window.history.state)
onUpdate()
})
window.addEventListener(popStateEvent, () => {
currentLocation = parseLocation(getHref(), window.history.state)
onUpdate()
})

var pushState = window.history.pushState
window.history.pushState = function () {
let res = pushState.apply(history, arguments as any)
onUpdate()
if (tracking) onUpdate()
return res
}
var replaceState = window.history.replaceState
window.history.replaceState = function () {
let res = replaceState.apply(history, arguments as any)
onUpdate()
if (tracking) onUpdate()
return res
}

Expand All @@ -210,16 +309,15 @@ export function createBrowserHistory(opts?: {
window.removeEventListener(popStateEvent, onUpdate)
}
},
pushState: (path, state) => {
window.history.pushState(state, '', createHref(path))
},
replaceState: (path, state) => {
window.history.replaceState(state, '', createHref(path))
},
pushState: (path, state, onUpdate) =>
queueHistoryAction('push', path, state, onUpdate),
replaceState: (path, state, onUpdate) =>
queueHistoryAction('replace', path, state, onUpdate),
back: () => window.history.back(),
forward: () => window.history.forward(),
go: (n) => window.history.go(n),
createHref: (path) => createHref(path),
flush,
})
}

Expand Down
4 changes: 1 addition & 3 deletions packages/router-core/src/routeInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@ export type RoutePaths<TRouteTree extends AnyRoute> =

export type FullSearchSchema<TRouteTree extends AnyRoute> = Partial<
Expand<
UnionToIntersection<
ParseRoute<TRouteTree>['types']['fullSearchSchema']
> & {}
UnionToIntersection<ParseRoute<TRouteTree>['types']['fullSearchSchema']>
>
>

Expand Down
3 changes: 0 additions & 3 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,19 +163,16 @@ export interface RouterOptions<
unknown,
AnySearchSchema,
AnyPathParams,
AnyContext,
AnyContext
>
defaultErrorComponent?: RegisteredErrorRouteComponent<
AnySearchSchema,
AnyPathParams,
AnyContext,
AnyContext
>
defaultPendingComponent?: RegisteredPendingRouteComponent<
AnySearchSchema,
AnyPathParams,
AnyContext,
AnyContext
>
defaultMaxAge?: number
Expand Down
1 change: 1 addition & 0 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type PickRequired<T> = {
[K in keyof T as undefined extends T[K] ? never : K]: T[K]
}

// export type Expand<T> = T
export type Expand<T> = T extends object
? T extends infer O
? { [K in keyof O]: O[K] }
Expand Down

0 comments on commit f54d33e

Please sign in to comment.