import { createBrowserHistory, parseHref } from '@tanstack/history' import { isServer } from '@tanstack/router-core/isServer' import { DEFAULT_PROTOCOL_ALLOWLIST, createControlledPromise, decodePath, deepEqual, encodePathLikeUrl, findLast, functionalUpdate, hasKeys, isDangerousProtocol, last, nullReplaceEqualDeep, replaceEqualDeep, } from './utils' import { buildRouteBranch, findFlatMatch, findRouteMatch, findSingleMatch, processRouteMasks, processRouteTree, } from './new-process-route-tree' import { cleanPath, compileDecodeCharMap, interpolatePath, resolvePath, trimPath, trimPathRight, } from './path' import { createLRUCache } from './lru-cache' 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 { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' import { composeRewrites, executeRewriteInput, executeRewriteOutput, rewriteBasepath, } from './rewrite' import { createRouterStores } from './stores' import type { LRUCache } from './lru-cache' import type { ProcessRouteTreeResult, ProcessedTree, } from './new-process-route-tree' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' import type { HistoryLocation, HistoryState, ParsedHistoryState, RouterHistory, } from '@tanstack/history' import type { Awaitable, Constrain, ControlledPromise, NoInfer, NonNullableUpdater, PickAsRequired, Updater, } from './utils' import type { ParsedLocation } from './location' import type { AnyContext, AnyRoute, AnyRouteWithContext, LoaderStaleReloadMode, 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, RouterManagedTag } from './manifest' import type { AnySchema, AnyValidator } from './validators' import type { NavigateOptions, ResolveRelativePath, ToOptions } from './link' import type { NotFoundError } from './not-found' import type { AnySerializationAdapter, ValidateSerializableInput, } from './ssr/serializer/transformer' import type { GetStoreConfig, RouterStores } from './stores' export type ControllablePromise = Promise & { resolve: (value: T) => void reject: (value?: any) => void } export type InjectedHtmlEntry = Promise export interface Register { // Lots of things on here like... // router // config // ssr } export type RegisteredSsr = TRegister extends { ssr: infer TSSR } ? TSSR : false export type RegisteredRouter = TRegister extends { router: infer TRouter } ? TRouter : AnyRouter export type RegisteredConfigType = TRegister extends { config: infer TConfig } ? TConfig extends { '~types': infer TTypes } ? TKey extends keyof TTypes ? TTypes[TKey] : unknown : unknown : unknown export type DefaultRemountDepsFn = ( opts: MakeRemountDepsOptionsUnion, ) => any export interface DefaultRouterOptionsExtensions {} export interface RouterOptionsExtensions extends DefaultRouterOptionsExtensions {} export type SSROption = boolean | 'data-only' export interface RouterOptions< TRouteTree extends AnyRoute, TTrailingSlashOption extends TrailingSlashOption, TDefaultStructuralSharingOption extends boolean = false, TRouterHistory extends RouterHistory = RouterHistory, TDehydrated = undefined, > 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 `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 stale reload mode a route loader should use if no `loader.staleReloadMode` is provided. * * `'background'` preserves the current stale-while-revalidate behavior. * `'blocking'` waits for stale loader reloads to complete before resolving navigation. * * @default 'background' */ defaultStaleReloadMode?: LoaderStaleReloadMode /** * 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 additionalContext?: any /** * 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?: () => Constrain< TDehydrated, ValidateSerializableInput > /** * 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 /** * 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 /** * @default false */ isShell?: boolean /** * @default false */ isPrerendering?: boolean /** * The default `ssr` a route should use if no `ssr` is provided. * * @default true */ defaultSsr?: SSROption 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 | ((opts: { location: ParsedLocation }) => 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 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 /** * An array of URL protocols to allow in links, redirects, and navigation. * Absolute URLs with protocols not in this list will be rejected. * * @default DEFAULT_PROTOCOL_ALLOWLIST (http:, https:, mailto:, tel:) * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#protocolallowlist-property) */ protocolAllowlist?: Array serializationAdapters?: ReadonlyArray /** * Configures how the router will rewrite the location between the actual href and the internal href of the router. * * @default undefined * @description You can provide a custom rewrite pair (in/out). * This is useful for shifting data from the origin to the path (for things like subdomain routing), or other advanced use cases. */ rewrite?: LocationRewrite origin?: string ssr?: { nonce?: string } } export type LocationRewrite = { /** * A function that will be called to rewrite the URL before it is interpreted by the router from the history instance. * * @default undefined */ input?: LocationRewriteFunction /** * A function that will be called to rewrite the URL before it is committed to the actual history instance from the router. * * @default undefined */ output?: LocationRewriteFunction } /** * A function that will be called to rewrite the URL. * * @param url The URL to rewrite. * @returns The rewritten URL (as a URL instance or full href string) or undefined if no rewrite is needed. */ export type LocationRewriteFunction = ({ url, }: { url: URL }) => undefined | string | URL export interface RouterState< in out TRouteTree extends AnyRoute = AnyRoute, in out TRouteMatch = MakeRouteMatchUnion, > { status: 'pending' | 'idle' loadedAt: number isLoading: boolean isTransitioning: boolean matches: Array location: ParsedLocation> resolvedLocation?: ParsedLocation> statusCode: number redirect?: AnyRedirect } 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 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 = (event: TEvent) => void export type RouterListener = { eventType: TRouterEvent['type'] fn: ListenerFn } export type SubscribeFn = ( eventType: TType, fn: ListenerFn, ) => () => void export interface MatchRoutesOpts { preload?: boolean throwOnError?: 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' | 'serializationAdapters' | 'defaultSsr' > & RouterContextOptions 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 > & { /** * @internal * A **trusted** built location that can be used to redirect to. */ _builtLocation?: ParsedLocation }, ) => 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 forcePending?: boolean }) => Promise export type ParseLocationFn = ( locationToParse: HistoryLocation, previousLocation?: ParsedLocation>, ) => ParsedLocation> export type GetMatchRoutesFn = (pathname: string) => { matchedRoutes: ReadonlyArray /** exhaustive params, still in their string form */ routeParams: Record foundRoute: AnyRoute | undefined parseError?: unknown } 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 interface MatchRoutesFn { ( pathname: string, locationSearch?: AnySchema, opts?: MatchRoutesOpts, ): Array /** * @deprecated use the following signature instead */ (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, ) => void export type LoadRouteChunkFn = (route: AnyRoute) => Promise> export type ResolveRedirect = (err: AnyRedirect) => ResolvedRedirect export type ClearCacheFn = (opts?: { filter?: (d: MakeRouteMatchUnion) => boolean }) => void export interface ServerSsr { /** * Injects HTML synchronously into the stream. * Emits an onInjectedHtml event that listeners can handle. * If no subscriber is listening, the HTML is buffered and can be retrieved via takeBufferedHtml(). */ injectHtml: (html: string) => void /** * Injects a script tag synchronously into the stream. */ injectScript: (script: string) => void isDehydrated: () => boolean isSerializationFinished: () => boolean onRenderFinished: (listener: () => void) => void setRenderFinished: () => void cleanup: () => void onSerializationFinished: (listener: () => void) => void dehydrate: (opts?: { requestAssets?: Array }) => Promise takeBufferedScripts: () => RouterManagedTag | undefined /** * Takes any buffered HTML that was injected. * Returns the buffered HTML string (which may include multiple script tags) or undefined if empty. */ takeBufferedHtml: () => string | undefined liftScriptBarrier: () => void } export type AnyRouterWithContext = RouterCore< AnyRouteWithContext, any, any, any, any > export type AnyRouter = RouterCore export interface ViewTransitionOptions { types: | Array | ((locationChangeInfo: { fromLocation?: ParsedLocation toLocation: ParsedLocation pathChanged: boolean hrefChanged: boolean hashChanged: boolean }) => Array | false) } // TODO where is this used? can we remove this? /** * Convert an unknown error into a minimal, serializable object. * Includes name and message (and stack in development). */ 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, } } /** Options for configuring trailing-slash behavior. */ export const trailingSlashOptions = { always: 'always', never: 'never', preserve: 'preserve', } as const export type TrailingSlashOption = (typeof trailingSlashOptions)[keyof typeof trailingSlashOptions] /** * Compute whether path, href or hash changed between previous and current * resolved locations. */ export function getLocationChangeInfo( location: ParsedLocation, resolvedLocation?: ParsedLocation, ) { const fromLocation = resolvedLocation const toLocation = 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 > declare global { // eslint-disable-next-line no-var var __TSR_CACHE__: | { routeTree: AnyRoute processRouteTreeResult: ProcessRouteTreeResult resolvePathCache: LRUCache } | undefined } /** * Core, framework-agnostic router engine that powers TanStack Router. * * Provides navigation, matching, loading, preloading, caching and event APIs * used by framework adapters (React/Solid). Prefer framework helpers like * `createRouter` in app code. * * @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouterType */ 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 stores!: RouterStores private getStoreConfig!: GetStoreConfig batch!: (fn: () => void) => void options!: PickAsRequired< RouterOptions< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory, TDehydrated >, 'stringifySearch' | 'parseSearch' | 'context' > history!: TRouterHistory rewrite?: LocationRewrite origin?: string latestLocation!: ParsedLocation> pendingBuiltLocation?: ParsedLocation> basepath!: string routeTree!: TRouteTree routesById!: RoutesById routesByPath!: RoutesByPath processedTree!: ProcessedTree resolvePathCache!: LRUCache private routeBranchCache = new WeakMap>() isServer!: boolean pathParamsDecoder?: (encoded: string) => string protocolAllowlist!: Set /** * @deprecated Use the `createRouter` function instead */ constructor( options: RouterConstructorOptions< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory, TDehydrated >, getStoreConfig: GetStoreConfig, ) { this.getStoreConfig = getStoreConfig 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, protocolAllowlist: options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST, }) if (typeof document !== 'undefined') { self.__TSR_ROUTER__ = this } } // This is a default implementation that can optionally be overridden // by the router provider once rendered. We provide this 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 (process.env.NODE_ENV !== 'production') { 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 prevOptions = this.options const prevBasepath = this.basepath ?? prevOptions?.basepath ?? '/' const basepathWasUnset = this.basepath === undefined const prevRewriteOption = prevOptions?.rewrite this.options = { ...prevOptions, ...newOptions, } this.isServer = this.options.isServer ?? typeof document === 'undefined' this.protocolAllowlist = new Set(this.options.protocolAllowlist) if (this.options.pathParamsAllowedCharacters) this.pathParamsDecoder = compileDecodeCharMap( this.options.pathParamsAllowedCharacters, ) if ( !this.history || (this.options.history && this.options.history !== this.history) ) { if (!this.options.history) { if (!(isServer ?? this.isServer)) { this.history = createBrowserHistory() as TRouterHistory } } else { this.history = this.options.history } } this.origin = this.options.origin if (!this.origin) { if ( !(isServer ?? this.isServer) && window?.origin && window.origin !== 'null' ) { this.origin = window.origin } else { // fallback for the server, can be overridden by calling router.update({origin}) on the server this.origin = 'http://localhost' } } if (this.history) { this.updateLatestLocation() } if (this.options.routeTree !== this.routeTree) { this.routeTree = this.options.routeTree as TRouteTree let processRouteTreeResult: ProcessRouteTreeResult if ( (isServer ?? this.isServer) && process.env.NODE_ENV !== 'development' && globalThis.__TSR_CACHE__ && globalThis.__TSR_CACHE__.routeTree === this.routeTree ) { const cached = globalThis.__TSR_CACHE__ this.resolvePathCache = cached.resolvePathCache processRouteTreeResult = cached.processRouteTreeResult as any } else { this.resolvePathCache = createLRUCache(1000) processRouteTreeResult = this.buildRouteTree() // only cache if nothing else is cached yet if ( (isServer ?? this.isServer) && process.env.NODE_ENV !== 'development' && globalThis.__TSR_CACHE__ === undefined ) { globalThis.__TSR_CACHE__ = { routeTree: this.routeTree, processRouteTreeResult: processRouteTreeResult as any, resolvePathCache: this.resolvePathCache, } } } this.setRoutes(processRouteTreeResult) } if (!this.stores && this.latestLocation) { const config = this.getStoreConfig(this) this.batch = config.batch this.stores = createRouterStores( getInitialRouterState(this.latestLocation), config, ) if (!(isServer ?? this.isServer)) { setupScrollRestoration(this) } } let needsLocationUpdate = false const nextBasepath = this.options.basepath ?? '/' const nextRewriteOption = this.options.rewrite const basepathChanged = basepathWasUnset || prevBasepath !== nextBasepath const rewriteChanged = prevRewriteOption !== nextRewriteOption if (basepathChanged || rewriteChanged) { this.basepath = nextBasepath const rewrites: Array = [] const trimmed = trimPath(nextBasepath) if (trimmed && trimmed !== '/') { rewrites.push( rewriteBasepath({ basepath: nextBasepath, }), ) } if (nextRewriteOption) { rewrites.push(nextRewriteOption) } this.rewrite = rewrites.length === 0 ? undefined : rewrites.length === 1 ? rewrites[0] : composeRewrites(rewrites) if (this.history) { this.updateLatestLocation() } needsLocationUpdate = true } if (needsLocationUpdate && this.stores) { this.stores.location.set(this.latestLocation) } 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(): RouterState { return this.stores.__store.get() } updateLatestLocation = () => { this.latestLocation = this.parseLocation( this.history.location, this.latestLocation, ) } buildRouteTree = () => { const result = processRouteTree( this.routeTree, this.options.caseSensitive, (route, i) => { route.init({ originalIndex: i, }) }, ) if (this.options.routeMasks) { processRouteMasks(this.options.routeMasks, result.processedTree) } return result } setRoutes({ routesById, routesByPath, processedTree, }: ProcessRouteTreeResult) { this.routesById = routesById as RoutesById this.routesByPath = routesByPath as RoutesByPath this.processedTree = processedTree const notFoundRoute = this.options.notFoundRoute if (notFoundRoute) { notFoundRoute.init({ originalIndex: 99999999999, }) this.routesById[notFoundRoute.id] = notFoundRoute } } /** * Subscribe to router lifecycle events like `onBeforeNavigate`, `onLoad`, * `onResolved`, etc. Returns an unsubscribe function. * * @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouterEventsType */ 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) } }) } /** * Parse a HistoryLocation into a strongly-typed ParsedLocation using the * current router options, rewrite rules and search parser/stringifier. */ parseLocation: ParseLocationFn = ( locationToParse, previousLocation, ) => { const parse = ({ pathname, search, hash, href, state, }: HistoryLocation): ParsedLocation> => { // Fast path: no rewrite configured and pathname doesn't need encoding // Characters that need encoding: space, high unicode, control chars // eslint-disable-next-line no-control-regex if (!this.rewrite && !/[ \x00-\x1f\x7f\u0080-\uffff]/.test(pathname)) { const parsedSearch = this.options.parseSearch(search) const searchStr = this.options.stringifySearch(parsedSearch) return { href: pathname + searchStr + hash, publicHref: pathname + searchStr + hash, pathname: decodePath(pathname).path, external: false, searchStr, search: nullReplaceEqualDeep( previousLocation?.search, parsedSearch, ) as any, hash: decodePath(hash.slice(1)).path, state: replaceEqualDeep(previousLocation?.state, state), } } // Before we do any processing, we need to allow rewrites to modify the URL // build up the full URL by combining the href from history with the router's origin const fullUrl = new URL(href, this.origin) const url = executeRewriteInput(this.rewrite, fullUrl) const parsedSearch = this.options.parseSearch(url.search) const searchStr = this.options.stringifySearch(parsedSearch) // Make sure our final url uses the re-stringified pathname, search, and has for consistency // (We were already doing this, so just keeping it for now) url.search = searchStr const fullPath = url.href.replace(url.origin, '') return { href: fullPath, publicHref: href, pathname: decodePath(url.pathname).path, external: !!this.rewrite && url.origin !== this.origin, searchStr, search: nullReplaceEqualDeep( previousLocation?.search, parsedSearch, ) as any, hash: decodePath(url.hash.slice(1)).path, 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 } /** Resolve a path using the router's trailing-slash policy. */ resolvePathWithBase = (from: string, path: string) => { return resolvePath({ base: from, to: path.includes('//') ? cleanPath(path) : path, trailingSlash: this.options.trailingSlash, cache: this.resolvePathCache, }) } private getRouteBranch(route: AnyRoute) { let branch = this.routeBranchCache.get(route) if (!branch) { branch = buildRouteBranch(route) this.routeBranchCache.set(route, branch) } return branch } get looseRoutesById() { return this.routesById as Record } 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 getParentContext(parentMatch?: AnyRouteMatch) { const parentMatchId = parentMatch?.id const parentContext = !parentMatchId ? ((this.options.context as any) ?? undefined) : (parentMatch.context ?? this.options.context ?? undefined) return parentContext } private matchRoutesInternal( next: ParsedLocation, opts?: MatchRoutesOpts, ): Array { const matchedRoutesResult = this.getMatchedRoutes(next.pathname) const { foundRoute, routeParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult 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 = [...matchedRoutes, this.options.notFoundRoute] } else { // If there is no routes found during path matching isGlobalNotFound = true } } const globalNotFoundRouteId = isGlobalNotFound ? findGlobalNotFoundRouteId(this.options.notFoundMode, matchedRoutes) : undefined const matches = new Array(matchedRoutes.length) // Snapshot of active match state keyed by routeId, used to stabilise // params/search across navigations. const previousActiveMatchesByRouteId = new Map() for (const store of this.stores.matchStores.values()) { if (store.routeId) { previousActiveMatchesByRouteId.set(store.routeId, store.get()) } } for (let index = 0; index < matchedRoutes.length; index++) { const route = matchedRoutes[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] let preMatchSearch: Record let strictMatchSearch: Record let searchError: 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 preMatchSearch = { ...parentSearch, ...strictSearch, } strictMatchSearch = { ...parentStrictSearch, ...strictSearch } searchError = undefined } catch (err: any) { let searchParamError = err if (!(err instanceof SearchParamError)) { searchParamError = new SearchParamError(err.message, { cause: err, }) } if (opts?.throwOnError) { throw searchParamError } preMatchSearch = parentSearch strictMatchSearch = {} searchError = 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, decoder: this.pathParamsDecoder, server: this.isServer, }) // 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 = // route.id for disambiguation route.id + // interpolatedPath for param changes interpolatedPath + // explicit deps loaderDepsHash const existingMatch = this.getMatch(matchId) const previousMatch = previousActiveMatchesByRouteId.get(route.id) const strictParams = existingMatch?._strictParams ?? usedParams let paramsError: unknown = undefined if (!existingMatch) { try { extractStrictParams(route, strictParams) } catch (err: any) { if (isNotFound(err) || isRedirect(err)) { paramsError = err } else { 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?.params ?? routeParams, _strictParams: strictParams, search: previousMatch ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) : nullReplaceEqualDeep(existingMatch.search, preMatchSearch), _strictSearch: strictMatchSearch, } } else { const status = route.options.loader || route.options.beforeLoad || route.lazyFn || routeNeedsPreload(route) ? 'pending' : 'success' match = { id: matchId, ssr: (isServer ?? this.isServer) ? undefined : route.options.ssr, index, routeId: route.id, params: previousMatch?.params ?? routeParams, _strictParams: strictParams, pathname: interpolatedPath, updatedAt: Date.now(), search: previousMatch ? nullReplaceEqualDeep(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 = this.getParentContext(parentMatch) match.context = { ...parentContext, ...match.__routeContext, ...match.__beforeLoadContext, } matches[index] = match } for (let index = 0; index < matches.length; index++) { const match = matches[index]! const route = this.looseRoutesById[match.routeId]! const existingMatch = this.getMatch(match.id) // Update the match's params const previousMatch = previousActiveMatchesByRouteId.get(match.routeId) match.params = previousMatch ? nullReplaceEqualDeep(previousMatch.params, routeParams) : routeParams if (!existingMatch) { const parentMatch = matches[index - 1] const parentContext = this.getParentContext(parentMatch) // Update the match's context if (route.options.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, routeId: route.id, } // Get the route context match.__routeContext = route.options.context(contextFnContext) ?? undefined } match.context = { ...parentContext, ...match.__routeContext, ...match.__beforeLoadContext, } } } return matches } getMatchedRoutes: GetMatchRoutesFn = (pathname) => { return getMatchedRoutes({ pathname, routesById: this.routesById, processedTree: this.processedTree, }) } /** * Lightweight route matching for buildLocation. * Only computes fullPath, accumulated search, and params - skipping expensive * operations like AbortController, ControlledPromise, loaderDeps, and full match objects. */ private matchRoutesLightweight(location: ParsedLocation): { matchedRoutes: ReadonlyArray fullPath: string search: Record params: Record } { const { matchedRoutes, routeParams } = this.getMatchedRoutes( location.pathname, ) const lastRoute = last(matchedRoutes)! // I don't know if we should run the full search middleware chain, or just validateSearch // // Accumulate search validation through the route chain // const accumulatedSearch: Record = applySearchMiddleware({ // search: { ...location.search }, // dest: location, // destRoutes: matchedRoutes, // _includeValidateSearch: true, // }) // Accumulate search validation through route chain const accumulatedSearch = { ...location.search } for (const route of matchedRoutes) { try { Object.assign( accumulatedSearch, validateSearch(route.options.validateSearch, accumulatedSearch), ) } catch { // Ignore errors, we're not actually routing } } // Determine params: reuse from state if possible, otherwise parse const lastStateMatchId = last(this.stores.matchesId.get()) const lastStateMatch = lastStateMatchId && this.stores.matchStores.get(lastStateMatchId)?.get() const canReuseParams = lastStateMatch && lastStateMatch.routeId === lastRoute.id && lastStateMatch.pathname === location.pathname let params: Record if (canReuseParams) { params = lastStateMatch.params } else { // Parse params through the route chain const strictParams: Record = Object.assign( Object.create(null), routeParams, ) for (const route of matchedRoutes) { try { extractStrictParams(route, strictParams) } catch { // Ignore errors, we're not actually routing } } params = strictParams } return { matchedRoutes, fullPath: lastRoute.fullPath, search: accumulatedSearch, params, } } cancelMatch = (id: string) => { const match = this.getMatch(id) if (!match) return match.abortController.abort() clearTimeout(match._nonReactive.pendingTimeout) match._nonReactive.pendingTimeout = undefined } cancelMatches = () => { this.stores.pendingIds.get().forEach((matchId) => { this.cancelMatch(matchId) }) this.stores.matchesId.get().forEach((matchId) => { if (this.stores.pendingMatchStores.has(matchId)) { return } const match = this.stores.matchStores.get(matchId)?.get() if (!match) { return } if (match.status === 'pending' || match.isFetching === 'loader') { this.cancelMatch(matchId) } }) } /** * Build the next ParsedLocation from navigation options without committing. * Resolves `to`/`from`, params/search/hash/state, applies search validation * and middlewares, and returns a stable, stringified location object. * * @link https://tanstack.com/router/latest/docs/framework/react/api/router/RouterType#buildlocation-method */ buildLocation: BuildLocationFn = (opts) => { const build = ( dest: BuildNextOptions & { unmaskOnReload?: boolean } = {}, ): ParsedLocation => { // We allow the caller to override the current location const currentLocation = dest._fromLocation || this.pendingBuiltLocation || this.latestLocation // Use lightweight matching - only computes what buildLocation needs // (fullPath, search, params) without creating full match objects const lightweightResult = this.matchRoutesLightweight(currentLocation) // 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).matchedRoutes const matchedFrom = findLast(lightweightResult.matchedRoutes, (d) => { return comparePaths(d.fullPath, dest.from!) }) const matchedCurrent = findLast(allFromMatches, (d) => { return comparePaths(d.fullPath, lightweightResult.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 ?? lightweightResult.fullPath) const destTo = dest.to ? `${dest.to}` : undefined // From search should always use the current location const fromSearch = lightweightResult.search // Same with params. It can't hurt to provide as many as possible const fromParams = Object.assign( Object.create(null), lightweightResult.params, ) const isAbsoluteTo = destTo?.charCodeAt(0) === 47 const sourcePath = isAbsoluteTo ? '/' : this.resolvePathWithBase(defaultedFromPath, '.') // Resolve the destination. Absolute destinations don't need the source path. const nextTo = destTo ? this.resolvePathWithBase(sourcePath, destTo) : sourcePath // Resolve the next params const nextParams = dest.params === false || dest.params === null ? Object.create(null) : (dest.params ?? true) === true ? fromParams : Object.assign( fromParams, functionalUpdate(dest.params as any, fromParams), ) const destRoute = this.routesByPath[ trimPathRight(nextTo) as keyof typeof this.routesByPath ] as AnyRoute | undefined let destRoutes: ReadonlyArray if (destRoute) { destRoutes = this.getRouteBranch(destRoute) } else if (nextTo.includes('$')) { // Route templates must match routesByPath exactly. A miss here is a // typed destination mismatch, not a concrete URL to route-match. destRoutes = [] } else { const destMatchResult = this.getMatchedRoutes(nextTo) destRoutes = destMatchResult.matchedRoutes if ( this.options.notFoundRoute && (!destMatchResult.foundRoute || (destMatchResult.foundRoute.path !== '/' && destMatchResult.routeParams['**'])) ) { destRoutes = [...destRoutes, this.options.notFoundRoute] } } // If there are any params, we need to stringify them if (destRoutes.length && hasKeys(nextParams)) { for (const route of destRoutes) { const fn = route.options.params?.stringify ?? route.options.stringifyParams if (fn) { try { Object.assign(nextParams, fn(nextParams)) } catch { // Ignore errors here. When a paired parseParams is defined, // extractStrictParams will re-throw during route matching, // storing the error on the match and allowing the route's // errorComponent to render. If no parseParams is defined, // the stringify error is silently dropped. } } } } const nextPathname = opts.leaveParams ? // Keep path params uninterpolated for matchRoute/template matching. nextTo : decodePath( interpolatePath({ path: nextTo, params: nextParams, decoder: this.pathParamsDecoder, server: this.isServer, }).interpolatedPath, ).path if ( process.env.NODE_ENV !== 'production' && destRoute && !opts.leaveParams ) { try { const roundTrip = this.getMatchedRoutes(nextPathname) if (roundTrip.foundRoute?.id !== destRoute.id) { console.warn( `Generated path "${nextPathname}" for route "${destRoute.id}" did not match the same route after params.stringify.`, ) } } catch { // Ignore roundtrip validation errors. The generated location will be // handled by the normal navigation flow. } } // 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 = nullReplaceEqualDeep(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}` : '' // Resolve the next state let nextState = dest.state === true ? currentLocation.state : dest.state ? functionalUpdate(dest.state, currentLocation.state) : {} // Replace the equal deep nextState = replaceEqualDeep(currentLocation.state, nextState) // Create the full path of the location const fullPath = `${nextPathname}${searchStr}${hashStr}` // Compute href and publicHref without URL construction when no rewrite let href: string let publicHref: string let external = false if (this.rewrite) { // With rewrite, we need to construct URL to apply the rewrite const url = new URL(fullPath, this.origin) const rewrittenUrl = executeRewriteOutput(this.rewrite, url) href = url.href.replace(url.origin, '') // If rewrite changed the origin, publicHref needs full URL // Otherwise just use the path components if (rewrittenUrl.origin !== this.origin) { publicHref = rewrittenUrl.href external = true } else { publicHref = rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash } } else { // Fast path: no rewrite, skip URL construction entirely // fullPath is already the correct href (origin-stripped) // We need to encode non-ASCII (unicode) characters for the href // since decodePath decoded them from the interpolated path href = encodePathLikeUrl(fullPath) publicHref = href } return { publicHref, href, pathname: nextPathname, search: nextSearch, searchStr, state: nextState as any, hash: hash ?? '', external, unmaskOnReload: dest.unmaskOnReload, } } const buildWithMatches = ( dest: BuildNextOptions = {}, maskedDest?: BuildNextOptions, ) => { const next = build(dest) let maskedNext = maskedDest ? build(maskedDest) : undefined if (!maskedNext) { const params = Object.create(null) if (this.options.routeMasks) { const match = findFlatMatch>( next.pathname, this.processedTree, ) if (match) { Object.assign(params, match.rawParams) // Copy params, because they're cached const { from: _from, params: maskParams, ...maskProps } = match.route // If mask has a params function, call it with the matched params as context // Otherwise, use the matched params or the provided params value const nextParams = maskParams === false || maskParams === null ? Object.create(null) : (maskParams ?? true) === true ? params : Object.assign(params, functionalUpdate(maskParams, params)) maskedDest = { from: opts.from, ...maskProps, params: nextParams, } maskedNext = build(maskedDest) } } } if (maskedNext) { next.maskedLocation = maskedNext } return next } if (opts.mask) { return buildWithMatches(opts, { from: opts.from, ...opts.mask, }) } return buildWithMatches(opts) } commitLocationPromise: undefined | ControlledPromise /** * Commit a previously built location to history (push/replace), optionally * using view transitions and scroll restoration options. */ commitLocation: CommitLocationFn = async ({ 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', // TODO: Remove in v2 - use __TSR_key instead '__TSR_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 = trimPathRight(this.latestLocation.href) === trimPathRight(next.href) let previousCommitPromise = this.commitLocationPromise this.commitLocationPromise = createControlledPromise(() => { previousCommitPromise?.resolve() previousCommitPromise = undefined }) // Don't commit to history if nothing changed if (isSameUrl && isSameState()) { this.load() } else { let { // eslint-disable-next-line prefer-const maskedLocation, // eslint-disable-next-line prefer-const hashScrollIntoView, ...nextHistory } = next if (maskedLocation) { nextHistory = { ...maskedLocation, state: { ...maskedLocation.state, __tempKey: undefined, __tempLocation: { ...nextHistory, search: nextHistory.searchStr, state: { ...nextHistory.state, __tempKey: undefined!, __tempLocation: undefined!, __TSR_key: undefined!, key: undefined!, // TODO: Remove in v2 - use __TSR_key instead }, }, }, } 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.publicHref, nextHistory.state, { ignoreBlocker }, ) } this.resetNextScroll = next.resetScroll ?? true if (!this.history.subscribers.size) { this.load() } return this.commitLocationPromise } /** Convenience helper: build a location from options, then commit it. */ 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, }) // If the href contains the basepath, we need to strip it before setting `to` // because `buildLocation` will add the basepath back when creating the final URL. // Without this, hrefs like '/app/about' would become '/app/app/about'. const hrefUrl = new URL(parsed.pathname, this.origin) const rewrittenUrl = executeRewriteInput(this.rewrite, hrefUrl) rest.to = rewrittenUrl.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, }) this.pendingBuiltLocation = location as ParsedLocation< FullSearchSchema > const commitPromise = this.commitLocation({ ...location, viewTransition, replace, resetScroll, hashScrollIntoView, ignoreBlocker, }) // Clear pending location after commit starts // We do this on next microtask to allow synchronous navigate calls to chain Promise.resolve().then(() => { if (this.pendingBuiltLocation === location) { this.pendingBuiltLocation = undefined } }) return commitPromise } /** * Imperatively navigate using standard `NavigateOptions`. When `reloadDocument` * or an absolute `href` is provided, performs a full document navigation. * Otherwise, builds and commits a client-side location. * * @link https://tanstack.com/router/latest/docs/framework/react/api/router/NavigateOptionsType */ navigate: NavigateFn = async ({ to, reloadDocument, href, publicHref, ...rest }) => { let hrefIsUrl = false if (href) { try { new URL(`${href}`) hrefIsUrl = true } catch {} } if (hrefIsUrl && !reloadDocument) { reloadDocument = true } if (reloadDocument) { // When to is provided, always build a location to get the proper publicHref // (this handles redirects where href might be an internal path from resolveRedirect) // When only href is provided (no to), use it directly as it should already // be a complete path (possibly with basepath) if (to !== undefined || !href) { const location = this.buildLocation({ to, ...rest } as any) // Use publicHref which contains the path (origin-stripped is fine for reload) href = href ?? location.publicHref publicHref = publicHref ?? location.publicHref } // Use publicHref when available and href is not a full URL, // otherwise use href directly (which may already include basepath) const reloadHref = !hrefIsUrl && publicHref ? publicHref : href // Block dangerous protocols like javascript:, blob:, data: // These could execute arbitrary code if passed to window.location if (isDangerousProtocol(reloadHref, this.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn( `Blocked navigation to dangerous protocol: ${reloadHref}`, ) } return Promise.resolve() } // Check blockers for external URLs unless ignoreBlocker is true if (!rest.ignoreBlocker) { // Cast to access internal getBlockers method const historyWithBlockers = this.history as any const blockers = historyWithBlockers.getBlockers?.() ?? [] for (const blocker of blockers) { if (blocker?.blockerFn) { const shouldBlock = await blocker.blockerFn({ currentLocation: this.latestLocation, nextLocation: this.latestLocation, // External URLs don't have a next location in our router action: 'PUSH', }) if (shouldBlock) { return Promise.resolve() } } } } if (rest.replace) { window.location.replace(reloadHref) } else { window.location.href = reloadHref } return Promise.resolve() } return this.buildAndCommitLocation({ ...rest, href, to: to as string, _isNavigate: true, }) } latestLoadPromise: undefined | Promise beforeLoad = () => { // Cancel any pending matches this.cancelMatches() this.updateLatestLocation() if (isServer ?? this.isServer) { // for SPAs on the initial load, this is handled by the Transitioner const nextLocation = this.buildLocation({ to: this.latestLocation.pathname, search: true, params: true, hash: true, state: true, _includeValidateSearch: true, }) // Check if location changed - origin check is unnecessary since buildLocation // always uses this.origin when constructing URLs if (this.latestLocation.publicHref !== nextLocation.publicHref) { const href = this.getParsedLocationHref(nextLocation) if (nextLocation.external) { throw redirect({ href }) } else { throw redirect({ href, _builtLocation: nextLocation }) } } } // Match the routes const pendingMatches = this.matchRoutes(this.latestLocation) const nextCachedMatches = this.stores.cachedMatches .get() .filter((d) => !pendingMatches.some((e) => e.id === d.id)) // Ingest the new matches this.batch(() => { this.stores.status.set('pending') this.stores.statusCode.set(200) this.stores.isLoading.set(true) this.stores.location.set(this.latestLocation) this.stores.setPending(pendingMatches) // If a cached match moved to pending matches, remove it from cached matches this.stores.setCached(nextCachedMatches) }) } load: LoadFn = async (opts?: { sync?: boolean }): Promise => { let redirect: AnyRedirect | undefined let notFound: NotFoundError | undefined let loadPromise: Promise const previousLocation = this.stores.resolvedLocation.get() ?? this.stores.location.get() // eslint-disable-next-line prefer-const loadPromise = new Promise((resolve) => { this.startTransition(async () => { try { this.beforeLoad() const next = this.latestLocation const prevLocation = this.stores.resolvedLocation.get() const locationChangeInfo = getLocationChangeInfo(next, prevLocation) if (!this.stores.redirect.get()) { this.emit({ type: 'onBeforeNavigate', ...locationChangeInfo, }) } this.emit({ type: 'onBeforeLoad', ...locationChangeInfo, }) await loadMatches({ router: this, sync: opts?.sync, forceStaleReload: previousLocation.href === next.href, matches: this.stores.pendingMatches.get(), location: next, updateMatch: this.updateMatch, // eslint-disable-next-line @typescript-eslint/require-await onReady: async () => { // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) this.startTransition(() => { this.startViewTransition(async () => { // this.viewTransitionPromise = createControlledPromise() // Commit the pending matches. If a previous match was // removed, place it in the cachedMatches // // exitingMatches uses match.id (routeId + params + loaderDeps) so // navigating /foo?page=1 → /foo?page=2 correctly caches the page=1 entry. let exitingMatches: Array | null = null // Lifecycle-hook identity uses routeId only so that navigating between // different params/deps of the same route fires onStay (not onLeave+onEnter). let hookExitingMatches: Array | null = null let hookEnteringMatches: Array | null = null let hookStayingMatches: Array | null = null this.batch(() => { const pendingMatches = this.stores.pendingMatches.get() const mountPending = pendingMatches.length const currentMatches = this.stores.matches.get() exitingMatches = mountPending ? currentMatches.filter( (match) => !this.stores.pendingMatchStores.has(match.id), ) : null // Lifecycle-hook identity: routeId only (route presence in tree) // Build routeId sets from pools to avoid derived stores. const pendingRouteIds = new Set() for (const s of this.stores.pendingMatchStores.values()) { if (s.routeId) pendingRouteIds.add(s.routeId) } const activeRouteIds = new Set() for (const s of this.stores.matchStores.values()) { if (s.routeId) activeRouteIds.add(s.routeId) } hookExitingMatches = mountPending ? currentMatches.filter( (match) => !pendingRouteIds.has(match.routeId), ) : null hookEnteringMatches = mountPending ? pendingMatches.filter( (match) => !activeRouteIds.has(match.routeId), ) : null hookStayingMatches = mountPending ? pendingMatches.filter((match) => activeRouteIds.has(match.routeId), ) : currentMatches this.stores.isLoading.set(false) this.stores.loadedAt.set(Date.now()) /** * When committing new matches, cache any exiting matches that are still usable. * Routes that resolved with `status: 'error'` or `status: 'notFound'` are * deliberately excluded from `cachedMatches` so that subsequent invalidations * or reloads re-run their loaders instead of reusing the failed/not-found data. */ if (mountPending) { this.stores.setMatches(pendingMatches) this.stores.setPending([]) this.stores.setCached([ ...this.stores.cachedMatches.get(), ...exitingMatches!.filter( (d) => d.status !== 'error' && d.status !== 'notFound' && d.status !== 'redirected', ), ]) this.clearExpiredCache() } }) // for (const [matches, hook] of [ [hookExitingMatches, 'onLeave'], [hookEnteringMatches, 'onEnter'], [hookStayingMatches, 'onStay'], ] as const) { if (!matches) continue for (const match of matches as Array) { this.looseRoutesById[match.routeId]!.options[hook]?.( match, ) } } }) }) }, }) } catch (err) { if (isRedirect(err)) { redirect = err if (!(isServer ?? this.isServer)) { this.navigate({ ...redirect.options, replace: true, ignoreBlocker: true, }) } } else if (isNotFound(err)) { notFound = err } const nextStatusCode = redirect ? redirect.status : notFound ? 404 : this.stores.matches.get().some((d) => d.status === 'error') ? 500 : 200 this.batch(() => { this.stores.statusCode.set(nextStatusCode) this.stores.redirect.set(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 } let newStatusCode: number | undefined = undefined if (this.hasNotFoundMatch()) { newStatusCode = 404 } else if (this.stores.matches.get().some((d) => d.status === 'error')) { newStatusCode = 500 } if (newStatusCode !== undefined) { this.stores.statusCode.set(newStatusCode) } } 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 this.shouldViewTransition = undefined // 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 ) { const next = this.latestLocation const prevLocation = this.stores.resolvedLocation.get() const resolvedViewTransitionTypes = typeof shouldViewTransition.types === 'function' ? shouldViewTransition.types( getLocationChangeInfo(next, prevLocation), ) : shouldViewTransition.types if (resolvedViewTransitionTypes === false) { fn() return } startViewTransitionParams = { update: fn, types: resolvedViewTransitionTypes, } } else { startViewTransitionParams = fn } document.startViewTransition(startViewTransitionParams) } else { fn() } } updateMatch: UpdateMatchFn = (id, updater) => { this.startTransition(() => { const pendingMatch = this.stores.pendingMatchStores.get(id) if (pendingMatch) { pendingMatch.set(updater) return } const activeMatch = this.stores.matchStores.get(id) if (activeMatch) { activeMatch.set(updater) return } const cachedMatch = this.stores.cachedMatchStores.get(id) if (cachedMatch) { const next = updater(cachedMatch.get()) if (next.status === 'redirected') { const deleted = this.stores.cachedMatchStores.delete(id) if (deleted) { this.stores.cachedIds.set((prev) => prev.filter((matchId) => matchId !== id), ) } } else { cachedMatch.set(next) } } }) } getMatch: GetMatchFn = (matchId: string): AnyRouteMatch | undefined => { return ( this.stores.cachedMatchStores.get(matchId)?.get() ?? this.stores.pendingMatchStores.get(matchId)?.get() ?? this.stores.matchStores.get(matchId)?.get() ) } /** * Invalidate the current matches and optionally force them back into a pending state. * * - Marks all matches that pass the optional `filter` as `invalid: true`. * - If `forcePending` is true, or a match is currently in `'error'` or `'notFound'` status, * its status is reset to `'pending'` and its `error` cleared so that the loader is re-run * on the next `load()` call (eg. after HMR or a manual invalidation). */ invalidate: InvalidateFn< RouterCore< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory, TDehydrated > > = (opts) => { const invalidate = (d: MakeRouteMatch) => { if (opts?.filter?.(d as MakeRouteMatchUnion) ?? true) { return { ...d, invalid: true, ...(opts?.forcePending || d.status === 'error' || d.status === 'notFound' ? ({ status: 'pending', error: undefined } as const) : undefined), } } return d } this.batch(() => { this.stores.setMatches(this.stores.matches.get().map(invalidate)) this.stores.setCached(this.stores.cachedMatches.get().map(invalidate)) this.stores.setPending(this.stores.pendingMatches.get().map(invalidate)) }) this.shouldViewTransition = false return this.load({ sync: opts?.sync }) } getParsedLocationHref = (location: ParsedLocation) => { // For redirects and external use, we need publicHref (with rewrite output applied) // href is the internal path after rewrite input, publicHref is user-facing return location.publicHref || '/' } resolveRedirect = (redirect: AnyRedirect): AnyRedirect => { const locationHeader = redirect.headers.get('Location') if (!redirect.options.href || redirect.options._builtLocation) { const location = redirect.options._builtLocation ?? this.buildLocation(redirect.options) const href = this.getParsedLocationHref(location) redirect.options.href = href redirect.headers.set('Location', href) } else if (locationHeader) { try { const url = new URL(locationHeader) if (this.origin && url.origin === this.origin) { const href = url.pathname + url.search + url.hash redirect.options.href = href redirect.headers.set('Location', href) } } catch { // ignore invalid URLs } } if ( redirect.options.href && !redirect.options._builtLocation && // Check for dangerous protocols before processing the redirect isDangerousProtocol(redirect.options.href, this.protocolAllowlist) ) { throw new Error( process.env.NODE_ENV !== 'production' ? `Redirect blocked: unsafe protocol in href "${redirect.options.href}". Allowed protocols: ${Array.from(this.protocolAllowlist).join(', ')}.` : 'Redirect blocked: unsafe protocol', ) } if (!redirect.headers.get('Location')) { redirect.headers.set('Location', redirect.options.href) } return redirect } clearCache: ClearCacheFn = (opts) => { const filter = opts?.filter if (filter !== undefined) { this.stores.setCached( this.stores.cachedMatches .get() .filter((m) => !filter(m as MakeRouteMatchUnion)), ) } else { this.stores.setCached([]) } } clearExpiredCache = () => { const now = Date.now() // 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 const isError = d.status === 'error' if (isError) return true const gcEligible = now - d.updatedAt >= gcTime return gcEligible } this.clearCache({ filter }) } loadRouteChunk = loadRouteChunk preloadRoute: PreloadRouteFn< TRouteTree, TTrailingSlashOption, TDefaultStructuralSharingOption, TRouterHistory > = async (opts) => { const next = opts._builtLocation ?? this.buildLocation(opts as any) let matches = this.matchRoutes(next, { throwOnError: true, preload: true, dest: opts, }) const activeMatchIds = new Set([ ...this.stores.matchesId.get(), ...this.stores.pendingIds.get(), ]) const loadedMatchIds = new Set([ ...activeMatchIds, ...this.stores.cachedIds.get(), ]) // If the matches are already loaded, we need to add them to the cached matches. const matchesToCache = matches.filter( (match) => !loadedMatchIds.has(match.id), ) if (matchesToCache.length) { const cachedMatches = this.stores.cachedMatches.get() this.stores.setCached([...cachedMatches, ...matchesToCache]) } try { matches = await loadMatches({ router: this, 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.options.reloadDocument) { return undefined } return await this.preloadRoute({ ...err.options, _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 || '', location.to as string) : undefined, params: location.params || {}, leaveParams: true, } const next = this.buildLocation(matchLocation as any) if (opts?.pending && this.stores.status.get() !== 'pending') { return false } const pending = opts?.pending === undefined ? !this.stores.isLoading.get() : opts.pending const baseLocation = pending ? this.latestLocation : this.stores.resolvedLocation.get() || this.stores.location.get() const match = findSingleMatch( next.pathname, opts?.caseSensitive ?? false, opts?.fuzzy ?? false, baseLocation.pathname, this.processedTree, ) if (!match) { return false } if (location.params) { if (!deepEqual(match.rawParams, location.params, { partial: true })) { return false } } if (opts?.includeSearch ?? true) { return deepEqual(baseLocation.search, next.search, { partial: true }) ? match.rawParams : false } return match.rawParams } ssr?: { manifest: Manifest | undefined } serverSsr?: ServerSsr hasNotFoundMatch = () => { return this.stores.matches .get() .some((d) => d.status === 'notFound' || d.globalNotFound) } } /** Error thrown when search parameter validation fails. */ export class SearchParamError extends Error {} /** Error thrown when path parameter parsing/validation fails. */ export class PathParamError extends Error {} const normalize = (str: string) => str.endsWith('/') && str.length > 1 ? str.slice(0, -1) : str function comparePaths(a: string, b: string) { return normalize(a) === normalize(b) } /** * Lazily import a module function and forward arguments to it, retaining * parameter and return types for the selected export key. */ 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) } } /** Create an initial RouterState from a parsed location. */ export function getInitialRouterState( location: ParsedLocation, ): RouterState { return { loadedAt: 0, isLoading: false, isTransitioning: false, status: 'idle', resolvedLocation: undefined, location, matches: [], 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 {} } /** * Build the matched route chain and extract params for a pathname. * Falls back to the root route if no specific route is found. */ export function getMatchedRoutes({ pathname, routesById, processedTree, }: { pathname: string routesById: Record processedTree: ProcessedTree }) { const routeParams: Record = Object.create(null) const trimmedPath = trimPathRight(pathname) let foundRoute: TRouteLike | undefined = undefined const match = findRouteMatch(trimmedPath, processedTree, true) if (match) { foundRoute = match.route Object.assign(routeParams, match.rawParams) // Copy params, because they're cached } const matchedRoutes = match?.branch || [routesById[rootRouteId]!] return { matchedRoutes, routeParams, foundRoute } } /** * TODO: once caches are persisted across requests on the server, * we can cache the built middleware chain using `last(destRoutes)` as the key */ function applySearchMiddleware({ search, dest, destRoutes, _includeValidateSearch, }: { search: any dest: { search?: unknown } destRoutes: ReadonlyArray _includeValidateSearch: boolean | undefined }) { const middleware = buildMiddlewareChain(destRoutes) return middleware(search, dest, _includeValidateSearch ?? false) } function buildMiddlewareChain(destRoutes: ReadonlyArray) { const context = { dest: null as unknown as BuildNextOptions, _includeValidateSearch: false, middlewares: [] as Array>, } for (const route of destRoutes) { if ('search' in route.options) { if (route.options.search?.middlewares) { context.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 } context.middlewares.push(legacyMiddleware) } if (route.options.validateSearch) { const validate: SearchMiddleware = ({ search, next }) => { const result = next(search) if (!context._includeValidateSearch) return result try { const validatedSearch = { ...result, ...(validateSearch(route.options.validateSearch, result) ?? undefined), } return validatedSearch } catch { // ignore errors here because they are already handled in matchRoutes return result } } context.middlewares.push(validate) } } // the chain ends here since `next` is not called const final: SearchMiddleware = ({ search }) => { const dest = context.dest if (!dest.search) { return {} } if (dest.search === true) { return search } return functionalUpdate(dest.search, search) } context.middlewares.push(final) const applyNext = ( index: number, currentSearch: any, middlewares: Array>, ): any => { // no more middlewares left, return the current search if (index >= middlewares.length) { return currentSearch } const middleware = middlewares[index]! const next = (newSearch: any): any => { return applyNext(index + 1, newSearch, middlewares) } return middleware({ search: currentSearch, next }) } return function middleware( search: any, dest: BuildNextOptions, _includeValidateSearch: boolean, ) { context.dest = dest context._includeValidateSearch = _includeValidateSearch return applyNext(0, search, context.middlewares) } } function findGlobalNotFoundRouteId( notFoundMode: 'root' | 'fuzzy' | undefined, routes: ReadonlyArray, ) { if (notFoundMode !== 'root') { for (let i = routes.length - 1; i >= 0; i--) { const route = routes[i]! if (route.children) { return route.id } } } return rootRouteId } function extractStrictParams( route: AnyRoute, accumulatedParams: Record, ) { const parseParams = route.options.params?.parse ?? route.options.parseParams if (parseParams) { const result = parseParams(accumulatedParams as Record) if (result === false) { throw new Error('Route params.parse returned false for a matched route') } Object.assign(accumulatedParams, result) } }