UNPKG

@tanstack/router-core

Version:

Modern and scalable routing for React applications

1,719 lines (1,644 loc) 73.7 kB
// 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) {