@tanstack/router-core
Version:
Modern and scalable routing for React applications
1,803 lines (1,715 loc) • 76 kB
text/typescript
// Keep this filename free of a secondary extension so declaration generation
// can rewrite relative imports for both ESM and CJS.
import { isNotFound } from './not-found'
import { isRedirect } from './redirect'
import {
_getUserHistoryState,
getLocationChangeInfo,
runRouteLifecycle,
} from './router'
import { deepEqual } from './utils'
import { hydrateSsrMatchId } from './ssr/ssr-match-id'
import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './ssr/constants'
import type { AnySerializationAdapter } from './ssr/serializer/transformer'
import type { TsrSsrGlobal } from './ssr/types'
import type { ParsedLocation } from './location'
import type { AnyRouteMatch } from './Matches'
import type { NotFoundError } from './not-found'
import type {
AnyRoute,
BeforeLoadContextOptions,
LoaderFnContext,
RouteContextOptions,
RouteLoaderFn,
} from './route'
import type { AnyRedirect } from './redirect'
import type { AnyRouter } from './router'
type RouteComponentType =
| 'component'
| 'pendingComponent'
| 'errorComponent'
| 'notFoundComponent'
export function replaceRouteChunk(
route: AnyRoute,
lazyFn: AnyRoute['lazyFn'],
): void {
route.lazyFn = lazyFn ?? route.lazyFn
route._lazy = undefined
}
function preloadComponent(
route: AnyRoute,
type: RouteComponentType,
): Promise<void> | undefined {
return (route.options[type] as any)?.preload?.()
}
function loadComponents(route: AnyRoute): Promise<void> | undefined {
const component = preloadComponent(route, 'component')
const pending = preloadComponent(route, 'pendingComponent')
if (component && pending) {
return Promise.all([component, pending]).then(() => {})
}
return component ?? pending
}
export function loadRouteChunk(
route: AnyRoute,
// `false` waits only for lazy route options, before a boundary is selected.
componentType?: 'errorComponent' | 'notFoundComponent' | false,
): Promise<void> | undefined {
const afterLazy = () =>
componentType === false
? undefined
: componentType
? preloadComponent(route, componentType)
: loadComponents(route)
const current = route._lazy
if (current) {
return current === true ? afterLazy() : current.then(afterLazy)
}
if (!route.lazyFn) {
return afterLazy()
}
const promise = route.lazyFn().then(
(lazyRoute) => {
// HMR clears the owner before an obsolete import can settle.
if (process.env.NODE_ENV === 'production' || route._lazy === promise) {
const { id: _id, ...options } = lazyRoute.options
Object.assign(route.options, options)
route._lazy = true
}
},
(error) => {
if (process.env.NODE_ENV === 'production' || route._lazy === promise) {
route._lazy = undefined
}
throw error
},
)
route._lazy = promise
return promise.then(afterLazy)
}
/** Return the structural lane through the first terminal render boundary. */
export function _getRenderedMatches(
matches: Array<AnyRouteMatch>,
): Array<AnyRouteMatch> {
const end =
matches.findIndex(
(match) => match.status !== 'success' || match._notFound,
) + 1
return end && end < matches.length ? matches.slice(0, end) : matches
}
/** Return the lane whose document assets belong to the current presentation. */
export function _getAssetMatches(
matches: Array<AnyRouteMatch>,
): Array<AnyRouteMatch> {
let end = matches.length
for (let index = 0; index < end; index++) {
const match = matches[index]!
// `_assetEnd` is only ever set on hydration presentation clones that are
// `status: 'pending'`, `ssr: 'data-only'`, error-free, and not not-found
// (see hydrate.ts), and commits clear it — so its presence alone is the guard.
if (match._assetEnd !== undefined) {
end = Math.min(end, Math.max(index + 1, match._assetEnd))
continue
}
if (match.status !== 'success' || match._notFound) {
end = index + 1
break
}
}
// `end` only ever shrinks to `index + 1 >= 1`, so no zero guard is needed.
return end < matches.length ? matches.slice(0, end) : matches
}
declare const lanePhase: unique symbol
type LanePhase = 'matched' | 'contextualized' | 'reduced' | 'projected'
/**
* Lane matches carry their lane's phase so functions can demand evidence of
* pipeline position (e.g. `commitMatches` only accepts a projected lane's
* matches). The brand is phantom — it never exists at runtime.
*/
type LaneMatches<TPhase extends LanePhase> = Array<WorkMatch> & {
readonly [lanePhase]?: TPhase
}
type Lane<TPhase extends LanePhase> = [
location: ParsedLocation,
matches: LaneMatches<TPhase>,
background?: Array<BackgroundLoaderTask>,
backgroundSettlement?: Promise<IndexedOutcome | undefined>,
] & { readonly [lanePhase]?: TPhase }
type MatchedLane = Lane<'matched'>
type ContextualizedLane = Lane<'contextualized'>
type ReducedLane = Lane<'reduced'>
type ProjectedLane = Lane<'projected'>
const SUCCESS = 0
const ERROR = 1
const NOT_FOUND = 2
// Control outcomes stay contiguous so the hot path can test them together.
const REDIRECTED = 3
const CANCELED = 4
type LoaderOutcome =
| [typeof SUCCESS, data: unknown]
| [typeof ERROR, error: unknown]
| [typeof NOT_FOUND, error: NotFoundError]
| [typeof REDIRECTED, redirect: AnyRedirect]
| [typeof CANCELED]
type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number]
export type LoaderFlight = [
outcome: Promise<LoaderOutcome>,
controller: AbortController,
leases: number,
]
type WorkMatch = AnyRouteMatch & {
_flight?: LoaderFlight
}
declare const matchPhase: unique symbol
/**
* A match whose loader outcome has been applied by `settleInto`, which is the
* sole granter of this brand (phantom, zero-runtime). Consumers that require
* it — e.g. `cacheLoaderMatch` — can only be reached after settlement, so the
* compiler enforces the loader→settle→cache ordering. Sources that arrive
* already settled (dehydrated server data) must cast at a named boundary.
*/
type SettledMatch = WorkMatch & { readonly [matchPhase]: 'settled' }
export type LaneInputs = [
routeTree: AnyRoute,
context: unknown,
additionalContext: unknown,
state: object,
search: object,
maskedLocation:
| [
href: string,
state: object,
search: object,
unmaskOnReload: boolean | undefined,
]
| undefined,
]
export type ActivePreload = [
matches: Array<AnyRouteMatch>,
controller: AbortController,
result: Promise<LaneResult>,
semanticOwner: Array<AnyRouteMatch>,
inputs: LaneInputs,
redirects: number,
]
export type LoadTransaction = [
controller: AbortController,
redirects: number,
location: ParsedLocation,
matches: Array<AnyRouteMatch>,
startedAt: number,
done: Promise<void>,
/**
* Dev-only HMR refresh mode. Presence is the mode flag; a refresh always
* carries the presentation it started from, while the hydration handoff is
* genuinely optional — the tuple makes a half-armed refresh unrepresentable.
*/
refresh?: [
presentation: Array<AnyRouteMatch>,
handoff: NonNullable<AnyRouter['_handoff']> | undefined,
],
]
export type PendingSession = [
owner: LoadTransaction,
boundary: number,
/** Pending reveal time until acknowledged, then minimum-visible-until time. */
deadline: number,
timer?: ReturnType<typeof setTimeout>,
ack?: Promise<boolean>,
component?: unknown,
]
type CoordinatorRouter = AnyRouter & {
/** Whole speculative lanes that a matching navigation may adopt. */
_preloads?: Map<string, ActivePreload>
_refreshNextLoad?: boolean
_rollbackRefresh?: () => void
_cancelTransition?: () => void
}
type PublicationCheckpoint = {
previousMatches: Array<AnyRouteMatch>
previousPresentation: Array<AnyRouteMatch>
previousCache: Map<string, AnyRouteMatch>
commitPromise: CoordinatorRouter['_commitPromise']
published: boolean
}
type LoaderTask = [
index: number,
outcome: Promise<LoaderOutcome>,
ready: Promise<IndexedOutcome | undefined>,
candidate?: WorkMatch,
]
type BackgroundLoaderTask = [
index: number,
outcome: Promise<LoaderOutcome>,
ready: Promise<IndexedOutcome | undefined>,
candidate: WorkMatch,
]
type ExecuteLaneOptions = [
controller: AbortController,
redirects: number,
isCurrent: () => boolean,
base: Array<AnyRouteMatch>,
preload?: boolean,
sync?: boolean,
forceStaleReload?: boolean,
resolvedPrefix?: number,
onReady?: () => void,
]
type ControlOutcome =
| [typeof REDIRECTED, redirect: AnyRedirect]
| [typeof CANCELED]
type LaneResult = ProjectedLane | ControlOutcome
function isControl(
result: Lane<any> | ControlOutcome,
): result is ControlOutcome {
return typeof result[0] === 'number'
}
export function waitFor<T>(
value: T | PromiseLike<T>,
signal: AbortSignal,
): Promise<T> {
if (signal.aborted) {
return Promise.race([Promise.reject(signal), value])
}
return new Promise<T>((resolve, reject) => {
const abort = () => reject(signal)
signal.addEventListener('abort', abort, { once: true })
Promise.resolve(value)
.then(resolve, reject)
.finally(() => signal.removeEventListener('abort', abort))
})
}
export function getRoute(router: AnyRouter, match: WorkMatch): AnyRoute {
return (router.routesById as Record<string, AnyRoute>)[match.routeId]!
}
function normalize(
value: unknown,
rejected: boolean,
routeId?: string,
): LoaderOutcome {
if (isRedirect(value)) {
return [REDIRECTED, value]
}
if (isNotFound(value)) {
value.routeId ||= routeId
return [NOT_FOUND, value]
}
if (rejected && typeof (value as any)?.then === 'function') {
value = new Error('A Promise was thrown', { cause: value })
}
return rejected ? [ERROR, value] : [SUCCESS, value]
}
function normalizeError(route: AnyRoute, cause: unknown): LoaderOutcome {
let outcome = normalize(cause, true, route.id)
if (outcome[0] !== ERROR) {
return outcome
}
try {
route.options.onError?.(outcome[1])
} catch (onErrorCause) {
outcome = normalize(onErrorCause, true, route.id)
}
return outcome
}
function normalizeLaneError(
route: AnyRoute,
cause: unknown,
options: ExecuteLaneOptions,
): LoaderOutcome {
if (options[0].signal.aborted || !options[2]()) {
options[0].abort()
return [CANCELED]
}
return normalizeError(route, cause)
}
export function navigateFrom(router: AnyRouter, location: ParsedLocation) {
return (opts: any) =>
router.navigate({
...opts,
_fromLocation: location,
})
}
async function contextualize(
router: AnyRouter,
lane: MatchedLane,
options: ExecuteLaneOptions,
end: number,
): Promise<IndexedOutcome | undefined> {
const [location, matches] = lane
const signal = options[0].signal
const preload = !!options[4]
for (let index = options[7] ?? 0; index < end; index++) {
const match = matches[index]!
const route = getRoute(router, match)
match.abortController = options[0]
// Contextualization is serial, so the previous match already contains the
// complete parent context for this route.
const parentContext =
matches[index - 1]?.context ?? router.options.context ?? {}
const common = {
params: match.params,
location,
navigate: navigateFrom(router, location),
buildLocation: router.buildLocation,
cause: preload ? ('preload' as const) : match.cause,
abortController: options[0],
preload,
matches,
routeId: route.id,
}
let context = parentContext
try {
let routeContext = match._ctx
if (!routeContext && route.options.context) {
routeContext = match._ctx =
route.options.context({
...common,
deps: match.loaderDeps,
context: parentContext,
} satisfies RouteContextOptions<any, any, any, any, any>) || {}
}
context = {
...parentContext,
...routeContext,
}
match.context = context
} catch (cause) {
releaseFlight(router, match)
return [index, normalizeLaneError(route, cause, options)]
}
if (signal.aborted || !options[2]()) {
options[0].abort()
return [index, [CANCELED]]
}
const validationError = match.paramsError ?? match.searchError
if (validationError !== undefined) {
releaseFlight(router, match)
return [index, normalizeLaneError(route, validationError, options)]
}
const beforeLoad = route.options.beforeLoad
if (!beforeLoad) {
continue
}
const beforeLoadContext: BeforeLoadContextOptions<
any,
any,
any,
any,
any,
any,
any,
any,
any
> = {
...common,
search: match.search,
context,
...router.options.additionalContext,
}
const previousStatus = match.status
if (previousStatus === 'success') {
match.status = 'pending'
}
options[8]?.()
try {
setFetching(router, match, 'beforeLoad', options[0])
const result = await waitFor(beforeLoad(beforeLoadContext), signal)
if (!options[2]()) {
options[0].abort()
return [index, [CANCELED]]
}
const outcome = normalize(result, false, route.id)
if (outcome[0] !== SUCCESS) {
releaseFlight(router, match)
return [index, outcome]
}
match.context = {
...context,
...result,
}
} catch (cause) {
releaseFlight(router, match)
return [index, normalizeLaneError(route, cause, options)]
} finally {
if (previousStatus === 'success' && match.status === 'pending') {
match.status = 'success'
}
setFetching(router, match, false, options[0])
}
}
return
}
function releaseOwnedFlight(
router: AnyRouter,
id: string,
flight?: LoaderFlight,
): AbortController | undefined {
if (!flight || --flight[2]) {
return
}
if (router._flights?.get(id) === flight) {
router._flights.delete(id)
}
return flight[1]
}
function releaseFlight(router: AnyRouter, match: WorkMatch): void {
const flight = match._flight
match._flight = undefined
releaseOwnedFlight(router, match.id, flight)?.abort()
}
export function laneInputs(
router: AnyRouter,
location: ParsedLocation,
): LaneInputs {
const masked = location.maskedLocation
return [
router.routeTree,
router.options.context ?? {},
router.options.additionalContext,
_getUserHistoryState(location.state),
location.search,
masked && [
masked.href,
_getUserHistoryState(masked.state),
masked.search,
masked.unmaskOnReload,
],
]
}
function samePreloadLane(
preload: ActivePreload,
router: AnyRouter,
location: ParsedLocation,
redirects: number,
): boolean {
return (
preload[3] === router._committed &&
preload[5] === redirects &&
deepEqual(preload[4], laneInputs(router, location)) &&
!preload[0].some(
(match) => getRoute(router, match as WorkMatch).options.preload === false,
)
)
}
/**
* Not passing in a `next` ownership recipient
* is equivalent to discarding the match resources
*/
export function transferMatchResources(
router: AnyRouter,
previous: Array<AnyRouteMatch>,
next?: Array<AnyRouteMatch>,
): void {
const abort: Array<AbortController> = []
for (const match of previous as Array<WorkMatch>) {
if (!next?.includes(match)) {
const flight = match._flight
match._flight = undefined
const controller = releaseOwnedFlight(router, match.id, flight)
if (controller) {
abort.push(controller)
}
}
}
for (const controller of abort) {
controller.abort()
}
}
function discardPreload(router: AnyRouter, preload: ActivePreload): void {
preload[1].abort()
transferMatchResources(router, preload[0])
}
function acquireMatchResources(matches: Array<AnyRouteMatch>): void {
for (const match of matches as Array<WorkMatch>) {
const flight = match._flight
if (flight) {
flight[2]++
}
}
}
function setFetching(
router: AnyRouter,
match: WorkMatch,
value: AnyRouteMatch['isFetching'],
owner?: AbortController,
): void {
match.isFetching = value
if (owner && router._tx?.[0] !== owner) {
return
}
const store = router.stores.byRoute.get(match.routeId)
const presented = store?.get()
if (presented?.id === match.id) {
store!.set({ ...presented, isFetching: value })
}
}
function getLoaderContext(
router: AnyRouter,
lane: ContextualizedLane,
match: WorkMatch,
route: AnyRoute,
controller: AbortController,
parentMatchPromise: Promise<WorkMatch> | undefined,
preload: boolean,
): LoaderFnContext {
const location = lane[0]
return {
params: match.params,
location,
navigate: navigateFrom(router, location),
cause: preload ? ('preload' as const) : match.cause,
abortController: controller,
preload,
deps: match.loaderDeps,
parentMatchPromise: parentMatchPromise as any,
context: match.context,
route,
...router.options.additionalContext,
}
}
async function loadResource(
router: AnyRouter,
lane: ContextualizedLane,
match: WorkMatch,
route: AnyRoute,
loader: RouteLoaderFn<any> | undefined,
parentMatchPromise: Promise<WorkMatch> | undefined,
preload: boolean,
owner: AbortController,
): Promise<LoaderOutcome> {
const signal = owner.signal
if (signal.aborted) {
return [CANCELED]
}
if (!loader) {
return [SUCCESS, undefined]
}
let flight = match._flight
let joined = !!flight
setFetching(router, match, 'loader', owner)
try {
for (;;) {
if (!flight) {
const controller = new AbortController()
const outcome = Promise.resolve()
.then(() =>
loader(
getLoaderContext(
router,
lane,
match,
route,
controller,
parentMatchPromise,
preload,
),
),
)
.then(
(value) => normalize(value, false, route.id),
(cause) => normalize(cause, true, route.id),
)
.then((result): LoaderOutcome => {
return result[0] === ERROR && match._flight === flight
? normalizeError(route, result[1])
: result
})
flight = [outcome, controller, 1]
;(router._flights ??= new Map()).set(match.id, flight)
}
match._flight = flight
match.abortController = flight[1]
try {
const outcome = await waitFor(flight[0], signal)
if (!joined || outcome[0] === SUCCESS || outcome[0] === REDIRECTED) {
return outcome
}
} catch (cause) {
if (cause === signal) {
releaseFlight(router, match)
return [CANCELED]
}
throw cause
}
releaseFlight(router, match)
if (signal.aborted) {
return [CANCELED]
}
flight = undefined
joined = false
}
} finally {
setFetching(router, match, false, owner)
}
}
function settleInto(
match: WorkMatch,
result: LoaderOutcome,
preload: boolean,
): asserts match is SettledMatch {
if (result[0] === SUCCESS) {
match.loaderData = result[1]
match.error = undefined
match.status = 'success'
match.invalid = false
match.updatedAt = Date.now()
match.preload = preload
} else if (result[0] !== REDIRECTED) {
// Reduction installs only the selected terminal failure. Every other
// settled attempt remains a renderable, stale match in that lane.
match.status = 'success'
match.error = undefined
match.invalid = true
}
}
export function cacheLoaderMatch(
router: CoordinatorRouter,
match: SettledMatch,
planned: AnyRouteMatch | undefined,
): void {
const current = router._cache.get(match.id) as WorkMatch | undefined
if (
current !== planned ||
router._committed.some(
(candidate) =>
candidate.id === match.id &&
(candidate as WorkMatch)._flight === match._flight,
)
) {
return
}
const cached = {
...match,
_notFound: undefined,
context: {},
} as WorkMatch
if (cached._flight) {
cached._flight[2]++
}
router._cache.set(match.id, cached)
if (current) {
releaseFlight(router, current)
}
}
function getParentSnapshot(
match: WorkMatch,
outcome: LoaderOutcome,
): WorkMatch {
if (outcome[0] === ERROR || outcome[0] === NOT_FOUND) {
return {
...match,
status: outcome[0] === ERROR ? 'error' : 'notFound',
error: outcome[1],
_flight: undefined,
}
}
return match
}
function createLoaderTask(
router: AnyRouter,
lane: ContextualizedLane,
index: number,
tasks: Array<LoaderTask>,
semanticParent: Promise<WorkMatch> | undefined,
options: ExecuteLaneOptions,
): Promise<WorkMatch> {
const match = lane[1][index]!
const route = getRoute(router, match)
const preload = !!options[4]
const plannedCacheMatch = preload ? router._cache.get(match.id) : undefined
let reload = false
let reloadFailure: LoaderOutcome | undefined
try {
let configured
if (match.status === 'success') {
configured = route.options.shouldReload
if (typeof configured === 'function') {
configured = configured(
getLoaderContext(
router,
lane,
match,
route,
options[0],
semanticParent,
preload,
),
)
}
if (!options[2]()) {
options[0].abort()
reloadFailure = [CANCELED]
}
}
if (!reloadFailure) {
if (match.status !== 'success') {
reload = true
} else {
const staleAge =
options[4] || match.preload
? (route.options.preloadStaleTime ??
router.options.defaultPreloadStaleTime ??
30_000)
: (route.options.staleTime ?? router.options.defaultStaleTime ?? 0)
reload = !!(
match.invalid ||
configured ||
(configured === undefined &&
Date.now() - match.updatedAt >= staleAge &&
(options[6] ||
match.cause === 'enter' ||
options[3].some(
(candidate) =>
candidate.routeId === match.routeId &&
candidate.id !== match.id,
)))
)
}
}
} catch (cause) {
match.invalid = true
releaseFlight(router, match)
reloadFailure = normalizeLaneError(route, cause, options)
}
const routeLoader = route.options.loader
const loader =
typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler
const background = !!(
routeLoader &&
reload &&
match.status === 'success' &&
!preload &&
!options[5] &&
((typeof routeLoader === 'function'
? undefined
: routeLoader?.staleReloadMode) ??
router.options.defaultStaleReloadMode) !== 'blocking'
)
const loaded = reload && (!preload || route.options.preload !== false)
const blocking =
loaded && !background && (match.status !== 'success' || !!routeLoader)
if (loaded && !routeLoader) {
match.invalid = false
match.updatedAt = Date.now()
}
let donor = loaded && routeLoader ? router._flights?.get(match.id) : undefined
if (donor === match._flight) {
donor = undefined
} else if (donor) {
donor[2]++
}
if (blocking) {
const acceptedFlight = match._flight
match._flight = donor
releaseOwnedFlight(router, match.id, acceptedFlight)?.abort()
// A successful route without a loader has no blocking work to present. It
// still gets a task so its chunk and derived assets participate in the
// lane, but putting it back into pending would hide an already-rendered
// ancestor while only a descendant is loading.
if (match.status === 'success') {
match.status = 'pending'
}
options[8]?.()
}
if (!loaded) {
match.isFetching = false
}
const rawOutcome = reloadFailure
? Promise.resolve(reloadFailure)
: !blocking
? Promise.resolve<LoaderOutcome>([SUCCESS, match.loaderData])
: loadResource(
router,
lane,
match,
route,
loader,
semanticParent,
preload,
options[0],
)
const outcome = rawOutcome.then((result) => {
if (blocking) {
settleInto(match, result, preload)
if (result[0] === SUCCESS) {
if (preload && routeLoader) {
cacheLoaderMatch(router, match, plannedCacheMatch)
}
// A route is renderable only after both its data and normal component
// chunk are ready. Its loader data is already available to descendants.
match.status = 'pending'
}
}
return result
})
const rawChunkFailure = waitFor(
Promise.resolve().then(() => loadRouteChunk(route)),
options[0].signal,
).then(
() => {
options[8]?.()
return undefined
},
(cause): IndexedOutcome => [
index,
normalizeLaneError(route, cause, options),
],
)
const chunkFailure = rawChunkFailure.then((failure) =>
outcome.then((result) => {
if (
blocking &&
!failure &&
result[0] === SUCCESS &&
match.status === 'pending' &&
options[2]()
) {
match.status = 'success'
options[8]?.()
}
return failure
}),
)
tasks.push([index, outcome, chunkFailure])
if (!background) {
return outcome.then((result) => getParentSnapshot(match, result))
}
const candidate: WorkMatch = {
...match,
status: 'pending',
preload: false,
_flight: donor,
}
match.invalid = false
match.isFetching = 'loader'
const backgroundOutcome = loadResource(
router,
lane,
candidate,
route,
loader,
semanticParent,
false,
options[0],
).then((result) => {
match.isFetching = false
settleInto(candidate, result, false)
return result
})
;(lane[2] ??= []).push([index, backgroundOutcome, chunkFailure, candidate])
return backgroundOutcome.then((result) =>
getParentSnapshot(candidate, result),
)
}
async function getNotFoundBoundary(
router: AnyRouter,
matches: Array<WorkMatch>,
indexed: IndexedOutcome | undefined,
signal: AbortSignal,
fallback = 0,
): Promise<number> {
const cause = indexed?.[1][1] as NotFoundError | undefined
let index = cause?.routeId
? matches.findIndex((match) => match.routeId === cause.routeId)
: (indexed?.[0] ?? matches.length - 1)
if (index < 0) {
index = 0
}
for (let i = index; i >= 0; i--) {
const route = getRoute(router, matches[i]!)
const loading = loadRouteChunk(route, false)
if (loading) {
try {
await waitFor(loading, signal)
} catch (cause) {
if (cause === signal) {
throw cause
}
}
}
if (route.options.notFoundComponent) {
return i
}
}
return cause?.routeId ? index : fallback
}
function discardBackground(router: AnyRouter, lane: Lane<any>): void {
if (lane[2]) {
transferMatchResources(
router,
lane[2].map((task) => task[3]),
)
lane[2] = undefined
}
}
async function settleTasks(
tasks: Array<LoaderTask>,
serialFailure?: IndexedOutcome,
redirectTasks?: Array<BackgroundLoaderTask>,
gate?: number | Promise<number>,
): Promise<IndexedOutcome | undefined> {
let loaderFailure: IndexedOutcome | undefined
try {
await Promise.all(
tasks.map((task) =>
task[1].then(async (outcome) => {
const taskIndex = task[0]
if (gate && taskIndex >= (await gate)) {
return
}
if (outcome[0] >= REDIRECTED) {
throw [taskIndex, outcome] as IndexedOutcome
}
if (!loaderFailure && outcome[0] !== SUCCESS) {
loaderFailure = [taskIndex, outcome]
// Every started descendant must settle before an ordinary failure
// wins because a redirect from any of them remains control flow.
await Promise.all(
(redirectTasks ?? []).map((nextTask) => {
if (nextTask[0] <= taskIndex) {
return
}
return nextTask[1].then((nextOutcome) => {
if (nextOutcome[0] === REDIRECTED) {
throw [nextTask[0], nextOutcome] as IndexedOutcome
}
})
}),
)
}
}),
),
)
} catch (cause) {
return cause as IndexedOutcome
}
return serialFailure ?? loaderFailure
}
async function reduceLane(
router: AnyRouter,
lane: ContextualizedLane,
tasks: Array<LoaderTask>,
controller: AbortController,
redirects: number,
settlement: Promise<IndexedOutcome | undefined>,
onReady?: () => void,
): Promise<ReducedLane | ControlOutcome> {
const matches = lane[1]
let failure = await settlement
let redirectLimitExceeded = false
const plannedBoundary = matches.findIndex((match) => match._notFound)
const boundaryOf = (found: IndexedOutcome) =>
found[1][0] === NOT_FOUND
? getNotFoundBoundary(router, matches, found, controller.signal)
: found[0]
let readinessEnd = plannedBoundary < 0 ? matches.length : plannedBoundary
if ((failure?.[1][0] ?? 0) >= REDIRECTED) {
readinessEnd = 0
} else if (failure) {
readinessEnd = failure[2] ??= await boundaryOf(failure)
for (const task of tasks) {
if (task[0] >= readinessEnd) {
break
}
const outcome = await task[1]
// Presence means a loader previously succeeded, even with `undefined`.
if (
outcome[0] !== SUCCESS &&
outcome[0] < REDIRECTED &&
!('loaderData' in matches[task[0]]!)
) {
failure = [task[0], outcome]
readinessEnd = failure[2] = await boundaryOf(failure)
break
}
}
}
for (const task of tasks) {
if (task[0] >= readinessEnd) {
break
}
const chunkFailure = await task[2]
if (!chunkFailure) {
continue
}
failure = chunkFailure
break
}
if ((failure?.[1][0] ?? 0) >= REDIRECTED) {
const outcome = failure![1]
if (
outcome[0] !== REDIRECTED ||
outcome[1].options.reloadDocument ||
redirects < 20
) {
discardBackground(router, lane)
return outcome as ControlOutcome
}
redirectLimitExceeded = true
failure = [0, [ERROR, new Error('Too many redirects')]]
}
const boundary = failure
? (failure[2] ?? (await boundaryOf(failure)))
: plannedBoundary
if (boundary >= 0) {
const outcome = failure?.[1]
const kind = outcome?.[0]
const match = matches[boundary]!
const cause = outcome?.[1]
const install = () => {
if (outcome) {
match._notFound = undefined
if (kind === ERROR) {
match.status = 'error'
} else {
;(cause as NotFoundError).routeId = match.routeId
if (match.routeId === router.routeTree.id) {
match.status = 'success'
match._notFound = true
} else {
match.status = 'notFound'
}
}
match.error = cause
match.isFetching = false
}
}
install()
try {
await waitFor<unknown>(
outcome
? Promise.resolve().then(() =>
loadRouteChunk(
getRoute(router, match),
kind === ERROR ? 'errorComponent' : 'notFoundComponent',
),
)
: Promise.all([
loadRouteChunk(getRoute(router, match)),
loadRouteChunk(getRoute(router, match), 'notFoundComponent'),
]),
controller.signal,
)
} catch (cause) {
if (cause === controller.signal) {
discardBackground(router, lane)
return [CANCELED]
}
}
if (!outcome) {
match.status = 'success'
onReady?.()
} else if (redirectLimitExceeded) {
controller.abort()
await Promise.all([
...tasks.map((task) => task[1]),
...tasks.map((task) => task[2]),
...(lane[2] ?? []).map((task) => task[1]),
])
discardBackground(router, lane)
transferMatchResources(router, matches)
install()
}
}
return lane as ReducedLane
}
export async function projectLane(
router: AnyRouter,
lane: ReducedLane,
signal: AbortSignal,
start = 0,
end = lane[1].length,
): Promise<ProjectedLane> {
const matches = lane[1]
for (let index = start; index < end; index++) {
const match = matches[index]!
const routeOptions = getRoute(router, match).options
if (routeOptions.head || routeOptions.scripts) {
try {
const context = {
ssr: router.options.ssr,
matches,
match,
params: match.params,
loaderData: match.loaderData,
}
const [head, scripts] = await waitFor(
Promise.all([
routeOptions.head?.(context),
routeOptions.scripts?.(context),
]),
signal,
)
match.meta = head?.meta
match.links = head?.links
match.headScripts = head?.scripts
match.styles = head?.styles
match.scripts = scripts
} catch (cause) {
if (cause === signal) {
break
}
console.error(cause)
}
}
if (match.status !== 'success' || match._notFound) {
break
}
}
return lane as ProjectedLane
}
async function executeClientLane(
router: AnyRouter,
location: ParsedLocation,
matches: Array<AnyRouteMatch>,
options: ExecuteLaneOptions,
): Promise<LaneResult> {
const matched = [location, matches as Array<WorkMatch>] as MatchedLane
let plannedBoundary = matches.findIndex((match) => match._notFound)
if (router.options.notFoundMode !== 'root' && plannedBoundary >= 0) {
const boundary = await getNotFoundBoundary(
router,
matched[1],
undefined,
options[0].signal,
plannedBoundary,
)
if (boundary !== plannedBoundary) {
matches[plannedBoundary]!._notFound = undefined
matches[boundary]!._notFound = true
}
plannedBoundary = boundary
}
let end = plannedBoundary < 0 ? matches.length : plannedBoundary + 1
// From here on `matched` is contextualized: `contextualize` communicates
// through mutation plus a failure return, so the phase brand is asserted at
// the two use sites below rather than granted by a (byte-costing) return.
const failure = await contextualize(router, matched, options, end)
if (failure) {
options[5] = true
}
const tasks: Array<LoaderTask> = []
const start = options[7] ?? 0
let semanticParent = start
? Promise.resolve(matched[1][start - 1]!)
: undefined
end = failure?.[0] ?? end
if (failure?.[1][0] === NOT_FOUND) {
failure[2] = await getNotFoundBoundary(
router,
matched[1],
failure,
options[0].signal,
)
end = Math.min(end, failure[2] + 1)
} else if ((failure?.[1][0] ?? 0) >= REDIRECTED) {
end = 0
}
for (let index = start; index < end; index++) {
if (options[0].signal.aborted) {
break
}
semanticParent = createLoaderTask(
router,
matched as ContextualizedLane,
index,
tasks,
semanticParent,
options,
)
}
let reduced: ReducedLane | ControlOutcome
try {
const reduction = reduceLane(
router,
matched as ContextualizedLane,
tasks,
options[0],
options[1],
settleTasks(tasks, failure, matched[2]),
options[8],
)
if (matched[2]?.length) {
matched[3] = settleTasks(
matched[2],
undefined,
undefined,
reduction.then(
(foreground) =>
isControl(foreground)
? 0
: _getRenderedMatches(foreground[1]).length,
() => 0,
),
)
}
reduced = await reduction
} catch (cause) {
discardBackground(router, matched)
throw cause
}
if (isControl(reduced)) {
return reduced
}
return projectLane(
router,
reduced,
options[0].signal,
options[7] === reduced[1].length ? options[7] : 0,
)
}
/**
* Finds the first route that should show pending UI and its two timing values.
* A fallback already on screen remains selected after its route loads, so we
* do not jump to a child fallback. Matches put back into pending by invalidation
* skip pendingMs, and a route without a usable fallback blocks pending UI for deeper routes.
*/
function pendingConfig(
router: AnyRouter,
matches: Array<AnyRouteMatch>,
):
| [delay: number, boundary: number, min: number, component: unknown]
| undefined
| void {
const presented = router.stores.matches.get()
for (let index = 0; index < matches.length; index++) {
const match = matches[index]!
const success = match.status === 'success'
const visible =
success &&
presented[index]?.id === match.id &&
presented[index]?.status === 'pending'
if (success && !visible) {
continue
}
const route = getRoute(router, match as WorkMatch)
const delay =
visible || match.invalid
? 0
: (route.options.pendingMs ?? router.options.defaultPendingMs)
const component =
route.options.pendingComponent ??
(router.options as any).defaultPendingComponent
return component && typeof delay === 'number' && delay !== Infinity
? [
delay,
index,
route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0,
component,
]
: undefined
}
}
/**
* Waits for `pendingMs`, then presents the complete lane. Rendering applies the
* selected boundary cutoff while retaining every match's structural state.
* A replacement load for the same match keeps the timer; choosing a different
* match resets it. `pendingMinMs` starts after the fallback renders.
*/
function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void {
if (router._tx !== tx) {
return
}
let session = router._pending
let tookOver = false
const sessionMatchId = session?.[0][3][session[1]]?.id
if (session?.[0] !== tx) {
if (session && tx[3][session[1]]?.id === sessionMatchId) {
session[0] = tx
tookOver = true
} else {
clearTimeout(session?.[3])
router._pending = session = undefined
}
}
const config = pendingConfig(router, tx[3])
if (!config) {
return
}
const [delay, boundary, min, component] = config
const matchId = tx[3][boundary]!.id
if (!session || session[1] !== boundary || sessionMatchId !== matchId) {
// Hydration and redirects can preserve pending presentation without a session.
// Do not delay it again; conservatively start pendingMinMs from now.
clearTimeout(session?.[3])
const presented = router.stores.matches.get()[boundary]
const visible = presented?.id === matchId && presented.status === 'pending'
router._pending = session = [
tx,
boundary,
visible ? Date.now() + min : tx[4] + delay,
undefined,
visible ? Promise.resolve(true) : undefined,
component,
]
}
if (session[4] && !tookOver && session[5] === component) {
return
}
session[5] = component
if (!session[4]) {
clearTimeout(session[3])
const remaining = session[2] - Date.now()
if (remaining > 0) {
session[3] = setTimeout(() => {
offerPending(router, tx)
}, remaining)
return
}
session[2] = 0
}
const offered = tx[3].map((match) => ({
...match,
_flight: undefined,
}))
offered[boundary]!.status = 'pending'
const ack = router
.startTransition(() => router.stores.setMatches(offered), offered, true)
.then((rendered) => {
if (
rendered &&
router._pending === session &&
session[4] === ack &&
!session[2]
) {
session[2] = Date.now() + min
}
return rendered
})
session[4] = ack
}
/**
* Cancels pending UI timing when its load ends. The ownership check prevents
* an older, superseded load from clearing pending UI that a newer load took over.
*/
function finishPending(router: CoordinatorRouter, tx: LoadTransaction): void {
const session = router._pending
if (session?.[0] === tx) {
clearTimeout(session[3])
router._pending = undefined
}
}
function publishMatches(
router: CoordinatorRouter,
matches: Array<AnyRouteMatch>,
): void {
router._committed = matches
router.stores.setMatches(matches)
}
function discardLane(router: AnyRouter, lane: ProjectedLane): void {
transferMatchResources(router, lane[1])
discardBackground(router, lane)
}
function commitMatches(
router: CoordinatorRouter,
tx: LoadTransaction,
matches: LaneMatches<'projected'>,
resolvedPrefix?: number,
): void {
const previous = router._committed
const previousCached = router._cache
for (const match of matches) {
match.preload = false
if (resolvedPrefix) {
match._assetEnd = undefined
}
}
const cut = _getRenderedMatches(matches).length
const cached = new Map<string, AnyRouteMatch>()
const now = Date.now()
for (const match of [...previous, ...previousCached.values()]) {
// Rendered-prefix ids and settled successes anywhere in the lane are
// authoritative: retaining an older same-id generation would shadow them
// at the next planning pass. Unsettled beyond-boundary matches are not —
// they must not evict a newer same-id preload.
if (
match.status !== 'success' ||
matches.some(
(candidate, index) =>
candidate.id === match.id &&
(index < cut || candidate.status === 'success'),
)
) {
continue
}
const work = match as WorkMatch
const route = getRoute(router, work)
if (
!route.options.loader ||
now - match.updatedAt >=
(match.preload
? (route.options.preloadGcTime ??
router.options.defaultPreloadGcTime ??
300_000)
: (route.options.gcTime ?? router.options.defaultGcTime ?? 300_000))
) {
continue
}
cached.set(
match.id,
previousCached.get(match.id) === match
? match
: ({
...match,
_flight: undefined,
isFetching: false,
context: {},
} as WorkMatch),
)
}
// The lane becomes committed before publication can synchronously reenter.
tx[3] = []
router._cache = cached
publishMatches(router, matches)
transferMatchResources(
router,
[...previousCached.values(), ...previous],
[...matches, ...cached.values()],
)
runRouteLifecycle(router, previous, matches, () => router._tx === tx)
}
function commitRefreshMatches(
router: CoordinatorRouter,
tx: LoadTransaction,
matches: LaneMatches<'projected'>,
checkpoint: PublicationCheckpoint,
): void {
const previous = router._committed
const previousCached = router._cache
for (const match of matches) {
match.preload = false
}
const cached = new Map<string, AnyRouteMatch>()
// Delay releasing the previous owners until the HMR render is acknowledged.
// Old generations must not become reusable cache entries after refresh.
tx[3] = []
router._cache = cached
checkpoint.previousMatches = previous
checkpoint.previousCache = previousCached
checkpoint.published = true
publishMatches(router, matches)
if (!checkpoint.published || router._tx !== tx) {
return
}
runRouteLifecycle(router, previous, matches, () => router._tx === tx)
}
function settlePublication(
router: CoordinatorRouter,
checkpoint: PublicationCheckpoint,
): void {
if (!checkpoint.published) {
return
}
checkpoint.published = false
transferMatchResources(
router,
[...checkpoint.previousCache.values(), ...checkpoint.previousMatches],
[...router._cache.values(), ...router._committed],
)
}
function rollbackPublication(
router: CoordinatorRouter,
tx: LoadTransaction,
lane: ProjectedLane,
checkpoint: PublicationCheckpoint,
): boolean {
if (
!checkpoint.published ||
router._tx !== tx ||
router._committed !== lane[1]
) {
settlePublication(router, checkpoint)
return false
}
const discarded = [...router._cache.values(), ...router._committed]
const restored = [
...checkpoint.previousCache.values(),
...checkpoint.previousMatches,
]
router._cache = checkpoint.previousCache
router._committed = checkpoint.previousMatches
checkpoint.published = false
for (const match of discarded as Array<WorkMatch>) {
if (
!restored.includes(match) &&
match._flight &&
router._flights?.get(match.id) === match._flight
) {
router._flights.delete(match.id)
}
}
finishPending(router, tx)
router.batch(() => {
router.stores.status.set('idle')
router.stores.setMatches(checkpoint.previousPresentation)
})
tx[0].abort()
transferMatchResources(router, discarded, restored)
discardBackground(router, lane)
if (router._tx === tx && router._commitPromise === checkpoint.commitPromise) {
router._commitPromise?.resolve()
router._commitPromise = undefined
}
return true
}
async function transitionRefresh(
router: CoordinatorRouter,
tx: LoadTransaction,
lane: ProjectedLane,
changeInfo: ReturnType<typeof getLocationChangeInfo>,
): Promise<boolean | undefined> {
const checkpoint: PublicationCheckpoint = {
previousMatches: router._committed,
previousPresentation: tx[6]?.[0] ?? router.stores.matches.get(),
previousCache: router._cache,
commitPromise: router._commitPromise,
published: false,
}
const commit = () => {
finishPending(router, tx)
router._rollbackRefresh = rollback
commitRefreshMatches(router, tx, lane[1], checkpoint)
if (!checkpoint.published || router._tx !== tx) {
return
}
router.emit({ type: 'onLoad', ...changeInfo })
if (router._tx === tx) {
router.emit({ type: 'onBeforeRouteMount', ...changeInfo })
}
}
const rollback = () => {
if (router._rollbackRefresh === rollback) {
router._rollbackRefresh = undefined
}
const restored = rollbackPublication(router, tx, lane, checkpoint)
router._cancelTransition?.()
return restored
}
try {
const rendered = await router.startTransition(commit, lane[1])
if (router._rollbackRefresh === rollback) {
router._rollbackRefresh = undefined
}
if (checkpoint.published) {
const handoff = tx[6]?.[1]
if (handoff && router._handoff === handoff) {
handoff[1]()
}
if (router._tx === tx) {
tx[6] = undefined
}
}
settlePublication(router, checkpoint)
return rendered
} catch (cause) {
if (rollback()) {
return
}
throw cause
}
}
async function awaitCurrent(
router: CoordinatorRouter,
owner?: LoadTransaction,
): Promise<void> {
let current = router._tx
while (current && current !== owner) {
await current[5]
if (router._tx === current) {
return
}
current = router._tx
}
}
async function followRedirect(
router: CoordinatorRouter,
tx: LoadTransaction,
redirect: AnyRedirect,
): Promise<void> {
await router.navigate({
...redirect.options,
replace: true,
ignoreBlocker: true,
_redirects: tx[1] + 1,
} as any)
}
function restoreCommitted(
router: CoordinatorRouter,
tx: LoadTransaction,
): void {
finishPending(router, tx)
tx[0].abort()
transferMatchResources(router, tx[3])
tx[3] = []
if (router._tx !== tx) {
return
}
router.batch(() => {
router.stores.status.set('idle')
router.stores.setMatches(router._committed)
})
if (router._tx === tx) {
router._commitPromise?.resolve()
router._commitPromise = undefined
}
}
async function runBackground(
router: CoordinatorRouter,
tx: LoadTransaction,
base: Array<AnyRouteMatch>,
tasks: Array<BackgroundLoaderTask>,
settlement: Promise<IndexedOutcome | undefined>,
): Promise<void> {
const next = base.map((match) => ({ ...match }))
acquireMatchResources(next)
for (const task of tasks) {
releaseFlight(router, next[task[0]]!)
next[task[0]] = task[3]
}
// Phase jump: the clones inherit beforeLoad context from the committed
// foreground lane, which already ran `contextualize` for these matches.
const lane = [tx[2], next] as ContextualizedLane
let reduced: ReducedLane | ControlOutcome
try {
reduced = await reduceLane(router, lane, tasks, tx[0], tx[1], settlement)
} catch (cause) {
transferMatchResources(router, next)
throw cause
}
if (isControl(reduced)) {
transferMatchResources(router, next)
if (
reduced[0] === REDIRECTED &&
router._tx === tx &&
router._committed === base
) {
await followRedirect(router, tx, reduced[1])
}
return
}
const projected = await projectLane(router, reduced, tx[0].signal)
if (router._tx !== tx || router._committed !== base) {
transferMatchResources(router, projected[1])
return
}
for (const match of projected[1] as Array<WorkMatch>) {
const cached = router._cache.get(match.id) as WorkMatch | undefined
if (cached?._flight && cached._flight === match._flight) {
router._cache.delete(match.id)
releaseFlight(router, cached)
}
}
publishMatches(router, projected[1])
transferMatchResources(router, base, projected[1])
}
async function runClientTransaction(
router: CoordinatorRouter,
tx: LoadTransaction,
forceStaleReload: boolean,
onReady?: () => void,
sync?: boolean,
resolvedPrefix?: number,
adopted?: ActivePreload,
retained?: ActivePreload,
): Promise<void> {
con