@tanstack/router-core
Version:
Modern and scalable routing for React applications
1,553 lines (1,380 loc) • 77.9 kB
text/typescript
import { Store, batch } from '@tanstack/store'
import {
createBrowserHistory,
createMemoryHistory,
parseHref,
} from '@tanstack/history'
import {
createControlledPromise,
deepEqual,
findLast,
functionalUpdate,
last,
replaceEqualDeep,
} from './utils'
import { processRouteTree } from './process-route-tree'
import {
cleanPath,
interpolatePath,
joinPaths,
matchPathname,
resolvePath,
trimPath,
trimPathRight,
} from './path'
import { isNotFound } from './not-found'
import { setupScrollRestoration } from './scroll-restoration'
import { defaultParseSearch, defaultStringifySearch } from './searchParams'
import { rootRouteId } from './root'
import { isRedirect, redirect } from './redirect'
import { createLRUCache } from './lru-cache'
import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches'
import type { ParsePathnameCache } from './path'
import type { SearchParser, SearchSerializer } from './searchParams'
import type { AnyRedirect, ResolvedRedirect } from './redirect'
import type {
HistoryLocation,
HistoryState,
ParsedHistoryState,
RouterHistory,
} from '@tanstack/history'
import type {
Awaitable,
ControlledPromise,
NoInfer,
NonNullableUpdater,
PickAsRequired,
Updater,
} from './utils'
import type { ParsedLocation } from './location'
import type {
AnyContext,
AnyRoute,
AnyRouteWithContext,
MakeRemountDepsOptionsUnion,
RouteContextOptions,
RouteLike,
RouteMask,
SearchMiddleware,
} from './route'
import type {
FullSearchSchema,
RouteById,
RoutePaths,
RoutesById,
RoutesByPath,
} from './routeInfo'
import type {
AnyRouteMatch,
MakeRouteMatch,
MakeRouteMatchUnion,
MatchRouteOptions,
} from './Matches'
import type {
BuildLocationFn,
CommitLocationOptions,
NavigateFn,
} from './RouterProvider'
import type { Manifest } from './manifest'
import type { AnySchema, AnyValidator } from './validators'
import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link'
import type { NotFoundError } from './not-found'
export type ControllablePromise<T = any> = Promise<T> & {
resolve: (value: T) => void
reject: (value?: any) => void
}
export type InjectedHtmlEntry = Promise<string>
export interface DefaultRegister {
router: AnyRouter
}
export interface Register extends DefaultRegister {
// router: Router
}
export type RegisteredRouter = Register['router']
export type DefaultRemountDepsFn<TRouteTree extends AnyRoute> = (
opts: MakeRemountDepsOptionsUnion<TRouteTree>,
) => any
export interface DefaultRouterOptionsExtensions {}
export interface RouterOptionsExtensions
extends DefaultRouterOptionsExtensions {}
export interface RouterOptions<
TRouteTree extends AnyRoute,
TTrailingSlashOption extends TrailingSlashOption,
TDefaultStructuralSharingOption extends boolean = false,
TRouterHistory extends RouterHistory = RouterHistory,
TDehydrated extends Record<string, any> = Record<string, any>,
> extends RouterOptionsExtensions {
/**
* The history object that will be used to manage the browser history.
*
* If not provided, a new createBrowserHistory instance will be created and used.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#history-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/history-types)
*/
history?: TRouterHistory
/**
* A function that will be used to stringify search params when generating links.
*
* @default defaultStringifySearch
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#stringifysearch-method)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization)
*/
stringifySearch?: SearchSerializer
/**
* A function that will be used to parse search params when parsing the current location.
*
* @default defaultParseSearch
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#parsesearch-method)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/custom-search-param-serialization)
*/
parseSearch?: SearchParser
/**
* If `false`, routes will not be preloaded by default in any way.
*
* If `'intent'`, routes will be preloaded by default when the user hovers over a link or a `touchstart` event is detected on a `<Link>`.
*
* If `'viewport'`, routes will be preloaded by default when they are within the viewport.
*
* @default false
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreload-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading)
*/
defaultPreload?: false | 'intent' | 'viewport' | 'render'
/**
* The delay in milliseconds that a route must be hovered over or touched before it is preloaded.
*
* @default 50
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloaddelay-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading#preload-delay)
*/
defaultPreloadDelay?: number
/**
* The default `preloadIntentProximity` a route should use if no preloadIntentProximity is provided.
*
* @default 0
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloadintentproximity-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading#preload-intent-proximity)
*/
defaultPreloadIntentProximity?: number
/**
* The default `pendingMs` a route should use if no pendingMs is provided.
*
* @default 1000
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpendingms-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#avoiding-pending-component-flash)
*/
defaultPendingMs?: number
/**
* The default `pendingMinMs` a route should use if no pendingMinMs is provided.
*
* @default 500
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpendingminms-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#avoiding-pending-component-flash)
*/
defaultPendingMinMs?: number
/**
* The default `staleTime` a route should use if no staleTime is provided. This is the time in milliseconds that a route will be considered fresh.
*
* @default 0
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultstaletime-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#key-options)
*/
defaultStaleTime?: number
/**
* The default `preloadStaleTime` a route should use if no preloadStaleTime is provided.
*
* @default 30_000 `(30 seconds)`
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloadstaletime-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading)
*/
defaultPreloadStaleTime?: number
/**
* The default `defaultPreloadGcTime` a route should use if no preloadGcTime is provided.
*
* @default 1_800_000 `(30 minutes)`
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultpreloadgctime-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/preloading)
*/
defaultPreloadGcTime?: number
/**
* If `true`, route navigations will called using `document.startViewTransition()`.
*
* If the browser does not support this api, this option will be ignored.
*
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition) for more information on how this function works.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultviewtransition-property)
*/
defaultViewTransition?: boolean | ViewTransitionOptions
/**
* The default `hashScrollIntoView` a route should use if no hashScrollIntoView is provided while navigating
*
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) for more information on `ScrollIntoViewOptions`.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaulthashscrollintoview-property)
*/
defaultHashScrollIntoView?: boolean | ScrollIntoViewOptions
/**
* @default 'fuzzy'
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#notfoundmode-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/not-found-errors#the-notfoundmode-option)
*/
notFoundMode?: 'root' | 'fuzzy'
/**
* The default `gcTime` a route should use if no gcTime is provided.
*
* @default 1_800_000 `(30 minutes)`
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultgctime-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#key-options)
*/
defaultGcTime?: number
/**
* If `true`, all routes will be matched as case-sensitive.
*
* @default false
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#casesensitive-property)
*/
caseSensitive?: boolean
/**
*
* The route tree that will be used to configure the router instance.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#routetree-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/routing/route-trees)
*/
routeTree?: TRouteTree
/**
* The basepath for then entire router. This is useful for mounting a router instance at a subpath.
*
* @default '/'
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#basepath-property)
*/
basepath?: string
/**
* The root context that will be provided to all routes in the route tree.
*
* This can be used to provide a context to all routes in the tree without having to provide it to each route individually.
*
* Optional or required if the root route was created with [`createRootRouteWithContext()`](https://tanstack.com/router/latest/docs/framework/react/api/router/createRootRouteWithContextFunction).
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#context-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/router-context)
*/
context?: InferRouterContext<TRouteTree>
/**
* A function that will be called when the router is dehydrated.
*
* The return value of this function will be serialized and stored in the router's dehydrated state.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#dehydrate-method)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading#critical-dehydrationhydration)
*/
dehydrate?: () => Awaitable<TDehydrated>
/**
* A function that will be called when the router is hydrated.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#hydrate-method)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading#critical-dehydrationhydration)
*/
hydrate?: (dehydrated: TDehydrated) => Awaitable<void>
/**
* An array of route masks that will be used to mask routes in the route tree.
*
* Route masking is when you display a route at a different path than the one it is configured to match, like a modal popup that when shared will unmask to the modal's content instead of the modal's context.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#routemasks-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/route-masking)
*/
routeMasks?: Array<RouteMask<TRouteTree>>
/**
* If `true`, route masks will, by default, be removed when the page is reloaded.
*
* This can be overridden on a per-mask basis by setting the `unmaskOnReload` option on the mask, or on a per-navigation basis by setting the `unmaskOnReload` option in the `Navigate` options.
*
* @default false
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#unmaskonreload-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/route-masking#unmasking-on-page-reload)
*/
unmaskOnReload?: boolean
/**
* Use `notFoundComponent` instead.
*
* @deprecated
* See https://tanstack.com/router/v1/docs/guide/not-found-errors#migrating-from-notfoundroute for more info.
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#notfoundroute-property)
*/
notFoundRoute?: AnyRoute
/**
* Configures how trailing slashes are treated.
*
* - `'always'` will add a trailing slash if not present
* - `'never'` will remove the trailing slash if present
* - `'preserve'` will not modify the trailing slash.
*
* @default 'never'
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#trailingslash-property)
*/
trailingSlash?: TTrailingSlashOption
/**
* While usually automatic, sometimes it can be useful to force the router into a server-side state, e.g. when using the router in a non-browser environment that has access to a global.document object.
*
* @default typeof document !== 'undefined'
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#isserver-property)
*/
isServer?: boolean
/**
* @default false
*/
isShell?: boolean
/**
* @default false
*/
isPrerendering?: boolean
/**
* The default `ssr` a route should use if no `ssr` is provided.
*
* @default true
*/
defaultSsr?: boolean | 'data-only'
search?: {
/**
* Configures how unknown search params (= not returned by any `validateSearch`) are treated.
*
* @default false
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#search.strict-property)
*/
strict?: boolean
}
/**
* Configures whether structural sharing is enabled by default for fine-grained selectors.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultstructuralsharing-property)
*/
defaultStructuralSharing?: TDefaultStructuralSharingOption
/**
* Configures which URI characters are allowed in path params that would ordinarily be escaped by encodeURIComponent.
*
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#pathparamsallowedcharacters-property)
* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/path-params#allowed-characters)
*/
pathParamsAllowedCharacters?: Array<
';' | ':' | '@' | '&' | '=' | '+' | '$' | ','
>
defaultRemountDeps?: DefaultRemountDepsFn<TRouteTree>
/**
* If `true`, scroll restoration will be enabled
*
* @default false
*/
scrollRestoration?: boolean
/**
* A function that will be called to get the key for the scroll restoration cache.
*
* @default (location) => location.href
*/
getScrollRestorationKey?: (location: ParsedLocation) => string
/**
* The default behavior for scroll restoration.
*
* @default 'auto'
*/
scrollRestorationBehavior?: ScrollBehavior
/**
* An array of selectors that will be used to scroll to the top of the page in addition to `window`
*
* @default ['window']
*/
scrollToTopSelectors?: Array<string | (() => Element | null | undefined)>
/**
* When `true`, disables the global catch boundary that normally wraps all route matches.
* This allows unhandled errors to bubble up to top-level error handlers in the browser.
*
* Useful for testing tools (like Storybook Test Runner), error reporting services,
* and debugging scenarios where you want errors to reach the browser's global error handlers.
*
* @default false
*/
disableGlobalCatchBoundary?: boolean
}
export interface RouterState<
in out TRouteTree extends AnyRoute = AnyRoute,
in out TRouteMatch = MakeRouteMatchUnion,
> {
status: 'pending' | 'idle'
loadedAt: number
isLoading: boolean
isTransitioning: boolean
matches: Array<TRouteMatch>
pendingMatches?: Array<TRouteMatch>
cachedMatches: Array<TRouteMatch>
location: ParsedLocation<FullSearchSchema<TRouteTree>>
resolvedLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
statusCode: number
redirect?: AnyRedirect
}
export interface BuildNextOptions {
to?: string | number | null
params?: true | Updater<unknown>
search?: true | Updater<unknown>
hash?: true | Updater<string>
state?: true | NonNullableUpdater<ParsedHistoryState, HistoryState>
mask?: {
to?: string | number | null
params?: true | Updater<unknown>
search?: true | Updater<unknown>
hash?: true | Updater<string>
state?: true | NonNullableUpdater<ParsedHistoryState, HistoryState>
unmaskOnReload?: boolean
}
from?: string
href?: string
_fromLocation?: ParsedLocation
unsafeRelative?: 'path'
_isNavigate?: boolean
}
type NavigationEventInfo = {
fromLocation?: ParsedLocation
toLocation: ParsedLocation
pathChanged: boolean
hrefChanged: boolean
hashChanged: boolean
}
export interface RouterEvents {
onBeforeNavigate: {
type: 'onBeforeNavigate'
} & NavigationEventInfo
onBeforeLoad: {
type: 'onBeforeLoad'
} & NavigationEventInfo
onLoad: {
type: 'onLoad'
} & NavigationEventInfo
onResolved: {
type: 'onResolved'
} & NavigationEventInfo
onBeforeRouteMount: {
type: 'onBeforeRouteMount'
} & NavigationEventInfo
onRendered: {
type: 'onRendered'
} & NavigationEventInfo
}
export type RouterEvent = RouterEvents[keyof RouterEvents]
export type ListenerFn<TEvent extends RouterEvent> = (event: TEvent) => void
export type RouterListener<TRouterEvent extends RouterEvent> = {
eventType: TRouterEvent['type']
fn: ListenerFn<TRouterEvent>
}
export type SubscribeFn = <TType extends keyof RouterEvents>(
eventType: TType,
fn: ListenerFn<RouterEvents[TType]>,
) => () => void
export interface MatchRoutesOpts {
preload?: boolean
throwOnError?: boolean
_buildLocation?: boolean
dest?: BuildNextOptions
}
export type InferRouterContext<TRouteTree extends AnyRoute> =
TRouteTree['types']['routerContext']
export type RouterContextOptions<TRouteTree extends AnyRoute> =
AnyContext extends InferRouterContext<TRouteTree>
? {
context?: InferRouterContext<TRouteTree>
}
: {
context: InferRouterContext<TRouteTree>
}
export type RouterConstructorOptions<
TRouteTree extends AnyRoute,
TTrailingSlashOption extends TrailingSlashOption,
TDefaultStructuralSharingOption extends boolean,
TRouterHistory extends RouterHistory,
TDehydrated extends Record<string, any>,
> = Omit<
RouterOptions<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory,
TDehydrated
>,
'context'
> &
RouterContextOptions<TRouteTree>
export type PreloadRouteFn<
TRouteTree extends AnyRoute,
TTrailingSlashOption extends TrailingSlashOption,
TDefaultStructuralSharingOption extends boolean,
TRouterHistory extends RouterHistory,
> = <
TFrom extends RoutePaths<TRouteTree> | string = string,
TTo extends string | undefined = undefined,
TMaskFrom extends RoutePaths<TRouteTree> | string = TFrom,
TMaskTo extends string = '',
>(
opts: NavigateOptions<
RouterCore<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory
>,
TFrom,
TTo,
TMaskFrom,
TMaskTo
>,
) => Promise<Array<AnyRouteMatch> | undefined>
export type MatchRouteFn<
TRouteTree extends AnyRoute,
TTrailingSlashOption extends TrailingSlashOption,
TDefaultStructuralSharingOption extends boolean,
TRouterHistory extends RouterHistory,
> = <
TFrom extends RoutePaths<TRouteTree> = '/',
TTo extends string | undefined = undefined,
TResolved = ResolveRelativePath<TFrom, NoInfer<TTo>>,
>(
location: ToOptions<
RouterCore<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory
>,
TFrom,
TTo
>,
opts?: MatchRouteOptions,
) => false | RouteById<TRouteTree, TResolved>['types']['allParams']
export type UpdateFn<
TRouteTree extends AnyRoute,
TTrailingSlashOption extends TrailingSlashOption,
TDefaultStructuralSharingOption extends boolean,
TRouterHistory extends RouterHistory,
TDehydrated extends Record<string, any>,
> = (
newOptions: RouterConstructorOptions<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory,
TDehydrated
>,
) => void
export type InvalidateFn<TRouter extends AnyRouter> = (opts?: {
filter?: (d: MakeRouteMatchUnion<TRouter>) => boolean
sync?: boolean
forcePending?: boolean
}) => Promise<void>
export type ParseLocationFn<TRouteTree extends AnyRoute> = (
locationToParse: HistoryLocation,
previousLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>,
) => ParsedLocation<FullSearchSchema<TRouteTree>>
export type GetMatchRoutesFn = (
pathname: string,
routePathname: string | undefined,
) => {
matchedRoutes: Array<AnyRoute>
routeParams: Record<string, string>
foundRoute: AnyRoute | undefined
}
export type EmitFn = (routerEvent: RouterEvent) => void
export type LoadFn = (opts?: { sync?: boolean }) => Promise<void>
export type CommitLocationFn = ({
viewTransition,
ignoreBlocker,
...next
}: ParsedLocation & CommitLocationOptions) => Promise<void>
export type StartTransitionFn = (fn: () => void) => void
export interface MatchRoutesFn {
(
pathname: string,
locationSearch?: AnySchema,
opts?: MatchRoutesOpts,
): Array<MakeRouteMatchUnion>
/**
* @deprecated use the following signature instead
*/
(next: ParsedLocation, opts?: MatchRoutesOpts): Array<AnyRouteMatch>
(
pathnameOrNext: string | ParsedLocation,
locationSearchOrOpts?: AnySchema | MatchRoutesOpts,
opts?: MatchRoutesOpts,
): Array<AnyRouteMatch>
}
export type GetMatchFn = (matchId: string) => AnyRouteMatch | undefined
export type UpdateMatchFn = (
id: string,
updater: (match: AnyRouteMatch) => AnyRouteMatch,
) => void
export type LoadRouteChunkFn = (route: AnyRoute) => Promise<Array<void>>
export type ResolveRedirect = (err: AnyRedirect) => ResolvedRedirect
export type ClearCacheFn<TRouter extends AnyRouter> = (opts?: {
filter?: (d: MakeRouteMatchUnion<TRouter>) => boolean
}) => void
export interface ServerSsr {
injectedHtml: Array<InjectedHtmlEntry>
injectHtml: (getHtml: () => string | Promise<string>) => Promise<void>
injectScript: (
getScript: () => string | Promise<string>,
opts?: { logScript?: boolean },
) => Promise<void>
isDehydrated: () => boolean
onRenderFinished: (listener: () => void) => void
dehydrate: () => Promise<void>
}
export type AnyRouterWithContext<TContext> = RouterCore<
AnyRouteWithContext<TContext>,
any,
any,
any,
any
>
export type AnyRouter = RouterCore<any, any, any, any, any>
export interface ViewTransitionOptions {
types:
| Array<string>
| ((locationChangeInfo: {
fromLocation?: ParsedLocation
toLocation: ParsedLocation
pathChanged: boolean
hrefChanged: boolean
hashChanged: boolean
}) => Array<string>)
}
export function defaultSerializeError(err: unknown) {
if (err instanceof Error) {
const obj = {
name: err.name,
message: err.message,
}
if (process.env.NODE_ENV === 'development') {
;(obj as any).stack = err.stack
}
return obj
}
return {
data: err,
}
}
export type TrailingSlashOption = 'always' | 'never' | 'preserve'
export function getLocationChangeInfo(routerState: {
resolvedLocation?: ParsedLocation
location: ParsedLocation
}) {
const fromLocation = routerState.resolvedLocation
const toLocation = routerState.location
const pathChanged = fromLocation?.pathname !== toLocation.pathname
const hrefChanged = fromLocation?.href !== toLocation.href
const hashChanged = fromLocation?.hash !== toLocation.hash
return { fromLocation, toLocation, pathChanged, hrefChanged, hashChanged }
}
export type CreateRouterFn = <
TRouteTree extends AnyRoute,
TTrailingSlashOption extends TrailingSlashOption = 'never',
TDefaultStructuralSharingOption extends boolean = false,
TRouterHistory extends RouterHistory = RouterHistory,
TDehydrated extends Record<string, any> = Record<string, any>,
>(
options: undefined extends number
? 'strictNullChecks must be enabled in tsconfig.json'
: RouterConstructorOptions<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory,
TDehydrated
>,
) => RouterCore<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory,
TDehydrated
>
export class RouterCore<
in out TRouteTree extends AnyRoute,
in out TTrailingSlashOption extends TrailingSlashOption,
in out TDefaultStructuralSharingOption extends boolean,
in out TRouterHistory extends RouterHistory = RouterHistory,
in out TDehydrated extends Record<string, any> = Record<string, any>,
> {
// Option-independent properties
tempLocationKey: string | undefined = `${Math.round(
Math.random() * 10000000,
)}`
resetNextScroll = true
shouldViewTransition?: boolean | ViewTransitionOptions = undefined
isViewTransitionTypesSupported?: boolean = undefined
subscribers = new Set<RouterListener<RouterEvent>>()
viewTransitionPromise?: ControlledPromise<true>
isScrollRestoring = false
isScrollRestorationSetup = false
// Must build in constructor
__store!: Store<RouterState<TRouteTree>>
options!: PickAsRequired<
RouterOptions<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory,
TDehydrated
>,
'stringifySearch' | 'parseSearch' | 'context'
>
history!: TRouterHistory
latestLocation!: ParsedLocation<FullSearchSchema<TRouteTree>>
basepath!: string
routeTree!: TRouteTree
routesById!: RoutesById<TRouteTree>
routesByPath!: RoutesByPath<TRouteTree>
flatRoutes!: Array<AnyRoute>
isServer!: boolean
pathParamsDecodeCharMap?: Map<string, string>
/**
* @deprecated Use the `createRouter` function instead
*/
constructor(
options: RouterConstructorOptions<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory,
TDehydrated
>,
) {
this.update({
defaultPreloadDelay: 50,
defaultPendingMs: 1000,
defaultPendingMinMs: 500,
context: undefined!,
...options,
caseSensitive: options.caseSensitive ?? false,
notFoundMode: options.notFoundMode ?? 'fuzzy',
stringifySearch: options.stringifySearch ?? defaultStringifySearch,
parseSearch: options.parseSearch ?? defaultParseSearch,
})
if (typeof document !== 'undefined') {
self.__TSR_ROUTER__ = this
}
}
// These are default implementations that can optionally be overridden
// by the router provider once rendered. We provide these so that the
// router can be used in a non-react environment if necessary
startTransition: StartTransitionFn = (fn) => fn()
isShell() {
return !!this.options.isShell
}
isPrerendering() {
return !!this.options.isPrerendering
}
update: UpdateFn<
TRouteTree,
TTrailingSlashOption,
TDefaultStructuralSharingOption,
TRouterHistory,
TDehydrated
> = (newOptions) => {
if (newOptions.notFoundRoute) {
console.warn(
'The notFoundRoute API is deprecated and will be removed in the next major version. See https://tanstack.com/router/v1/docs/framework/react/guide/not-found-errors#migrating-from-notfoundroute for more info.',
)
}
const previousOptions = this.options
this.options = {
...this.options,
...newOptions,
}
this.isServer = this.options.isServer ?? typeof document === 'undefined'
this.pathParamsDecodeCharMap = this.options.pathParamsAllowedCharacters
? new Map(
this.options.pathParamsAllowedCharacters.map((char) => [
encodeURIComponent(char),
char,
]),
)
: undefined
if (
!this.basepath ||
(newOptions.basepath && newOptions.basepath !== previousOptions.basepath)
) {
if (
newOptions.basepath === undefined ||
newOptions.basepath === '' ||
newOptions.basepath === '/'
) {
this.basepath = '/'
} else {
this.basepath = `/${trimPath(newOptions.basepath)}`
}
}
if (
!this.history ||
(this.options.history && this.options.history !== this.history)
) {
this.history =
this.options.history ??
((this.isServer
? createMemoryHistory({
initialEntries: [this.basepath || '/'],
})
: createBrowserHistory()) as TRouterHistory)
this.updateLatestLocation()
}
if (this.options.routeTree !== this.routeTree) {
this.routeTree = this.options.routeTree as TRouteTree
this.buildRouteTree()
}
if (!this.__store) {
this.__store = new Store(getInitialRouterState(this.latestLocation), {
onUpdate: () => {
this.__store.state = {
...this.state,
cachedMatches: this.state.cachedMatches.filter(
(d) => !['redirected'].includes(d.status),
),
}
},
})
setupScrollRestoration(this)
}
if (
typeof window !== 'undefined' &&
'CSS' in window &&
typeof window.CSS?.supports === 'function'
) {
this.isViewTransitionTypesSupported = window.CSS.supports(
'selector(:active-view-transition-type(a)',
)
}
}
get state() {
return this.__store.state
}
updateLatestLocation = () => {
this.latestLocation = this.parseLocation(
this.history.location,
this.latestLocation,
)
}
buildRouteTree = () => {
const { routesById, routesByPath, flatRoutes } = processRouteTree({
routeTree: this.routeTree,
initRoute: (route, i) => {
route.init({
originalIndex: i,
})
},
})
this.routesById = routesById as RoutesById<TRouteTree>
this.routesByPath = routesByPath as RoutesByPath<TRouteTree>
this.flatRoutes = flatRoutes as Array<AnyRoute>
const notFoundRoute = this.options.notFoundRoute
if (notFoundRoute) {
notFoundRoute.init({
originalIndex: 99999999999,
})
this.routesById[notFoundRoute.id] = notFoundRoute
}
}
subscribe: SubscribeFn = (eventType, fn) => {
const listener: RouterListener<any> = {
eventType,
fn,
}
this.subscribers.add(listener)
return () => {
this.subscribers.delete(listener)
}
}
emit: EmitFn = (routerEvent) => {
this.subscribers.forEach((listener) => {
if (listener.eventType === routerEvent.type) {
listener.fn(routerEvent)
}
})
}
parseLocation: ParseLocationFn<TRouteTree> = (
locationToParse,
previousLocation,
) => {
const parse = ({
pathname,
search,
hash,
state,
}: HistoryLocation): ParsedLocation<FullSearchSchema<TRouteTree>> => {
const parsedSearch = this.options.parseSearch(search)
const searchStr = this.options.stringifySearch(parsedSearch)
return {
pathname,
searchStr,
search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any,
hash: hash.split('#').reverse()[0] ?? '',
href: `${pathname}${searchStr}${hash}`,
state: replaceEqualDeep(previousLocation?.state, state),
}
}
const location = parse(locationToParse)
const { __tempLocation, __tempKey } = location.state
if (__tempLocation && (!__tempKey || __tempKey === this.tempLocationKey)) {
// Sync up the location keys
const parsedTempLocation = parse(__tempLocation) as any
parsedTempLocation.state.key = location.state.key // TODO: Remove in v2 - use __TSR_key instead
parsedTempLocation.state.__TSR_key = location.state.__TSR_key
delete parsedTempLocation.state.__tempLocation
return {
...parsedTempLocation,
maskedLocation: location,
}
}
return location
}
resolvePathWithBase = (from: string, path: string) => {
const resolvedPath = resolvePath({
basepath: this.basepath,
base: from,
to: cleanPath(path),
trailingSlash: this.options.trailingSlash,
caseSensitive: this.options.caseSensitive,
parseCache: this.parsePathnameCache,
})
return resolvedPath
}
get looseRoutesById() {
return this.routesById as Record<string, AnyRoute>
}
matchRoutes: MatchRoutesFn = (
pathnameOrNext: string | ParsedLocation,
locationSearchOrOpts?: AnySchema | MatchRoutesOpts,
opts?: MatchRoutesOpts,
) => {
if (typeof pathnameOrNext === 'string') {
return this.matchRoutesInternal(
{
pathname: pathnameOrNext,
search: locationSearchOrOpts,
} as ParsedLocation,
opts,
)
}
return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts)
}
private matchRoutesInternal(
next: ParsedLocation,
opts?: MatchRoutesOpts,
): Array<AnyRouteMatch> {
const { foundRoute, matchedRoutes, routeParams } = this.getMatchedRoutes(
next.pathname,
opts?.dest?.to as string,
)
let isGlobalNotFound = false
// Check to see if the route needs a 404 entry
if (
// If we found a route, and it's not an index route and we have left over path
foundRoute
? foundRoute.path !== '/' && routeParams['**']
: // Or if we didn't find a route and we have left over path
trimPathRight(next.pathname)
) {
// If the user has defined an (old) 404 route, use it
if (this.options.notFoundRoute) {
matchedRoutes.push(this.options.notFoundRoute)
} else {
// If there is no routes found during path matching
isGlobalNotFound = true
}
}
const globalNotFoundRouteId = (() => {
if (!isGlobalNotFound) {
return undefined
}
if (this.options.notFoundMode !== 'root') {
for (let i = matchedRoutes.length - 1; i >= 0; i--) {
const route = matchedRoutes[i]!
if (route.children) {
return route.id
}
}
}
return rootRouteId
})()
const matches: Array<AnyRouteMatch> = []
const getParentContext = (parentMatch?: AnyRouteMatch) => {
const parentMatchId = parentMatch?.id
const parentContext = !parentMatchId
? ((this.options.context as any) ?? undefined)
: (parentMatch.context ?? this.options.context ?? undefined)
return parentContext
}
matchedRoutes.forEach((route, index) => {
// Take each matched route and resolve + validate its search params
// This has to happen serially because each route's search params
// can depend on the parent route's search params
// It must also happen before we create the match so that we can
// pass the search params to the route's potential key function
// which is used to uniquely identify the route match in state
const parentMatch = matches[index - 1]
const [preMatchSearch, strictMatchSearch, searchError]: [
Record<string, any>,
Record<string, any>,
any,
] = (() => {
// Validate the search params and stabilize them
const parentSearch = parentMatch?.search ?? next.search
const parentStrictSearch = parentMatch?._strictSearch ?? undefined
try {
const strictSearch =
validateSearch(route.options.validateSearch, { ...parentSearch }) ??
undefined
return [
{
...parentSearch,
...strictSearch,
},
{ ...parentStrictSearch, ...strictSearch },
undefined,
]
} catch (err: any) {
let searchParamError = err
if (!(err instanceof SearchParamError)) {
searchParamError = new SearchParamError(err.message, {
cause: err,
})
}
if (opts?.throwOnError) {
throw searchParamError
}
return [parentSearch, {}, searchParamError]
}
})()
// This is where we need to call route.options.loaderDeps() to get any additional
// deps that the route's loader function might need to run. We need to do this
// before we create the match so that we can pass the deps to the route's
// potential key function which is used to uniquely identify the route match in state
const loaderDeps =
route.options.loaderDeps?.({
search: preMatchSearch,
}) ?? ''
const loaderDepsHash = loaderDeps ? JSON.stringify(loaderDeps) : ''
const { interpolatedPath, usedParams } = interpolatePath({
path: route.fullPath,
params: routeParams,
decodeCharMap: this.pathParamsDecodeCharMap,
})
// Waste not, want not. If we already have a match for this route,
// reuse it. This is important for layout routes, which might stick
// around between navigation actions that only change leaf routes.
// Existing matches are matches that are already loaded along with
// pending matches that are still loading
const matchId =
interpolatePath({
path: route.id,
params: routeParams,
leaveWildcards: true,
decodeCharMap: this.pathParamsDecodeCharMap,
parseCache: this.parsePathnameCache,
}).interpolatedPath + loaderDepsHash
const existingMatch = this.getMatch(matchId)
const previousMatch = this.state.matches.find(
(d) => d.routeId === route.id,
)
const strictParams = existingMatch?._strictParams ?? usedParams
let paramsError: PathParamError | undefined = undefined
if (!existingMatch) {
const strictParseParams =
route.options.params?.parse ?? route.options.parseParams
if (strictParseParams) {
try {
Object.assign(
strictParams,
strictParseParams(strictParams as Record<string, string>),
)
} catch (err: any) {
paramsError = new PathParamError(err.message, {
cause: err,
})
if (opts?.throwOnError) {
throw paramsError
}
}
}
}
Object.assign(routeParams, strictParams)
const cause = previousMatch ? 'stay' : 'enter'
let match: AnyRouteMatch
if (existingMatch) {
match = {
...existingMatch,
cause,
params: previousMatch
? replaceEqualDeep(previousMatch.params, routeParams)
: routeParams,
_strictParams: strictParams,
search: previousMatch
? replaceEqualDeep(previousMatch.search, preMatchSearch)
: replaceEqualDeep(existingMatch.search, preMatchSearch),
_strictSearch: strictMatchSearch,
}
} else {
const status =
route.options.loader ||
route.options.beforeLoad ||
route.lazyFn ||
routeNeedsPreload(route)
? 'pending'
: 'success'
match = {
id: matchId,
index,
routeId: route.id,
params: previousMatch
? replaceEqualDeep(previousMatch.params, routeParams)
: routeParams,
_strictParams: strictParams,
pathname: joinPaths([this.basepath, interpolatedPath]),
updatedAt: Date.now(),
search: previousMatch
? replaceEqualDeep(previousMatch.search, preMatchSearch)
: preMatchSearch,
_strictSearch: strictMatchSearch,
searchError: undefined,
status,
isFetching: false,
error: undefined,
paramsError,
__routeContext: undefined,
_nonReactive: {
loadPromise: createControlledPromise(),
},
__beforeLoadContext: undefined,
context: {},
abortController: new AbortController(),
fetchCount: 0,
cause,
loaderDeps: previousMatch
? replaceEqualDeep(previousMatch.loaderDeps, loaderDeps)
: loaderDeps,
invalid: false,
preload: false,
links: undefined,
scripts: undefined,
headScripts: undefined,
meta: undefined,
staticData: route.options.staticData || {},
fullPath: route.fullPath,
}
}
if (!opts?.preload) {
// If we have a global not found, mark the right match as global not found
match.globalNotFound = globalNotFoundRouteId === route.id
}
// update the searchError if there is one
match.searchError = searchError
const parentContext = getParentContext(parentMatch)
match.context = {
...parentContext,
...match.__routeContext,
...match.__beforeLoadContext,
}
matches.push(match)
})
matches.forEach((match, index) => {
const route = this.looseRoutesById[match.routeId]!
const existingMatch = this.getMatch(match.id)
// only execute `context` if we are not calling from router.buildLocation
if (!existingMatch && opts?._buildLocation !== true) {
const parentMatch = matches[index - 1]
const parentContext = getParentContext(parentMatch)
// Update the match's context
if (route.options.context) {
const contextFnContext: RouteContextOptions<any, any, any, any> = {
deps: match.loaderDeps,
params: match.params,
context: parentContext ?? {},
location: next,
navigate: (opts: any) =>
this.navigate({ ...opts, _fromLocation: next }),
buildLocation: this.buildLocation,
cause: match.cause,
abortController: match.abortController,
preload: !!match.preload,
matches,
}
// Get the route context
match.__routeContext =
route.options.context(contextFnContext) ?? undefined
}
match.context = {
...parentContext,
...match.__routeContext,
...match.__beforeLoadContext,
}
}
})
return matches
}
/** a cache for `parsePathname` */
private parsePathnameCache: ParsePathnameCache = createLRUCache(1000)
getMatchedRoutes: GetMatchRoutesFn = (
pathname: string,
routePathname: string | undefined,
) => {
return getMatchedRoutes({
pathname,
routePathname,
basepath: this.basepath,
caseSensitive: this.options.caseSensitive,
routesByPath: this.routesByPath,
routesById: this.routesById,
flatRoutes: this.flatRoutes,
parseCache: this.parsePathnameCache,
})
}
cancelMatch = (id: string) => {
const match = this.getMatch(id)
if (!match) return
match.abortController.abort()
clearTimeout(match._nonReactive.pendingTimeout)
match._nonReactive.pendingTimeout = undefined
}
cancelMatches = () => {
this.state.pendingMatches?.forEach((match) => {
this.cancelMatch(match.id)
})
}
buildLocation: BuildLocationFn = (opts) => {
const build = (
dest: BuildNextOptions & {
unmaskOnReload?: boolean
} = {},
): ParsedLocation => {
// We allow the caller to override the current location
const currentLocation = dest._fromLocation || this.latestLocation
const allCurrentLocationMatches = this.matchRoutes(currentLocation, {
_buildLocation: true,
})
// Now let's find the starting pathname
// This should default to the current location if no from is provided
const lastMatch = last(allCurrentLocationMatches)!
// check that from path exists in the current route tree
// do this check only on navigations during test or development
if (
dest.from &&
process.env.NODE_ENV !== 'production' &&
dest._isNavigate
) {
const allFromMatches = this.getMatchedRoutes(
dest.from,
undefined,
).matchedRoutes
const matchedFrom = findLast(allCurrentLocationMatches, (d) => {
return comparePaths(d.fullPath, dest.from!)
})
const matchedCurrent = findLast(allFromMatches, (d) => {
return comparePaths(d.fullPath, lastMatch.fullPath)
})
// for from to be invalid it shouldn't just be unmatched to currentLocation
// but the currentLocation should also be unmatched to from
if (!matchedFrom && !matchedCurrent) {
console.warn(`Could not find match for from: ${dest.from}`)
}
}
const defaultedFromPath =
dest.unsafeRelative === 'path'
? currentLocation.pathname
: (dest.from ?? lastMatch.fullPath)
// ensure this includes the basePath if set
const fromPath = this.resolvePathWithBase(defaultedFromPath, '.')
// From search should always use the current location
const fromSearch = lastMatch.search
// Same with params. It can't hurt to provide as many as possible
const fromParams = { ...lastMatch.params }
// Resolve the next to
// ensure this includes the basePath if set
const nextTo = dest.to
? this.resolvePathWithBase(fromPath, `${dest.to}`)
: this.resolvePathWithBase(fromPath, '.')
// Resolve the next params
const nextParams =
dest.params === false || dest.params === null
? {}
: (dest.params ?? true) === true
? fromParams
: Object.assign(
fromParams,
functionalUpdate(dest.params as any, fromParams),
)
// Interpolate the path first to get the actual resolved path, then match against that
const interpolatedNextTo = interpolatePath({
path: nextTo,
params: nextParams,
parseCache: this.parsePathnameCache,
}).interpolatedPath
const destRoutes = this.matchRoutes(interpolatedNextTo, undefined, {
_buildLocation: true,
}).map((d) => this.looseRoutesById[d.routeId]!)
// If there are any params, we need to stringify them
if (Object.keys(nextParams).length > 0) {
for (const route of destRoutes) {
const fn =
route.options.params?.stringify ?? route.options.stringifyParams
if (fn) {
Object.assign(nextParams, fn(nextParams))
}
}
}
const nextPathname = interpolatePath({
// Use the original template path for interpolation
// This preserves the original parameter syntax including optional parameters
path: nextTo,
params: nextParams,
leaveWildcards: false,
leaveParams: opts.leaveParams,
decodeCharMap: this.pathParamsDecodeCharMap,
parseCache: this.parsePathnameCache,
}).interpolatedPath
// Resolve the next search
let nextSearch = fromSearch
if (opts._includeValidateSearch && this.options.search?.strict) {
const validatedSearch = {}
destRoutes.forEach((route) => {
if (route.options.validateSearch) {
try {
Object.assign(
validatedSearch,
validateSearch(route.options.validateSearch, {
...validatedSearch,
...nextSearch,
}),
)
} catch {
// ignore errors here because they are already handled in matchRoutes
}
}
})
nextSearch = validatedSearch
}
nextSearch = applySearchMiddleware({
search: nextSearch,
dest,
destRoutes,
_includeValidateSearch: opts._includeValidateSearch,
})
// Replace the equal deep
nextSearch = replaceEqualDeep(fromSearch, nextSearch)
// Stringify the next search
const searchStr = this.options.stringifySearch(nextSearch)
// Resolve the next hash
const hash =
dest.hash === true
? currentLocation.hash
: dest.hash
? functionalUpdate(dest.hash, currentLocation.hash)
: undefined
// Resolve the next hash string
const hashStr = hash ? `#${hash}` : ''
// Reso