@tanstack/router-core
Version:
Modern and scalable routing for React applications
1,719 lines (1,644 loc) • 73.7 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 { getLocationChangeInfo, runRouteLifecycle } from './router'
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,
onPendingReady?: () => void,
): Promise<void> | undefined {
const component = preloadComponent(route, 'component')
const pending = preloadComponent(route, 'pendingComponent')
const pendingReady =
onPendingReady && pending ? pending.then(onPendingReady) : pending
if (onPendingReady && !pending) {
onPendingReady()
}
if (component && pendingReady) {
return Promise.all([component, pendingReady]).then(() => {})
}
return component ?? pendingReady
}
export function loadRouteChunk(
route: AnyRoute,
// `false` waits only for lazy route options, before a boundary is selected.
componentType?: 'errorComponent' | 'notFoundComponent' | false,
onPendingReady?: () => void,
): Promise<void> | undefined {
const afterLazy = () =>
componentType === false
? undefined
: componentType
? preloadComponent(route, componentType)
: loadComponents(route, onPendingReady)
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
const CANCELED_OUTCOME: [kind: typeof CANCELED] = [CANCELED]
type RedirectOutcome = [
kind: typeof REDIRECTED,
redirect: AnyRedirect,
location?: ParsedLocation,
]
type NonRedirectOutcome =
| [kind: typeof SUCCESS, data: unknown]
| [kind: typeof ERROR, error: unknown]
| [kind: typeof NOT_FOUND, error: NotFoundError]
| [kind: typeof CANCELED]
type RawLoaderOutcome =
| NonRedirectOutcome
| [kind: typeof REDIRECTED, redirect: AnyRedirect]
type LoaderOutcome = NonRedirectOutcome | RedirectOutcome
type IndexedOutcome = [index: number, outcome: LoaderOutcome, boundary?: number]
export type LoaderFlight = [
outcome: Promise<RawLoaderOutcome>,
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 LoadTransaction = [
controller: AbortController,
redirects: number,
location: ParsedLocation,
matches: Array<AnyRouteMatch>,
startedAt: number,
done: Promise<void>,
/**
* Dev-only HMR refresh mode. Presence forces successor rematerialization
* until this publication is acknowledged. The optional hydration handoff is
* retired when the refresh publishes.
*/
refresh?: [handoff: NonNullable<AnyRouter['_handoff']> | undefined],
]
export type PendingSession = [
generation: LoadTransaction,
boundaryId: string,
/** Pending reveal time until acknowledged, then minimum-visible-until time. */
deadline: number,
revealTimer?: ReturnType<typeof setTimeout>,
ack?: Promise<boolean> | true,
component?: unknown,
]
type CoordinatorRouter = AnyRouter & {
/** Active speculative lanes retained for cancellation, invalidation, and cache clearing. */
_preloads?: Map<AbortController, Array<AnyRouteMatch>>
_refreshNextLoad?: boolean
}
type LoaderTask = [
index: number,
outcome: Promise<LoaderOutcome>,
chunkFailure: Promise<IndexedOutcome | undefined>,
candidate?: WorkMatch,
]
type BackgroundLoaderTask = [
index: number,
outcome: Promise<LoaderOutcome>,
chunkFailure: Promise<IndexedOutcome | undefined>,
candidate: WorkMatch,
]
type ExecuteLaneOptions = [
controller: AbortController,
redirects: number,
base: Array<AnyRouteMatch>,
preload?: boolean,
sync?: boolean,
forceStaleReload?: boolean,
resolvedPrefix?: number,
onReady?: () => void,
]
type ControlOutcome = RedirectOutcome | [kind: typeof CANCELED]
type LaneResult = ProjectedLane | ControlOutcome
function isControl(
result: Lane<any> | ControlOutcome,
): result is ControlOutcome {
return typeof result[0 /* location or kind */] === '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,
): RawLoaderOutcome {
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): RawLoaderOutcome {
let outcome = normalize(cause, true, route.id)
if (outcome[0 /* kind */] !== ERROR) {
return outcome
}
try {
route.options.onError?.(outcome[1 /* error */])
} catch (onErrorCause) {
outcome = normalize(onErrorCause, true, route.id)
}
return outcome
}
function normalizeLaneError(
router: AnyRouter,
lane: Lane<any>,
route: AnyRoute,
cause: unknown,
options: ExecuteLaneOptions,
): LoaderOutcome {
if (options[0 /* controller */].signal.aborted) {
return CANCELED_OUTCOME
}
return materializeRedirect(
router,
lane,
route,
normalizeError(route, cause),
options,
)
}
async function contextualize(
router: AnyRouter,
lane: MatchedLane,
options: ExecuteLaneOptions,
end: number,
planSuccessfulLane: () => void,
retainedEnd: number,
): Promise<IndexedOutcome | undefined> {
const [location, matches] = lane
const signal = options[0 /* controller */].signal
const preload = !!options[3 /* preload */]
for (let index = options[6 /* resolvedPrefix */] ?? 0; index < end; index++) {
const match = matches[index]!
const route = getRoute(router, match)
match.abortController = options[0 /* controller */]
// 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: (opts: any) =>
router.navigate({
...opts,
_fromLocation: location,
}),
buildLocation: router.buildLocation,
cause: preload ? ('preload' as const) : match.cause,
abortController: options[0 /* controller */],
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(router, lane, route, cause, options)]
}
if (signal.aborted) {
return [index, CANCELED_OUTCOME]
}
const validationError = match.paramsError ?? match.searchError
if (validationError !== undefined) {
releaseFlight(router, match)
return [
index,
normalizeLaneError(router, lane, 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 (index >= retainedEnd) {
match.status = 'pending'
options[7 /* onReady */]?.()
}
try {
setFetching(router, match, 'beforeLoad', options[0 /* controller */])
const result = await waitFor(beforeLoad(beforeLoadContext), signal)
if (signal.aborted) {
return [index, CANCELED_OUTCOME]
}
const outcome = materializeRedirect(
router,
lane,
route,
normalize(result, false, route.id),
options,
)
if (outcome[0 /* kind */] !== SUCCESS) {
releaseFlight(router, match)
return [index, outcome]
}
match.context = {
...context,
...result,
}
} catch (cause) {
releaseFlight(router, match)
return [index, normalizeLaneError(router, lane, route, cause, options)]
} finally {
if (match.status === 'pending') {
match.status = previousStatus
}
setFetching(router, match, false, options[0 /* controller */])
}
}
// Let a synchronous lane claim predecessor flights before this frame yields.
planSuccessfulLane()
return
}
function releaseOwnedFlight(
router: AnyRouter,
match: WorkMatch,
flight?: LoaderFlight,
): AbortController | undefined {
if (!flight || --flight[2 /* leases */]) {
return
}
if (router._flights?.get(match.id) === flight) {
const current = router._tx
if (
current &&
!current[0 /* controller */].signal.aborted &&
!current[3 /* matches */].includes(match) &&
current[3 /* matches */].some((candidate) => candidate.id === match.id) &&
current[3 /* matches */].some(
(candidate) => candidate.isFetching === 'beforeLoad',
)
) {
// Keep work discoverable only while the current lane is still running
// beforeLoad. Loader planning performs the matching zero-owner sweep.
return
}
router._flights.delete(match.id)
}
return flight[1 /* controller */]
}
function releaseFlight(router: AnyRouter, match: WorkMatch): void {
const flight = match._flight
match._flight = undefined
releaseOwnedFlight(router, match, flight)?.abort()
}
/**
* Not passing in a `next` ownership recipient
* is equivalent to discarding the match resources
*/
function transferMatchResources(
router: AnyRouter,
previous: Array<AnyRouteMatch>,
next?: Array<AnyRouteMatch>,
deferSameIdFlight?: true,
): void {
const abort: Array<AbortController> = []
for (const match of previous as Array<WorkMatch>) {
if (!next?.includes(match)) {
const flight = match._flight
match._flight = undefined
if (
deferSameIdFlight &&
flight?.[2 /* leases */] === 1 &&
router._flights?.get(match.id) === flight &&
next?.some((candidate) => candidate.id === match.id)
) {
// The successor has not made its same-ID reload decision yet.
flight[2 /* leases */] = 0
} else {
const controller = releaseOwnedFlight(router, match, flight)
if (controller) {
abort.push(controller)
}
}
}
}
for (const controller of abort) {
controller.abort()
}
}
function acquireMatchResources(matches: Array<AnyRouteMatch>): void {
for (const match of matches as Array<WorkMatch>) {
const flight = match._flight
if (flight) {
flight[2 /* leases */]++
}
}
}
function setFetching(
router: AnyRouter,
match: WorkMatch,
value: AnyRouteMatch['isFetching'],
owner?: AbortController,
): void {
match.isFetching = value
if (owner && router._tx?.[0 /* controller */] !== 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 /* location */]
return {
params: match.params,
location,
navigate: (opts: any) =>
router.navigate({
...opts,
_fromLocation: 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,
options: ExecuteLaneOptions,
): Promise<LoaderOutcome> {
const owner = options[0 /* controller */]
const signal = owner.signal
if (signal.aborted) {
return CANCELED_OUTCOME
}
if (!loader) {
return [SUCCESS, undefined]
}
let flight = match._flight
setFetching(router, match, 'loader', owner)
try {
if (!flight) {
const controller = new AbortController()
flight = [
Promise.resolve()
.then(() =>
loader(
getLoaderContext(
router,
lane,
match,
route,
controller,
parentMatchPromise,
!!options[3 /* preload */],
),
),
)
.then(
(value) => normalize(value, false, route.id),
(cause) => normalize(cause, true, route.id),
)
.then((result): RawLoaderOutcome => {
// The registry controls discovery; leases keep current consumers
// sharing the same terminal outcome.
if (
result[0 /* kind */] !== SUCCESS &&
router._flights?.get(match.id) === flight
) {
router._flights!.delete(match.id)
if (!flight![2 /* leases */]) {
controller.abort()
}
}
return result[0 /* kind */] === ERROR && flight![2 /* leases */]
? normalizeError(route, result[1 /* error */])
: result
}),
controller,
1,
]
;(router._flights ??= new Map()).set(match.id, flight)
}
match._flight = flight
match.abortController = flight[1 /* controller */]
return materializeRedirect(
router,
lane,
route,
await waitFor(flight[0 /* outcome */], signal),
options,
)
} catch (cause) {
if (cause !== signal || !signal.aborted) {
throw cause
}
releaseFlight(router, match)
return CANCELED_OUTCOME
} finally {
setFetching(router, match, false, owner)
}
}
function settleInto(
match: WorkMatch,
result: LoaderOutcome,
preload: boolean,
): asserts match is SettledMatch {
if (result[0 /* kind */] === SUCCESS) {
match.loaderData = result[1 /* data */]
match.error = undefined
match.status = 'success'
match.invalid = false
match.updatedAt = Date.now()
match.preload = preload
} else if (result[0 /* kind */] !== 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 /* leases */]++
}
router._cache.set(match.id, cached)
if (current) {
releaseFlight(router, current)
}
}
function getParentSnapshot(
match: WorkMatch,
outcome: LoaderOutcome,
): WorkMatch {
if (outcome[0 /* kind */] === ERROR || outcome[0 /* kind */] === NOT_FOUND) {
return {
...match,
status: outcome[0 /* kind */] === ERROR ? 'error' : 'notFound',
error: outcome[1 /* error */],
_flight: undefined,
}
}
return match
}
function createLoaderTask(
router: AnyRouter,
lane: ContextualizedLane,
index: number,
tasks: Array<LoaderTask>,
semanticParent: Promise<WorkMatch> | undefined,
options: ExecuteLaneOptions,
retainedEnd: number,
): Promise<WorkMatch> {
const match = lane[1 /* matches */][index]!
const route = getRoute(router, match)
const preload = !!options[3 /* preload */]
const plannedCacheMatch = router._cache.get(match.id)
let configured
let reload = false
let reloadFailure: LoaderOutcome | undefined
try {
if (match.status === 'success') {
configured = route.options.shouldReload
if (typeof configured === 'function') {
configured = configured(
getLoaderContext(
router,
lane,
match,
route,
options[0 /* controller */],
semanticParent,
preload,
),
)
}
if (options[0 /* controller */].signal.aborted) {
reloadFailure = CANCELED_OUTCOME
}
}
if (!reloadFailure) {
if (match.status !== 'success') {
reload = true
} else {
const staleAge =
options[3 /* preload */] || 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[5 /* forceStaleReload */] ||
match.cause === 'enter' ||
options[2 /* base */].some(
(candidate) =>
candidate.routeId === match.routeId &&
candidate.id !== match.id,
)))
)
}
}
} catch (cause) {
match.invalid = true
releaseFlight(router, match)
reloadFailure = normalizeLaneError(router, lane, route, cause, options)
}
const routeLoader = route.options.loader
const loader =
typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler
let donor =
(!preload || route.options.preload !== false) &&
routeLoader &&
!(process.env.NODE_ENV !== 'production' && router._tx?.[6 /* refresh */])
? router._flights?.get(match.id)
: undefined
if (donor === match._flight || reloadFailure) {
donor = undefined
} else if (donor && !reload && !preload && configured === undefined) {
// Normal cache policy accepts an already-running generation even when this
// lane itself would not have started another loader.
reload = true
} else if (!reload) {
donor = undefined
}
const background = !!(
routeLoader &&
reload &&
match.status === 'success' &&
!preload &&
!options[4 /* sync */] &&
((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)
const onReady = index >= retainedEnd ? options[7 /* onReady */] : undefined
const onLazyReady = route.lazyFn && route._lazy !== true ? onReady : undefined
if (loaded && !routeLoader) {
match.invalid = false
match.updatedAt = Date.now()
}
if (donor) {
donor[2 /* leases */]++
}
if (blocking) {
const acceptedFlight = match._flight
match._flight = donor
releaseOwnedFlight(router, match, acceptedFlight)?.abort()
// A mounted success remains renderable while its loader revalidates. Every
// non-retained blocking generation presents pending state.
if (index >= retainedEnd) {
match.status = 'pending'
}
onReady?.()
}
if (!loaded) {
match.isFetching = false
}
const loaderOutcome = reloadFailure
? Promise.resolve(reloadFailure)
: !blocking
? Promise.resolve<LoaderOutcome>([SUCCESS, match.loaderData])
: loadResource(
router,
lane,
match,
route,
loader,
semanticParent,
options,
)
const outcome = loaderOutcome.then((result) => {
if (blocking) {
settleInto(match, result, preload)
if (result[0 /* kind */] === SUCCESS) {
// A settled generation can outlive its lane without keeping unresolved
// navigation work alive.
if (routeLoader && !options[0 /* controller */].signal.aborted) {
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.
if (index >= retainedEnd) {
match.status = 'pending'
}
}
}
return result
})
const chunkOutcome = waitFor(
Promise.resolve().then(() => loadRouteChunk(route, undefined, onLazyReady)),
options[0 /* controller */].signal,
).then(
() => undefined,
(cause): IndexedOutcome | undefined =>
lane[1 /* matches */].some(
(candidate, candidateIndex) =>
candidateIndex <= index &&
(candidate.status === 'error' ||
candidate.status === 'notFound' ||
candidate._notFound),
)
? undefined
: [index, normalizeLaneError(router, lane, route, cause, options)],
)
const chunkFailure = chunkOutcome.then((failure) =>
outcome.then((result) => {
if (
blocking &&
!failure &&
result[0 /* kind */] === SUCCESS &&
match.status === 'pending' &&
!options[0 /* controller */].signal.aborted
) {
match.status = 'success'
onReady?.()
}
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,
options,
).then((result) => {
match.isFetching = false
settleInto(candidate, result, false)
return result
})
;(lane[2 /* background */] ??= []).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 /* outcome */][1 /* error or redirect */] as
| NotFoundError
| undefined
let index = cause?.routeId
? matches.findIndex((match) => match.routeId === cause.routeId)
: (indexed?.[0 /* index */] ?? matches.length - 1)
if (index < 0) {
index = 0
}
for (let i = index; i >= 0; i--) {
const route = getRoute(router, matches[i]!)
try {
const loading = loadRouteChunk(route, false)
if (loading) {
await waitFor(loading, signal)
}
} catch (cause) {
if (cause === signal && signal.aborted) {
throw cause
}
}
if (route.options.notFoundComponent) {
return i
}
}
return cause?.routeId ? index : fallback
}
function discardBackground(router: AnyRouter, lane: Lane<any>): void {
if (lane[2 /* background */]) {
transferMatchResources(
router,
lane[2 /* background */].map((task) => task[3 /* candidate */]),
)
lane[2 /* background */] = 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 /* outcome */].then(async (outcome) => {
const taskIndex = task[0 /* index */]
if (gate && taskIndex >= (await gate)) {
return
}
if (outcome[0 /* kind */] >= REDIRECTED) {
throw [taskIndex, outcome] as IndexedOutcome
}
if (!loaderFailure && outcome[0 /* kind */] !== 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 /* index */] <= taskIndex) {
return
}
return nextTask[1 /* outcome */].then((nextOutcome) => {
if (nextOutcome[0 /* kind */] === REDIRECTED) {
throw [
nextTask[0 /* index */],
nextOutcome,
] as IndexedOutcome
}
})
}),
)
}
}),
),
)
} catch (cause) {
return cause as IndexedOutcome
}
return serialFailure ?? loaderFailure
}
function materializeRedirect(
router: AnyRouter,
lane: Lane<any>,
route: AnyRoute,
outcome: RawLoaderOutcome,
options: ExecuteLaneOptions,
failed?: true,
): LoaderOutcome {
while (outcome[0 /* kind */] === REDIRECTED) {
const redirect = outcome[1 /* redirect */]
if (
redirect.options.reloadDocument
? options[3 /* preload */]
: options[1 /* redirects */] >= 20
) {
return outcome
}
try {
if (redirect.options.href && redirect.options.reloadDocument) {
router.resolveRedirect(redirect)
return outcome
}
return [
REDIRECTED,
redirect,
router.buildLocation({
...redirect.options,
_fromLocation: lane[0 /* location */],
_includeValidateSearch: true,
}),
]
} catch (cause) {
outcome = failed ? [ERROR, cause] : normalizeError(route, cause)
failed = true
}
}
return outcome
}
async function reduceLane(
router: AnyRouter,
lane: ContextualizedLane,
tasks: Array<LoaderTask>,
controller: AbortController,
settlement: Promise<IndexedOutcome | undefined>,
onReady?: () => void,
): Promise<ReducedLane | ControlOutcome> {
const matches = lane[1 /* matches */]
let failure = await settlement
let redirectLimitExceeded = false
const plannedBoundary = matches.findIndex((match) => match._notFound)
const boundaryOf = (found: IndexedOutcome) =>
found[1 /* outcome */][0 /* kind */] === NOT_FOUND
? getNotFoundBoundary(router, matches, found, controller.signal)
: found[0 /* index */]
let readinessEnd = plannedBoundary < 0 ? matches.length : plannedBoundary
if ((failure?.[1 /* outcome */][0 /* kind */] ?? 0) >= REDIRECTED) {
readinessEnd = 0
} else if (failure) {
readinessEnd = failure[2 /* boundary */] ??= await boundaryOf(failure)
for (const task of tasks) {
if (task[0 /* index */] >= readinessEnd) {
break
}
const outcome = await task[1 /* outcome */]
// Presence means a loader previously succeeded, even with `undefined`.
if (
outcome[0 /* kind */] !== SUCCESS &&
outcome[0 /* kind */] < REDIRECTED &&
!('loaderData' in matches[task[0 /* index */]]!)
) {
failure = [task[0 /* index */], outcome]
readinessEnd = failure[2 /* boundary */] = await boundaryOf(failure)
break
}
}
}
for (const task of tasks) {
if (task[0 /* index */] >= readinessEnd) {
break
}
const chunkFailure = await task[2 /* chunkFailure */]
if (!chunkFailure) {
continue
}
failure = chunkFailure
break
}
if ((failure?.[1 /* outcome */][0 /* kind */] ?? 0) >= REDIRECTED) {
const outcome = failure![1 /* outcome */]
if (
outcome[0 /* kind */] !== REDIRECTED ||
outcome[1 /* redirect */].options.reloadDocument ||
outcome[2 /* location */]
) {
discardBackground(router, lane)
return outcome as ControlOutcome
}
redirectLimitExceeded = true
failure = [0, [ERROR, new Error('Too many redirects')]]
}
const boundary = failure
? (failure[2 /* boundary */] ?? (await boundaryOf(failure)))
: plannedBoundary
if (boundary >= 0) {
const outcome = failure?.[1 /* outcome */]
const kind = outcome?.[0 /* kind */]
const match = matches[boundary]!
const cause = outcome?.[1 /* error or redirect */]
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()
if (!outcome) {
onReady?.()
}
const route = getRoute(router, match)
try {
await waitFor<unknown>(
outcome
? Promise.resolve().then(() =>
loadRouteChunk(
route,
kind === ERROR ? 'errorComponent' : 'notFoundComponent',
),
)
: Promise.all([
loadRouteChunk(route),
loadRouteChunk(route, 'notFoundComponent'),
]),
controller.signal,
)
} catch (cause) {
if (cause === controller.signal && controller.signal.aborted) {
discardBackground(router, lane)
return CANCELED_OUTCOME
}
}
if (!outcome) {
match.status = 'success'
} else if (redirectLimitExceeded) {
controller.abort()
await Promise.all([
...tasks.map((task) => task[1 /* outcome */]),
...tasks.map((task) => task[2 /* chunkFailure */]),
...(lane[2 /* background */] ?? []).map(
(task) => task[1 /* outcome */],
),
])
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 /* matches */].length,
): Promise<ProjectedLane> {
const matches = lane[1 /* matches */]
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 && signal.aborted) {
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
const signal = options[0 /* controller */].signal
let reduced: ReducedLane | ControlOutcome
try {
const presented = router.stores.matches.get()
let plannedBoundary = matches.findIndex((match) => match._notFound)
if (router.options.notFoundMode !== 'root' && plannedBoundary >= 0) {
const boundary = await getNotFoundBoundary(
router,
matched[1 /* matches */],
undefined,
signal,
plannedBoundary,
)
if (boundary !== plannedBoundary) {
matches[plannedBoundary]!._notFound = undefined
matches[boundary]!._notFound = true
}
plannedBoundary = boundary
}
let end = plannedBoundary < 0 ? matches.length : plannedBoundary + 1
let retainedEnd = 0
while (retainedEnd < end && retainedEnd !== plannedBoundary) {
const match = matches[retainedEnd]!
const committed = options[2 /* base */][retainedEnd]
const visible = presented[retainedEnd]
if (
committed?.id !== match.id ||
committed.status !== 'success' ||
committed._notFound ||
match.preload ||
visible?.id !== match.id ||
visible.status !== 'success' ||
visible._notFound
) {
break
}
retainedEnd++
}
const tasks: Array<LoaderTask> = []
const start = options[6 /* resolvedPrefix */] ?? 0
let semanticParent = start
? Promise.resolve(matched[1 /* matches */][start - 1]!)
: undefined
const planSuccessfulLane = () => {
for (let index = start; index < end; index++) {
if (signal.aborted) {
break
}
semanticParent = createLoaderTask(
router,
matched as ContextualizedLane,
index,
tasks,
semanticParent,
options,
retainedEnd,
)
}
}
// 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,
planSuccessfulLane,
retainedEnd,
)
if (failure) {
options[4 /* sync */] = true
end = failure[0 /* index */]
if (failure[1 /* outcome */][0 /* kind */] === NOT_FOUND) {
const boundary = await getNotFoundBoundary(
router,
matched[1 /* matches */],
failure,
signal,
)
failure[2 /* boundary */] = boundary
end = Math.min(end, boundary + 1)
} else if (failure[1 /* outcome */][0 /* kind */] >= REDIRECTED) {
end = 0
}
planSuccessfulLane()
}
if (!signal.aborted && !options[3 /* preload */]) {
const abort: Array<AbortController> = []
for (const [id, flight] of router._flights ?? []) {
if (!flight[2 /* leases */]) {
router._flights!.delete(id)
abort.push(flight[1 /* controller */])
}
}
for (const controller of abort) {
controller.abort()
}
}
const reduction = reduceLane(
router,
matched as ContextualizedLane,
tasks,
options[0 /* controller */],
settleTasks(tasks, failure, matched[2 /* background */]),
options[7 /* onReady */],
)
if (matched[2 /* background */]?.length) {
matched[3 /* backgroundSettlement */] = settleTasks(
matched[2 /* background */],
undefined,
undefined,
reduction.then(
(foreground) =>
isControl(foreground)
? 0
: _getRenderedMatches(foreground[1 /* matches */]).length,
() => 0,
),
)
}
reduced = await reduction
} catch (cause) {
discardBackground(router, matched)
if (cause === signal && signal.aborted) {
return CANCELED_OUTCOME
}
throw cause
}
if (isControl(reduced)) {
return reduced
}
return projectLane(
router,
reduced,
signal,
options[6 /* resolvedPrefix */] === reduced[1 /* matches */].length
? options[6 /* resolvedPrefix */]
: 0,
)
}
/**
* 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
}
const matches = tx[3 /* matches */]
const presented = router.stores.matches.get()
let session = router._pending
for (let index = 0; index < matches.length; index++) {
const match = matches[index]!
const success = match.status === 'success' && !match._notFound
const presentedPending =
presented[index]?.id === match.id &&
presented[index]?.status === 'pending'
if (success && !presentedPending) {
continue
}
const route = getRoute(router, match as WorkMatch)
const delay =
(success && presentedPending) || match.invalid
? 0
: (route.options.pendingMs ?? router.options.defaultPendingMs)
const component =
route.options.pendingComponent ??
(router.options as any).defaultPendingComponent
if (!component || typeof delay !== 'number' || delay === Infinity) {
if (session) {
session[0 /* generation */] = tx
session[2 /* deadline */] = 0
session[4 /* ack */] = true
}
return
}
const min =
route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0
let tookOver = false
if (session?.[1 /* boundaryId */] === match.id) {
tookOver = session[0 /* generation */] !== tx
session[0 /* generation */] = tx
} else {
clearTimeout(session?.[3 /* revealTimer */])
router._pending = session = undefined
}
if (!session) {
// Hydration and redirects can preserve pending presentation without a session.
// Do not delay it again; conservatively start pendingMinMs from now.
router._pending = session = [
tx,
match.id,
presentedPending ? Date.now() + min : tx[4 /* startedAt */] + delay,
undefined,
presentedPending || undefined,
component,
]
}
if (
session[4 /* ack */] &&
!tookOver &&
session[5 /* component */] === component
) {
return
}
session[5 /* component */] = component
if (!session[4 /* ack */]) {
clearTimeout(session[3 /* revealTimer */])
const remaining = session[2 /* deadline */] - Date.now()
if (remaining > 0) {
session[3 /* revealTimer */] = setTimeout(
() => offerPending(router, tx),
remaining,
)
return
}
session[2 /* deadline */] = 0
}
const offered = matches.map((match) => ({
...match,
_flight: undefined,
}))
offered[index]!.status = 'pending'
const ack = (session[4 /* ack */] = router
.startTransition(() => router.stores.setMatches(offered), offered)
.then((rendered) => {
if (
rendered &&
router._pending === session &&
session![4 /* ack */] === ack &&
!session![2 /* deadline */]
) {
session![2 /* deadline */] = Date.now() + min
}
return rendered
}))
return
}
}
/**
* Cancels pending UI timing unless the current successor can take over the
* same boundary that remains painted.
*/
function finishPending(router: CoordinatorRouter, tx: LoadTransaction): void {
const session = router._pending
if (
router._tx === tx ||
!router._tx?.[3 /* matches */].some(
(match) => match.id === session?.[1 /* boundaryId */],
)
) {
clearTimeout(session?.[3 /* revealTimer */])
router._pending = undefined
}
}
async function awaitPendingMinimum(
router: CoordinatorRouter,
tx: LoadTransaction,
): Promise<void> {
const session = router._pending
if (!session) {
return
}
clearTimeout(session[3 /* revealTimer */])
const remaining = session[2 /* deadline */] - Date.now()
if (
!session[4 /* ack */] ||
remaining <= 0 ||
!_getRenderedMatches(tx[3 /* matches */]).some(
(match) => match.id === session[1 /* boundaryId */],
)
) {
return
}
let timer: ReturnType<typeof setTimeout> | undefined
try {
await waitFor(
new Promise<void>((resolve) => {
timer = setTimeout(resolve, remaining)
}),
tx[0 /* controller */].signal,
)
} catch {}
clearTimeout(timer)
}
function publishMatches(
router: CoordinatorRouter,
matches: Array<AnyRouteMatch>,
): void {
router._committed = matches
router.stores.setMatches(matches)
}
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>()
if (process.env.NODE_ENV === 'production' || !tx[6 /* refresh */]) {
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 /* matches */] = []
router._cache = cached
publishMatches(router, matches)
transferMatchResources(
router,
[...previousCached.values(), ...previous],
[...matches, ...cached.values()],
)
if (process.env.NODE_ENV !== 'production') {
const handoff = tx[6 /* refresh */]?.[0 /* handoff */]
if (handoff && router._handoff === handoff) {
handoff[1 /* finish */]()
}
}
runRouteLifecycle(router, previous, matches, tx)
}
async function awaitCurrent(
router: CoordinatorRouter,
owner?: LoadTransaction,
): Promise<void> {
let current = router._tx
while (current && current !== owner) {
await current[5 /* done */]
if (router._tx === current) {
return
}
current = router._tx
}
}
async function followRedirect(
router: CoordinatorRouter,
tx: LoadTransaction,
outcome: RedirectOutcome,
): Promise<void> {
const redirect = outcome[1 /* redirect */]
const location = outcome[2 /* location */]
if (!location) {
await router.navigate({
...redirect.options,
replace: true,
ignoreBlocker: true,
} as any)
return
}
if (redirect.options.reloadDocument) {
await router.navigate({
href: location.publicHref,
reloadDocument: true,
replace: true,
ignoreBlocker: true,
} as any)
return
}
;(location as ParsedLocation & { _redirects?: number })._redirects =
tx[1 /* redirects */] + 1
router._pendingLocation = location
const committed = router.commitLocation({
...location,
viewTransition: redirect.options.viewTransition,
replace: true,
resetScroll: redirect.options.resetScroll,
hashScrollIntoView: redirect.options.hashScrollIntoView,
ignoreBlocker: true,
})
queueMicrotask(() => {
if (router._pendingLocation === location) {