import { Store, batch } from '@tanstack/store' import { createBrowserHistory, createMemoryHistory, parseHref, } from '@tanstack/history' import invariant from 'tiny-invariant' import { createControlledPromise, deepEqual, functionalUpdate, last, pick, replaceEqualDeep, } from './utils' import { cleanPath, interpolatePath, joinPaths, matchPathname, parsePathname, resolvePath, trimPath, trimPathLeft, trimPathRight, } from './path' import { isNotFound } from './not-found' import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' import { isRedirect, isResolvedRedirect } from './redirect' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' import type { HistoryLocation, HistoryState, ParsedHistoryState, RouterHistory, } from '@tanstack/history' import type { ControlledPromise, NoInfer, NonNullableUpdater, PickAsRequired, Updater, } from './utils' import type { ParsedLocation } from './location' import type { DeferredPromiseState } from './defer' import type { AnyContext, AnyRoute, AnyRouteWithContext, BeforeLoadContextOptions, LoaderFnContext, MakeRemountDepsOptionsUnion, RouteContextOptions, 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 { StartSerializer } from './serializer' import type { AnySchema, AnyValidator } from './validators' import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link' import type { NotFoundError } from './not-found' declare global { interface Window { __TSR_ROUTER__?: AnyRouter } } export type ControllablePromise = Promise & { resolve: (value: T) => void reject: (value?: any) => void } export type InjectedHtmlEntry = Promise export interface Register { // router: Router } export type RegisteredRouter = Register extends { router: infer TRouter extends AnyRouter } ? TRouter : AnyRouter export type DefaultRemountDepsFn = ( opts: MakeRemountDepsOptionsUnion, ) => 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 = Record, > 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 ``. * * 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 `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 /** * 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?: () => TDehydrated /** * A function that will be called when the router is hydrated. * * 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#hydrate-method) * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/external-data-loading#critical-dehydrationhydration) */ hydrate?: (dehydrated: TDehydrated) => 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> /** * 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 defaultSsr?: boolean 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 /** * 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 } export interface RouterState< in out TRouteTree extends AnyRoute = AnyRoute, in out TRouteMatch = MakeRouteMatchUnion, > { status: 'pending' | 'idle' loadedAt: number isLoading: boolean isTransitioning: boolean matches: Array pendingMatches?: Array cachedMatches: Array location: ParsedLocation> resolvedLocation?: ParsedLocation> statusCode: number redirect?: ResolvedRedirect } export interface BuildNextOptions { to?: string | number | null params?: true | Updater search?: true | Updater hash?: true | Updater state?: true | NonNullableUpdater mask?: { to?: string | number | null params?: true | Updater search?: true | Updater hash?: true | Updater state?: true | NonNullableUpdater unmaskOnReload?: boolean } from?: string _fromLocation?: ParsedLocation href?: string } type NavigationEventInfo = { fromLocation?: ParsedLocation toLocation: ParsedLocation pathChanged: boolean hrefChanged: boolean hashChanged: boolean } export type RouterEvents = { onBeforeNavigate: { type: 'onBeforeNavigate' } & NavigationEventInfo onBeforeLoad: { type: 'onBeforeLoad' } & NavigationEventInfo onLoad: { type: 'onLoad' } & NavigationEventInfo onResolved: { type: 'onResolved' } & NavigationEventInfo onBeforeRouteMount: { type: 'onBeforeRouteMount' } & NavigationEventInfo onInjectedHtml: { type: 'onInjectedHtml' promise: Promise } onRendered: { type: 'onRendered' } & NavigationEventInfo } export type RouterEvent = RouterEvents[keyof RouterEvents] export type ListenerFn = (event: TEvent) => void export type RouterListener = { eventType: TRouterEvent['type'] fn: ListenerFn } export interface MatchRoutesOpts { preload?: boolean throwOnError?: boolean _buildLocation?: boolean dest?: BuildNextOptions } export type InferRouterContext = TRouteTree['types']['routerContext'] export type RouterContextOptions = AnyContext extends InferRouterContext ? { context?: InferRouterContext } : { context: InferRouterContext } export type RouterConstructorOptions< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, TDefaultStructuralSharingOption extends boolean, TRouterHistory extends RouterHistory, TDehydrated extends Record, > = Omit< RouterOptions< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory, TDehydrated >, 'context' > & RouterContextOptions export interface RouterErrorSerializer { serialize: (err: unknown) => TSerializedError deserialize: (err: TSerializedError) => unknown } export interface MatchedRoutesResult { matchedRoutes: Array routeParams: Record } export type PreloadRouteFn< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, TDefaultStructuralSharingOption extends boolean, TRouterHistory extends RouterHistory, > = < TFrom extends RoutePaths | string = string, TTo extends string | undefined = undefined, TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( opts: NavigateOptions< RouterCore< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory >, TFrom, TTo, TMaskFrom, TMaskTo >, ) => Promise | undefined> export type MatchRouteFn< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, TDefaultStructuralSharingOption extends boolean, TRouterHistory extends RouterHistory, > = < TFrom extends RoutePaths = '/', TTo extends string | undefined = undefined, TResolved = ResolveRelativePath>, >( location: ToOptions< RouterCore< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory >, TFrom, TTo >, opts?: MatchRouteOptions, ) => false | RouteById['types']['allParams'] export type UpdateFn< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, TDefaultStructuralSharingOption extends boolean, TRouterHistory extends RouterHistory, TDehydrated extends Record, > = ( newOptions: RouterConstructorOptions< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory, TDehydrated >, ) => void export type InvalidateFn = (opts?: { filter?: (d: MakeRouteMatchUnion) => boolean sync?: boolean }) => Promise export type ParseLocationFn = ( previousLocation?: ParsedLocation>, locationToParse?: HistoryLocation, ) => ParsedLocation> export type GetMatchRoutesFn = ( next: ParsedLocation, dest?: BuildNextOptions, ) => { matchedRoutes: Array routeParams: Record foundRoute: AnyRoute | undefined } export type EmitFn = (routerEvent: RouterEvent) => void export type LoadFn = (opts?: { sync?: boolean }) => Promise export type CommitLocationFn = ({ viewTransition, ignoreBlocker, ...next }: ParsedLocation & CommitLocationOptions) => Promise export type StartTransitionFn = (fn: () => void) => void export type SubscribeFn = ( eventType: TType, fn: ListenerFn, ) => () => void export interface MatchRoutesFn { ( pathname: string, locationSearch: AnySchema, opts?: MatchRoutesOpts, ): Array (next: ParsedLocation, opts?: MatchRoutesOpts): Array ( pathnameOrNext: string | ParsedLocation, locationSearchOrOpts?: AnySchema | MatchRoutesOpts, opts?: MatchRoutesOpts, ): Array } export type GetMatchFn = (matchId: string) => AnyRouteMatch | undefined export type UpdateMatchFn = ( id: string, updater: (match: AnyRouteMatch) => AnyRouteMatch, ) => AnyRouteMatch export type LoadRouteChunkFn = (route: AnyRoute) => Promise> export type ResolveRedirect = (err: AnyRedirect) => ResolvedRedirect export type ClearCacheFn = (opts?: { filter?: (d: MakeRouteMatchUnion) => boolean }) => void export interface ServerSrr { injectedHtml: Array injectHtml: (getHtml: () => string | Promise) => Promise injectScript: ( getScript: () => string | Promise, opts?: { logScript?: boolean }, ) => Promise streamValue: (key: string, value: any) => void streamedKeys: Set onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any } export type AnyRouterWithContext = RouterCore< AnyRouteWithContext, any, any, any, any > export type AnyRouter = RouterCore export interface ViewTransitionOptions { types: Array } 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 interface ExtractedBaseEntry { dataType: '__beforeLoadContext' | 'loaderData' type: string path: Array id: number matchIndex: number } export interface ExtractedStream extends ExtractedBaseEntry { type: 'stream' streamState: StreamState } export interface ExtractedPromise extends ExtractedBaseEntry { type: 'promise' promiseState: DeferredPromiseState } export type ExtractedEntry = ExtractedStream | ExtractedPromise export type StreamState = { promises: Array> } 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 = Record, >( 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 = Record, > { // Option-independent properties tempLocationKey: string | undefined = `${Math.round( Math.random() * 10000000, )}` resetNextScroll = true shouldViewTransition?: boolean | ViewTransitionOptions = undefined isViewTransitionTypesSupported?: boolean = undefined subscribers = new Set>() viewTransitionPromise?: ControlledPromise isScrollRestoring = false isScrollRestorationSetup = false // Must build in constructor __store!: Store> options!: PickAsRequired< RouterOptions< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory, TDehydrated >, 'stringifySearch' | 'parseSearch' | 'context' > history!: TRouterHistory latestLocation!: ParsedLocation> basepath!: string routeTree!: TRouteTree routesById!: RoutesById routesByPath!: RoutesByPath flatRoutes!: Array isServer!: boolean pathParamsDecodeCharMap?: Map /** * @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') { ;(window as any).__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() 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 ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition !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.latestLocation = this.parseLocation() } if (this.options.routeTree !== this.routeTree) { this.routeTree = this.options.routeTree as TRouteTree this.buildRouteTree() } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 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 && // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition typeof window.CSS?.supports === 'function' ) { this.isViewTransitionTypesSupported = window.CSS.supports( 'selector(:active-view-transition-type(a)', ) } } get state() { return this.__store.state } buildRouteTree = () => { this.routesById = {} as RoutesById this.routesByPath = {} as RoutesByPath const notFoundRoute = this.options.notFoundRoute if (notFoundRoute) { notFoundRoute.init({ originalIndex: 99999999999, defaultSsr: this.options.defaultSsr, }) ;(this.routesById as any)[notFoundRoute.id] = notFoundRoute } const recurseRoutes = (childRoutes: Array) => { childRoutes.forEach((childRoute, i) => { childRoute.init({ originalIndex: i, defaultSsr: this.options.defaultSsr, }) const existingRoute = (this.routesById as any)[childRoute.id] invariant( !existingRoute, `Duplicate routes found with id: ${String(childRoute.id)}`, ) ;(this.routesById as any)[childRoute.id] = childRoute if (!childRoute.isRoot && childRoute.path) { const trimmedFullPath = trimPathRight(childRoute.fullPath) if ( !(this.routesByPath as any)[trimmedFullPath] || childRoute.fullPath.endsWith('/') ) { ;(this.routesByPath as any)[trimmedFullPath] = childRoute } } const children = childRoute.children if (children?.length) { recurseRoutes(children) } }) } recurseRoutes([this.routeTree]) const scoredRoutes: Array<{ child: AnyRoute trimmed: string parsed: ReturnType index: number scores: Array }> = [] const routes: Array = Object.values(this.routesById) routes.forEach((d, i) => { if (d.isRoot || !d.path) { return } const trimmed = trimPathLeft(d.fullPath) const parsed = parsePathname(trimmed) while (parsed.length > 1 && parsed[0]?.value === '/') { parsed.shift() } const scores = parsed.map((segment) => { if (segment.value === '/') { return 0.75 } if (segment.type === 'param') { return 0.5 } if (segment.type === 'wildcard') { return 0.25 } return 1 }) scoredRoutes.push({ child: d, trimmed, parsed, index: i, scores }) }) this.flatRoutes = scoredRoutes .sort((a, b) => { const minLength = Math.min(a.scores.length, b.scores.length) // Sort by min available score for (let i = 0; i < minLength; i++) { if (a.scores[i] !== b.scores[i]) { return b.scores[i]! - a.scores[i]! } } // Sort by length of score if (a.scores.length !== b.scores.length) { return b.scores.length - a.scores.length } // Sort by min available parsed value for (let i = 0; i < minLength; i++) { if (a.parsed[i]!.value !== b.parsed[i]!.value) { return a.parsed[i]!.value > b.parsed[i]!.value ? 1 : -1 } } // Sort by original index return a.index - b.index }) .map((d, i) => { d.child.rank = i return d.child }) } subscribe: SubscribeFn = (eventType, fn) => { const listener: RouterListener = { 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 = ( previousLocation, locationToParse, ) => { const parse = ({ pathname, search, hash, state, }: HistoryLocation): ParsedLocation> => { 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 ?? this.history.location) 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 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, }) return resolvedPath } get looseRoutesById() { return this.routesById as Record } /** @deprecated use the following signature instead ```ts matchRoutes ( next: ParsedLocation, opts?: { preload?: boolean; throwOnError?: boolean }, ): Array; ``` */ matchRoutes: MatchRoutesFn = ( pathnameOrNext: string | ParsedLocation, locationSearchOrOpts?: AnySchema | MatchRoutesOpts, opts?: MatchRoutesOpts, ) => { if (typeof pathnameOrNext === 'string') { return this.matchRoutesInternal( { pathname: pathnameOrNext, search: locationSearchOrOpts, } as ParsedLocation, opts, ) } else { return this.matchRoutesInternal(pathnameOrNext, locationSearchOrOpts) } } private matchRoutesInternal( next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { const { foundRoute, matchedRoutes, routeParams } = this.getMatchedRoutes( next, opts?.dest, ) 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 parseErrors = matchedRoutes.map((route) => { let parsedParamsError const parseParams = route.options.params?.parse ?? route.options.parseParams if (parseParams) { try { const parsedParams = parseParams(routeParams) // Add the parsed params to the accumulated params bag Object.assign(routeParams, parsedParams) } catch (err: any) { parsedParamsError = new PathParamError(err.message, { cause: err, }) if (opts?.throwOnError) { throw parsedParamsError } return parsedParamsError } } return }) const matches: Array = [] const getParentContext = (parentMatch?: AnyRouteMatch) => { const parentMatchId = parentMatch?.id const parentContext = !parentMatchId ? ((this.options.context as any) ?? {}) : (parentMatch.context ?? this.options.context ?? {}) 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, Record, any, ] = (() => { // Validate the search params and stabilize them const parentSearch = parentMatch?.search ?? next.search const parentStrictSearch = parentMatch?._strictSearch ?? {} try { const strictSearch = validateSearch(route.options.validateSearch, { ...parentSearch }) ?? {} 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 { usedParams, interpolatedPath } = interpolatePath({ path: route.fullPath, params: routeParams, decodeCharMap: this.pathParamsDecodeCharMap, }) const matchId = interpolatePath({ path: route.id, params: routeParams, leaveWildcards: true, decodeCharMap: this.pathParamsDecodeCharMap, }).interpolatedPath + loaderDepsHash // 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 existingMatch = this.getMatch(matchId) const previousMatch = this.state.matches.find( (d) => d.routeId === route.id, ) const cause = previousMatch ? 'stay' : 'enter' let match: AnyRouteMatch if (existingMatch) { match = { ...existingMatch, cause, params: previousMatch ? replaceEqualDeep(previousMatch.params, routeParams) : routeParams, _strictParams: usedParams, 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: usedParams, 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: parseErrors[index], __routeContext: {}, __beforeLoadContext: {}, 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 || {}, loadPromise: createControlledPromise(), 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 just building a location if (!existingMatch && opts?._buildLocation !== true) { const parentMatch = matches[index - 1] const parentContext = getParentContext(parentMatch) // Update the match's context const contextFnContext: RouteContextOptions = { 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) ?? {} match.context = { ...parentContext, ...match.__routeContext, ...match.__beforeLoadContext, } } // If it's already a success, update headers and head content // These may get updated again if the match is refreshed // due to being stale if (match.status === 'success') { match.headers = route.options.headers?.({ loaderData: match.loaderData, }) const assetContext = { matches, match, params: match.params, loaderData: match.loaderData, } const headFnContent = route.options.head?.(assetContext) match.links = headFnContent?.links match.headScripts = headFnContent?.scripts match.meta = headFnContent?.meta match.scripts = route.options.scripts?.(assetContext) } }) return matches } getMatchedRoutes: GetMatchRoutesFn = (next, dest) => { let routeParams: Record = {} const trimmedPath = trimPathRight(next.pathname) const getMatchedParams = (route: AnyRoute) => { const result = matchPathname(this.basepath, trimmedPath, { to: route.fullPath, caseSensitive: route.options.caseSensitive ?? this.options.caseSensitive, fuzzy: true, }) return result } let foundRoute: AnyRoute | undefined = dest?.to !== undefined ? this.routesByPath[dest.to!] : undefined if (foundRoute) { routeParams = getMatchedParams(foundRoute)! } else { foundRoute = this.flatRoutes.find((route) => { const matchedParams = getMatchedParams(route) if (matchedParams) { routeParams = matchedParams return true } return false }) } let routeCursor: AnyRoute = foundRoute || (this.routesById as any)[rootRouteId] const matchedRoutes: Array = [routeCursor] while (routeCursor.parentRoute) { routeCursor = routeCursor.parentRoute matchedRoutes.unshift(routeCursor) } return { matchedRoutes, routeParams, foundRoute } } cancelMatch = (id: string) => { const match = this.getMatch(id) if (!match) return match.abortController.abort() clearTimeout(match.pendingTimeout) } cancelMatches = () => { this.state.pendingMatches?.forEach((match) => { this.cancelMatch(match.id) }) } buildLocation: BuildLocationFn = (opts) => { const build = ( dest: BuildNextOptions & { unmaskOnReload?: boolean } = {}, matchedRoutesResult?: MatchedRoutesResult, ): ParsedLocation => { const fromMatches = dest._fromLocation ? this.matchRoutes(dest._fromLocation, { _buildLocation: true }) : this.state.matches const fromMatch = dest.from != null ? fromMatches.find((d) => matchPathname(this.basepath, trimPathRight(d.pathname), { to: dest.from, caseSensitive: false, fuzzy: false, }), ) : undefined const fromPath = fromMatch?.pathname || this.latestLocation.pathname invariant( dest.from == null || fromMatch != null, 'Could not find match for from: ' + dest.from, ) const fromSearch = this.state.pendingMatches?.length ? last(this.state.pendingMatches)?.search : last(fromMatches)?.search || this.latestLocation.search const stayingMatches = matchedRoutesResult?.matchedRoutes.filter((d) => fromMatches.find((e) => e.routeId === d.id), ) let pathname: string if (dest.to) { const resolvePathTo = fromMatch?.fullPath || last(fromMatches)?.fullPath || this.latestLocation.pathname pathname = this.resolvePathWithBase(resolvePathTo, `${dest.to}`) } else { const fromRouteByFromPathRouteId = this.routesById[ stayingMatches?.find((route) => { const interpolatedPath = interpolatePath({ path: route.fullPath, params: matchedRoutesResult?.routeParams ?? {}, decodeCharMap: this.pathParamsDecodeCharMap, }).interpolatedPath const pathname = joinPaths([this.basepath, interpolatedPath]) return pathname === fromPath })?.id as keyof this['routesById'] ] pathname = this.resolvePathWithBase( fromPath, fromRouteByFromPathRouteId?.to ?? fromPath, ) } const prevParams = { ...last(fromMatches)?.params } let nextParams = (dest.params ?? true) === true ? prevParams : { ...prevParams, ...functionalUpdate(dest.params as any, prevParams), } if (Object.keys(nextParams).length > 0) { matchedRoutesResult?.matchedRoutes .map((route) => { return ( route.options.params?.stringify ?? route.options.stringifyParams ) }) .filter(Boolean) .forEach((fn) => { nextParams = { ...nextParams!, ...fn!(nextParams) } }) } pathname = interpolatePath({ path: pathname, params: nextParams ?? {}, leaveWildcards: false, leaveParams: opts.leaveParams, decodeCharMap: this.pathParamsDecodeCharMap, }).interpolatedPath let search = fromSearch if (opts._includeValidateSearch && this.options.search?.strict) { let validatedSearch = {} matchedRoutesResult?.matchedRoutes.forEach((route) => { try { if (route.options.validateSearch) { validatedSearch = { ...validatedSearch, ...(validateSearch(route.options.validateSearch, { ...validatedSearch, ...search, }) ?? {}), } } } catch { // ignore errors here because they are already handled in matchRoutes } }) search = validatedSearch } const applyMiddlewares = (search: any) => { const allMiddlewares = matchedRoutesResult?.matchedRoutes.reduce( (acc, route) => { const middlewares: Array> = [] if ('search' in route.options) { if (route.options.search?.middlewares) { middlewares.push(...route.options.search.middlewares) } } // TODO remove preSearchFilters and postSearchFilters in v2 else if ( route.options.preSearchFilters || route.options.postSearchFilters ) { const legacyMiddleware: SearchMiddleware = ({ search, next, }) => { let nextSearch = search if ( 'preSearchFilters' in route.options && route.options.preSearchFilters ) { nextSearch = route.options.preSearchFilters.reduce( (prev, next) => next(prev), search, ) } const result = next(nextSearch) if ( 'postSearchFilters' in route.options && route.options.postSearchFilters ) { return route.options.postSearchFilters.reduce( (prev, next) => next(prev), result, ) } return result } middlewares.push(legacyMiddleware) } if (opts._includeValidateSearch && route.options.validateSearch) { const validate: SearchMiddleware = ({ search, next }) => { const result = next(search) try { const validatedSearch = { ...result, ...(validateSearch( route.options.validateSearch, result, ) ?? {}), } return validatedSearch } catch { // ignore errors here because they are already handled in matchRoutes return result } } middlewares.push(validate) } return acc.concat(middlewares) }, [] as Array>, ) ?? [] // the chain ends here since `next` is not called const final: SearchMiddleware = ({ search }) => { if (!dest.search) { return {} } if (dest.search === true) { return search } return functionalUpdate(dest.search, search) } allMiddlewares.push(final) const applyNext = (index: number, currentSearch: any): any => { // no more middlewares left, return the current search if (index >= allMiddlewares.length) { return currentSearch } const middleware = allMiddlewares[index]! const next = (newSearch: any): any => { return applyNext(index + 1, newSearch) } return middleware({ search: currentSearch, next }) } // Start applying middlewares return applyNext(0, search) } search = applyMiddlewares(search) search = replaceEqualDeep(fromSearch, search) const searchStr = this.options.stringifySearch(search) const hash = dest.hash === true ? this.latestLocation.hash : dest.hash ? functionalUpdate(dest.hash, this.latestLocation.hash) : undefined const hashStr = hash ? `#${hash}` : '' let nextState = dest.state === true ? this.latestLocation.state : dest.state ? functionalUpdate(dest.state, this.latestLocation.state) : {} nextState = replaceEqualDeep(this.latestLocation.state, nextState) return { pathname, search, searchStr, state: nextState as any, hash: hash ?? '', href: `${pathname}${searchStr}${hashStr}`, unmaskOnReload: dest.unmaskOnReload, } } const buildWithMatches = ( dest: BuildNextOptions = {}, maskedDest?: BuildNextOptions, ) => { const next = build(dest) let maskedNext = maskedDest ? build(maskedDest) : undefined if (!maskedNext) { let params = {} const foundMask = this.options.routeMasks?.find((d) => { const match = matchPathname(this.basepath, next.pathname, { to: d.from, caseSensitive: false, fuzzy: false, }) if (match) { params = match return true } return false }) if (foundMask) { const { from: _from, ...maskProps } = foundMask maskedDest = { ...pick(opts, ['from']), ...maskProps, params, } maskedNext = build(maskedDest) } } const nextMatches = this.getMatchedRoutes(next, dest) const final = build(dest, nextMatches) if (maskedNext) { const maskedMatches = this.getMatchedRoutes(maskedNext, maskedDest) const maskedFinal = build(maskedDest, maskedMatches) final.maskedLocation = maskedFinal } return final } if (opts.mask) { return buildWithMatches(opts, { ...pick(opts, ['from']), ...opts.mask, }) } return buildWithMatches(opts) } commitLocationPromise: undefined | ControlledPromise commitLocation: CommitLocationFn = ({ viewTransition, ignoreBlocker, ...next }) => { const isSameState = () => { // the following props are ignored but may still be provided when navigating, // temporarily add the previous values to the next state so they don't affect // the comparison const ignoredProps = [ 'key', '__TSR_index', '__hashScrollIntoViewOptions', ] as const ignoredProps.forEach((prop) => { ;(next.state as any)[prop] = this.latestLocation.state[prop] }) const isEqual = deepEqual(next.state, this.latestLocation.state) ignoredProps.forEach((prop) => { delete next.state[prop] }) return isEqual } const isSameUrl = this.latestLocation.href === next.href const previousCommitPromise = this.commitLocationPromise this.commitLocationPromise = createControlledPromise(() => { previousCommitPromise?.resolve() }) // Don't commit to history if nothing changed if (isSameUrl && isSameState()) { this.load() } else { // eslint-disable-next-line prefer-const let { maskedLocation, hashScrollIntoView, ...nextHistory } = next if (maskedLocation) { nextHistory = { ...maskedLocation, state: { ...maskedLocation.state, __tempKey: undefined, __tempLocation: { ...nextHistory, search: nextHistory.searchStr, state: { ...nextHistory.state, __tempKey: undefined!, __tempLocation: undefined!, key: undefined!, }, }, }, } if ( nextHistory.unmaskOnReload ?? this.options.unmaskOnReload ?? false ) { nextHistory.state.__tempKey = this.tempLocationKey } } nextHistory.state.__hashScrollIntoViewOptions = hashScrollIntoView ?? this.options.defaultHashScrollIntoView ?? true this.shouldViewTransition = viewTransition this.history[next.replace ? 'replace' : 'push']( nextHistory.href, nextHistory.state, { ignoreBlocker }, ) } this.resetNextScroll = next.resetScroll ?? true if (!this.history.subscribers.size) { this.load() } return this.commitLocationPromise } buildAndCommitLocation = ({ replace, resetScroll, hashScrollIntoView, viewTransition, ignoreBlocker, href, ...rest }: BuildNextOptions & CommitLocationOptions = {}) => { if (href) { const currentIndex = this.history.location.state.__TSR_index const parsed = parseHref(href, { __TSR_index: replace ? currentIndex : currentIndex + 1, }) rest.to = parsed.pathname rest.search = this.options.parseSearch(parsed.search) // remove the leading `#` from the hash rest.hash = parsed.hash.slice(1) } const location = this.buildLocation({ ...(rest as any), _includeValidateSearch: true, }) return this.commitLocation({ ...location, viewTransition, replace, resetScroll, hashScrollIntoView, ignoreBlocker, }) } navigate: NavigateFn = ({ to, reloadDocument, href, ...rest }) => { if (reloadDocument) { if (!href) { const location = this.buildLocation({ to, ...rest } as any) href = this.history.createHref(location.href) } if (rest.replace) { window.location.replace(href) } else { window.location.href = href } return } return this.buildAndCommitLocation({ ...rest, href, to: to as string, }) } latestLoadPromise: undefined | Promise load: LoadFn = async (opts?: { sync?: boolean }): Promise => { this.latestLocation = this.parseLocation(this.latestLocation) let redirect: ResolvedRedirect | undefined let notFound: NotFoundError | undefined let loadPromise: Promise // eslint-disable-next-line prefer-const loadPromise = new Promise((resolve) => { this.startTransition(async () => { try { const next = this.latestLocation const prevLocation = this.state.resolvedLocation // Cancel any pending matches this.cancelMatches() let pendingMatches!: Array batch(() => { // this call breaks a route context of destination route after a redirect // we should be fine not eagerly calling this since we call it later // this.clearExpiredCache() // Match the routes pendingMatches = this.matchRoutes(next) // Ingest the new matches this.__store.setState((s) => ({ ...s, status: 'pending', isLoading: true, location: next, pendingMatches, // If a cached moved to pendingMatches, remove it from cachedMatches cachedMatches: s.cachedMatches.filter((d) => { return !pendingMatches.find((e) => e.id === d.id) }), })) }) if (!this.state.redirect) { this.emit({ type: 'onBeforeNavigate', ...getLocationChangeInfo({ resolvedLocation: prevLocation, location: next, }), }) } this.emit({ type: 'onBeforeLoad', ...getLocationChangeInfo({ resolvedLocation: prevLocation, location: next, }), }) await this.loadMatches({ sync: opts?.sync, matches: pendingMatches, location: next, // eslint-disable-next-line @typescript-eslint/require-await onReady: async () => { // eslint-disable-next-line @typescript-eslint/require-await this.startViewTransition(async () => { // this.viewTransitionPromise = createControlledPromise() // Commit the pending matches. If a previous match was // removed, place it in the cachedMatches let exitingMatches!: Array let enteringMatches!: Array let stayingMatches!: Array batch(() => { this.__store.setState((s) => { const previousMatches = s.matches const newMatches = s.pendingMatches || s.matches exitingMatches = previousMatches.filter( (match) => !newMatches.find((d) => d.id === match.id), ) enteringMatches = newMatches.filter( (match) => !previousMatches.find((d) => d.id === match.id), ) stayingMatches = previousMatches.filter((match) => newMatches.find((d) => d.id === match.id), ) return { ...s, isLoading: false, loadedAt: Date.now(), matches: newMatches, pendingMatches: undefined, cachedMatches: [ ...s.cachedMatches, ...exitingMatches.filter((d) => d.status !== 'error'), ], } }) this.clearExpiredCache() }) // ;( [ [exitingMatches, 'onLeave'], [enteringMatches, 'onEnter'], [stayingMatches, 'onStay'], ] as const ).forEach(([matches, hook]) => { matches.forEach((match) => { this.looseRoutesById[match.routeId]!.options[hook]?.(match) }) }) }) }, }) } catch (err) { if (isResolvedRedirect(err)) { redirect = err if (!this.isServer) { this.navigate({ ...redirect, replace: true, ignoreBlocker: true, }) } } else if (isNotFound(err)) { notFound = err } this.__store.setState((s) => ({ ...s, statusCode: redirect ? redirect.statusCode : notFound ? 404 : s.matches.some((d) => d.status === 'error') ? 500 : 200, redirect, })) } if (this.latestLoadPromise === loadPromise) { this.commitLocationPromise?.resolve() this.latestLoadPromise = undefined this.commitLocationPromise = undefined } resolve() }) }) this.latestLoadPromise = loadPromise await loadPromise while ( (this.latestLoadPromise as any) && loadPromise !== this.latestLoadPromise ) { await this.latestLoadPromise } if (this.hasNotFoundMatch()) { this.__store.setState((s) => ({ ...s, statusCode: 404, })) } } startViewTransition = (fn: () => Promise) => { // Determine if we should start a view transition from the navigation // or from the router default const shouldViewTransition = this.shouldViewTransition ?? this.options.defaultViewTransition // Reset the view transition flag delete this.shouldViewTransition // Attempt to start a view transition (or just apply the changes if we can't) if ( shouldViewTransition && typeof document !== 'undefined' && 'startViewTransition' in document && typeof document.startViewTransition === 'function' ) { // lib.dom.ts doesn't support viewTransition types variant yet. // TODO: Fix this when dom types are updated let startViewTransitionParams: any if ( typeof shouldViewTransition === 'object' && this.isViewTransitionTypesSupported ) { startViewTransitionParams = { update: fn, types: shouldViewTransition.types, } } else { startViewTransitionParams = fn } document.startViewTransition(startViewTransitionParams) } else { fn() } } updateMatch: UpdateMatchFn = (id, updater) => { let updated!: AnyRouteMatch const isPending = this.state.pendingMatches?.find((d) => d.id === id) const isMatched = this.state.matches.find((d) => d.id === id) const isCached = this.state.cachedMatches.find((d) => d.id === id) const matchesKey = isPending ? 'pendingMatches' : isMatched ? 'matches' : isCached ? 'cachedMatches' : '' if (matchesKey) { this.__store.setState((s) => ({ ...s, [matchesKey]: s[matchesKey]?.map((d) => d.id === id ? (updated = updater(d)) : d, ), })) } return updated } getMatch: GetMatchFn = (matchId: string) => { return [ ...this.state.cachedMatches, ...(this.state.pendingMatches ?? []), ...this.state.matches, ].find((d) => d.id === matchId) } loadMatches = async ({ location, matches, preload: allPreload, onReady, updateMatch = this.updateMatch, sync, }: { location: ParsedLocation matches: Array preload?: boolean onReady?: () => Promise updateMatch?: ( id: string, updater: (match: AnyRouteMatch) => AnyRouteMatch, ) => void getMatch?: (matchId: string) => AnyRouteMatch | undefined sync?: boolean }): Promise> => { let firstBadMatchIndex: number | undefined let rendered = false const triggerOnReady = async () => { if (!rendered) { rendered = true await onReady?.() } } const resolvePreload = (matchId: string) => { return !!(allPreload && !this.state.matches.find((d) => d.id === matchId)) } if (!this.isServer && !this.state.matches.length) { triggerOnReady() } const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => { if (isResolvedRedirect(err)) { if (!err.reloadDocument) { throw err } } if (isRedirect(err) || isNotFound(err)) { updateMatch(match.id, (prev) => ({ ...prev, status: isRedirect(err) ? 'redirected' : isNotFound(err) ? 'notFound' : 'error', isFetching: false, error: err, beforeLoadPromise: undefined, loaderPromise: undefined, })) if (!(err as any).routeId) { ;(err as any).routeId = match.routeId } match.beforeLoadPromise?.resolve() match.loaderPromise?.resolve() match.loadPromise?.resolve() if (isRedirect(err)) { rendered = true err = this.resolveRedirect({ ...err, _fromLocation: location }) throw err } else if (isNotFound(err)) { this._handleNotFound(matches, err, { updateMatch, }) this.serverSsr?.onMatchSettled({ router: this, match: this.getMatch(match.id)!, }) throw err } } } try { await new Promise((resolveAll, rejectAll) => { ;(async () => { try { const handleSerialError = ( index: number, err: any, routerCode: string, ) => { const { id: matchId, routeId } = matches[index]! const route = this.looseRoutesById[routeId]! // Much like suspense, we use a promise here to know if // we've been outdated by a new loadMatches call and // should abort the current async operation if (err instanceof Promise) { throw err } err.routerCode = routerCode firstBadMatchIndex = firstBadMatchIndex ?? index handleRedirectAndNotFound(this.getMatch(matchId)!, err) try { route.options.onError?.(err) } catch (errorHandlerErr) { err = errorHandlerErr handleRedirectAndNotFound(this.getMatch(matchId)!, err) } updateMatch(matchId, (prev) => { prev.beforeLoadPromise?.resolve() prev.loadPromise?.resolve() return { ...prev, error: err, status: 'error', isFetching: false, updatedAt: Date.now(), abortController: new AbortController(), beforeLoadPromise: undefined, } }) } for (const [index, { id: matchId, routeId }] of matches.entries()) { const existingMatch = this.getMatch(matchId)! const parentMatchId = matches[index - 1]?.id const route = this.looseRoutesById[routeId]! const pendingMs = route.options.pendingMs ?? this.options.defaultPendingMs const shouldPending = !!( onReady && !this.isServer && !resolvePreload(matchId) && (route.options.loader || route.options.beforeLoad) && typeof pendingMs === 'number' && pendingMs !== Infinity && (route.options.pendingComponent ?? (this.options as any)?.defaultPendingComponent) ) let executeBeforeLoad = true if ( // If we are in the middle of a load, either of these will be present // (not to be confused with `loadPromise`, which is always defined) existingMatch.beforeLoadPromise || existingMatch.loaderPromise ) { if (shouldPending) { setTimeout(() => { try { // Update the match and prematurely resolve the loadMatches promise so that // the pending component can start rendering triggerOnReady() } catch {} }, pendingMs) } // Wait for the beforeLoad to resolve before we continue await existingMatch.beforeLoadPromise executeBeforeLoad = this.getMatch(matchId)!.status !== 'success' } if (executeBeforeLoad) { // If we are not in the middle of a load OR the previous load failed, start it try { updateMatch(matchId, (prev) => { // explicitly capture the previous loadPromise const prevLoadPromise = prev.loadPromise return { ...prev, loadPromise: createControlledPromise(() => { prevLoadPromise?.resolve() }), beforeLoadPromise: createControlledPromise(), } }) const abortController = new AbortController() let pendingTimeout: ReturnType if (shouldPending) { // If we might show a pending component, we need to wait for the // pending promise to resolve before we start showing that state pendingTimeout = setTimeout(() => { try { // Update the match and prematurely resolve the loadMatches promise so that // the pending component can start rendering triggerOnReady() } catch {} }, pendingMs) } const { paramsError, searchError } = this.getMatch(matchId)! if (paramsError) { handleSerialError(index, paramsError, 'PARSE_PARAMS') } if (searchError) { handleSerialError(index, searchError, 'VALIDATE_SEARCH') } const getParentMatchContext = () => parentMatchId ? this.getMatch(parentMatchId)!.context : (this.options.context ?? {}) updateMatch(matchId, (prev) => ({ ...prev, isFetching: 'beforeLoad', fetchCount: prev.fetchCount + 1, abortController, pendingTimeout, context: { ...getParentMatchContext(), ...prev.__routeContext, }, })) const { search, params, context, cause } = this.getMatch(matchId)! const preload = resolvePreload(matchId) const beforeLoadFnContext: BeforeLoadContextOptions< any, any, any, any, any > = { search, abortController, params, preload, context, location, navigate: (opts: any) => this.navigate({ ...opts, _fromLocation: location }), buildLocation: this.buildLocation, cause: preload ? 'preload' : cause, matches, } const beforeLoadContext = (await route.options.beforeLoad?.(beforeLoadFnContext)) ?? {} if ( isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext) ) { handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD') } updateMatch(matchId, (prev) => { return { ...prev, __beforeLoadContext: beforeLoadContext, context: { ...getParentMatchContext(), ...prev.__routeContext, ...beforeLoadContext, }, abortController, } }) } catch (err) { handleSerialError(index, err, 'BEFORE_LOAD') } updateMatch(matchId, (prev) => { prev.beforeLoadPromise?.resolve() return { ...prev, beforeLoadPromise: undefined, isFetching: false, } }) } } const validResolvedMatches = matches.slice(0, firstBadMatchIndex) const matchPromises: Array> = [] validResolvedMatches.forEach(({ id: matchId, routeId }, index) => { matchPromises.push( (async () => { const { loaderPromise: prevLoaderPromise } = this.getMatch(matchId)! let loaderShouldRunAsync = false let loaderIsRunningAsync = false if (prevLoaderPromise) { await prevLoaderPromise const match = this.getMatch(matchId)! if (match.error) { handleRedirectAndNotFound(match, match.error) } } else { const parentMatchPromise = matchPromises[index - 1] as any const route = this.looseRoutesById[routeId]! const getLoaderContext = (): LoaderFnContext => { const { params, loaderDeps, abortController, context, cause, } = this.getMatch(matchId)! const preload = resolvePreload(matchId) return { params, deps: loaderDeps, preload: !!preload, parentMatchPromise, abortController: abortController, context, location, navigate: (opts) => this.navigate({ ...opts, _fromLocation: location }), cause: preload ? 'preload' : cause, route, } } // This is where all of the stale-while-revalidate magic happens const age = Date.now() - this.getMatch(matchId)!.updatedAt const preload = resolvePreload(matchId) const staleAge = preload ? (route.options.preloadStaleTime ?? this.options.defaultPreloadStaleTime ?? 30_000) // 30 seconds for preloads by default : (route.options.staleTime ?? this.options.defaultStaleTime ?? 0) const shouldReloadOption = route.options.shouldReload // Default to reloading the route all the time // Allow shouldReload to get the last say, // if provided. const shouldReload = typeof shouldReloadOption === 'function' ? shouldReloadOption(getLoaderContext()) : shouldReloadOption updateMatch(matchId, (prev) => ({ ...prev, loaderPromise: createControlledPromise(), preload: !!preload && !this.state.matches.find((d) => d.id === matchId), })) const runLoader = async () => { try { // If the Matches component rendered // the pending component and needs to show it for // a minimum duration, we''ll wait for it to resolve // before committing to the match and resolving // the loadPromise const potentialPendingMinPromise = async () => { const latestMatch = this.getMatch(matchId)! if (latestMatch.minPendingPromise) { await latestMatch.minPendingPromise } } // Actually run the loader and handle the result try { this.loadRouteChunk(route) updateMatch(matchId, (prev) => ({ ...prev, isFetching: 'loader', })) // Kick off the loader! const loaderData = await route.options.loader?.(getLoaderContext()) handleRedirectAndNotFound( this.getMatch(matchId)!, loaderData, ) // Lazy option can modify the route options, // so we need to wait for it to resolve before // we can use the options await route._lazyPromise await potentialPendingMinPromise() const assetContext = { matches, match: this.getMatch(matchId)!, params: this.getMatch(matchId)!.params, loaderData, } const headFnContent = route.options.head?.(assetContext) const meta = headFnContent?.meta const links = headFnContent?.links const headScripts = headFnContent?.scripts const scripts = route.options.scripts?.(assetContext) const headers = route.options.headers?.({ loaderData, }) updateMatch(matchId, (prev) => ({ ...prev, error: undefined, status: 'success', isFetching: false, updatedAt: Date.now(), loaderData, meta, links, headScripts, headers, scripts, })) } catch (e) { let error = e await potentialPendingMinPromise() handleRedirectAndNotFound(this.getMatch(matchId)!, e) try { route.options.onError?.(e) } catch (onErrorError) { error = onErrorError handleRedirectAndNotFound( this.getMatch(matchId)!, onErrorError, ) } updateMatch(matchId, (prev) => ({ ...prev, error, status: 'error', isFetching: false, })) } this.serverSsr?.onMatchSettled({ router: this, match: this.getMatch(matchId)!, }) // Last but not least, wait for the the components // to be preloaded before we resolve the match await route._componentsPromise } catch (err) { updateMatch(matchId, (prev) => ({ ...prev, loaderPromise: undefined, })) handleRedirectAndNotFound(this.getMatch(matchId)!, err) } } // If the route is successful and still fresh, just resolve const { status, invalid } = this.getMatch(matchId)! loaderShouldRunAsync = status === 'success' && (invalid || (shouldReload ?? age > staleAge)) if (preload && route.options.preload === false) { // Do nothing } else if (loaderShouldRunAsync && !sync) { loaderIsRunningAsync = true ;(async () => { try { await runLoader() const { loaderPromise, loadPromise } = this.getMatch(matchId)! loaderPromise?.resolve() loadPromise?.resolve() updateMatch(matchId, (prev) => ({ ...prev, loaderPromise: undefined, })) } catch (err) { if (isResolvedRedirect(err)) { await this.navigate(err) } } })() } else if ( status !== 'success' || (loaderShouldRunAsync && sync) ) { await runLoader() } } if (!loaderIsRunningAsync) { const { loaderPromise, loadPromise } = this.getMatch(matchId)! loaderPromise?.resolve() loadPromise?.resolve() } updateMatch(matchId, (prev) => ({ ...prev, isFetching: loaderIsRunningAsync ? prev.isFetching : false, loaderPromise: loaderIsRunningAsync ? prev.loaderPromise : undefined, invalid: false, })) return this.getMatch(matchId)! })(), ) }) await Promise.all(matchPromises) resolveAll() } catch (err) { rejectAll(err) } })() }) await triggerOnReady() } catch (err) { if (isRedirect(err) || isNotFound(err)) { if (isNotFound(err) && !allPreload) { await triggerOnReady() } throw err } } return matches } invalidate: InvalidateFn< RouterCore< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory, TDehydrated > > = (opts) => { const invalidate = (d: MakeRouteMatch) => { if (opts?.filter?.(d as MakeRouteMatchUnion) ?? true) { return { ...d, invalid: true, ...(d.status === 'error' ? ({ status: 'pending', error: undefined } as const) : {}), } } return d } this.__store.setState((s) => ({ ...s, matches: s.matches.map(invalidate), cachedMatches: s.cachedMatches.map(invalidate), pendingMatches: s.pendingMatches?.map(invalidate), })) return this.load({ sync: opts?.sync }) } resolveRedirect = (err: AnyRedirect): ResolvedRedirect => { const redirect = err as ResolvedRedirect if (!redirect.href) { redirect.href = this.buildLocation(redirect as any).href } return redirect } clearCache: ClearCacheFn = (opts) => { const filter = opts?.filter if (filter !== undefined) { this.__store.setState((s) => { return { ...s, cachedMatches: s.cachedMatches.filter( (m) => !filter(m as MakeRouteMatchUnion), ), } }) } else { this.__store.setState((s) => { return { ...s, cachedMatches: [], } }) } } clearExpiredCache = () => { // This is where all of the garbage collection magic happens const filter = (d: MakeRouteMatch) => { const route = this.looseRoutesById[d.routeId]! if (!route.options.loader) { return true } // If the route was preloaded, use the preloadGcTime // otherwise, use the gcTime const gcTime = (d.preload ? (route.options.preloadGcTime ?? this.options.defaultPreloadGcTime) : (route.options.gcTime ?? this.options.defaultGcTime)) ?? 5 * 60 * 1000 return !(d.status !== 'error' && Date.now() - d.updatedAt < gcTime) } this.clearCache({ filter }) } loadRouteChunk = (route: AnyRoute) => { if (route._lazyPromise === undefined) { if (route.lazyFn) { route._lazyPromise = route.lazyFn().then((lazyRoute) => { // explicitly don't copy over the lazy route's id const { id: _id, ...options } = lazyRoute.options Object.assign(route.options, options) }) } else { route._lazyPromise = Promise.resolve() } } // If for some reason lazy resolves more lazy components... // We'll wait for that before pre attempt to preload any // components themselves. if (route._componentsPromise === undefined) { route._componentsPromise = route._lazyPromise.then(() => Promise.all( componentTypes.map(async (type) => { const component = route.options[type] if ((component as any)?.preload) { await (component as any).preload() } }), ), ) } return route._componentsPromise } preloadRoute: PreloadRouteFn< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory > = async (opts) => { const next = this.buildLocation(opts as any) let matches = this.matchRoutes(next, { throwOnError: true, preload: true, dest: opts, }) const activeMatchIds = new Set( [...this.state.matches, ...(this.state.pendingMatches ?? [])].map( (d) => d.id, ), ) const loadedMatchIds = new Set([ ...activeMatchIds, ...this.state.cachedMatches.map((d) => d.id), ]) // If the matches are already loaded, we need to add them to the cachedMatches batch(() => { matches.forEach((match) => { if (!loadedMatchIds.has(match.id)) { this.__store.setState((s) => ({ ...s, cachedMatches: [...(s.cachedMatches as any), match], })) } }) }) try { matches = await this.loadMatches({ matches, location: next, preload: true, updateMatch: (id, updater) => { // Don't update the match if it's currently loaded if (activeMatchIds.has(id)) { matches = matches.map((d) => (d.id === id ? updater(d) : d)) } else { this.updateMatch(id, updater) } }, }) return matches } catch (err) { if (isRedirect(err)) { if (err.reloadDocument) { return undefined } return await this.preloadRoute({ ...(err as any), _fromLocation: next, }) } if (!isNotFound(err)) { // Preload errors are not fatal, but we should still log them console.error(err) } return undefined } } matchRoute: MatchRouteFn< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory > = (location, opts) => { const matchLocation = { ...location, to: location.to ? this.resolvePathWithBase( (location.from || '') as string, location.to as string, ) : undefined, params: location.params || {}, leaveParams: true, } const next = this.buildLocation(matchLocation as any) if (opts?.pending && this.state.status !== 'pending') { return false } const pending = opts?.pending === undefined ? !this.state.isLoading : opts.pending const baseLocation = pending ? this.latestLocation : this.state.resolvedLocation || this.state.location const match = matchPathname(this.basepath, baseLocation.pathname, { ...opts, to: next.pathname, }) as any if (!match) { return false } if (location.params) { if (!deepEqual(match, location.params, { partial: true })) { return false } } if (match && (opts?.includeSearch ?? true)) { return deepEqual(baseLocation.search, next.search, { partial: true }) ? match : false } return match } ssr?: { manifest: Manifest | undefined serializer: StartSerializer } serverSsr?: { injectedHtml: Array injectHtml: (getHtml: () => string | Promise) => Promise injectScript: ( getScript: () => string | Promise, opts?: { logScript?: boolean }, ) => Promise streamValue: (key: string, value: any) => void streamedKeys: Set onMatchSettled: (opts: { router: AnyRouter; match: AnyRouteMatch }) => any } clientSsr?: { getStreamedValue: (key: string) => T | undefined } _handleNotFound = ( matches: Array, err: NotFoundError, { updateMatch = this.updateMatch, }: { updateMatch?: ( id: string, updater: (match: AnyRouteMatch) => AnyRouteMatch, ) => void } = {}, ) => { // Find the route that should handle the not found error // First check if a specific route is requested to show the error const routeCursor = this.routesById[err.routeId ?? ''] ?? this.routeTree const matchesByRouteId: Record = {} // Setup routesByRouteId object for quick access for (const match of matches) { matchesByRouteId[match.routeId] = match } // Ensure a NotFoundComponent exists on the route if ( !routeCursor.options.notFoundComponent && (this.options as any)?.defaultNotFoundComponent ) { routeCursor.options.notFoundComponent = ( this.options as any ).defaultNotFoundComponent } // Ensure we have a notFoundComponent invariant( routeCursor.options.notFoundComponent, 'No notFoundComponent found. Please set a notFoundComponent on your route or provide a defaultNotFoundComponent to the router.', ) // Find the match for this route const matchForRoute = matchesByRouteId[routeCursor.id] invariant( matchForRoute, 'Could not find match for route: ' + routeCursor.id, ) // Assign the error to the match - using non-null assertion since we've checked with invariant updateMatch(matchForRoute.id, (prev) => ({ ...prev, status: 'notFound', error: err, isFetching: false, })) if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) { err.routeId = routeCursor.parentRoute.id this._handleNotFound(matches, err, { updateMatch, }) } } hasNotFoundMatch = () => { return this.__store.state.matches.some( (d) => d.status === 'notFound' || d.globalNotFound, ) } } export class SearchParamError extends Error {} export class PathParamError extends Error {} // A function that takes an import() argument which is a function and returns a new function that will // proxy arguments from the caller to the imported function, retaining all type // information along the way export function lazyFn< T extends Record) => any>, TKey extends keyof T = 'default', >(fn: () => Promise, key?: TKey) { return async ( ...args: Parameters ): Promise>> => { const imported = await fn() return imported[key || 'default'](...args) } } export function getInitialRouterState( location: ParsedLocation, ): RouterState { return { loadedAt: 0, isLoading: false, isTransitioning: false, status: 'idle', resolvedLocation: undefined, location, matches: [], pendingMatches: [], cachedMatches: [], statusCode: 200, } } function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { if (validateSearch == null) return {} if ('~standard' in validateSearch) { const result = validateSearch['~standard'].validate(input) if (result instanceof Promise) throw new SearchParamError('Async validation not supported') if (result.issues) throw new SearchParamError(JSON.stringify(result.issues, undefined, 2), { cause: result, }) return result.value } if ('parse' in validateSearch) { return validateSearch.parse(input) } if (typeof validateSearch === 'function') { return validateSearch(input) } return {} } export const componentTypes = [ 'component', 'errorComponent', 'pendingComponent', 'notFoundComponent', ] as const function routeNeedsPreload(route: AnyRoute) { for (const componentType of componentTypes) { if ((route.options[componentType] as any)?.preload) { return true } } return false }