@tanstack/router-core
Version:
Modern and scalable routing for React applications
1,264 lines (1,112 loc) • 37.3 kB
text/typescript
import invariant from 'tiny-invariant'
import { isServer } from '@tanstack/router-core/isServer'
import { batch } from './utils/batch'
import { createControlledPromise, isPromise } from './utils'
import { isNotFound } from './not-found'
import { rootRouteId } from './root'
import { isRedirect } from './redirect'
import type { NotFoundError } from './not-found'
import type { ParsedLocation } from './location'
import type {
AnyRoute,
BeforeLoadContextOptions,
LoaderFnContext,
SsrContextOptions,
} from './route'
import type { AnyRouteMatch, MakeRouteMatch } from './Matches'
import type { AnyRouter, SSROption, UpdateMatchFn } from './router'
/**
* An object of this shape is created when calling `loadMatches`.
* It contains everything we need for all other functions in this file
* to work. (It's basically the function's argument, plus a few mutable states)
*/
type InnerLoadContext = {
/** the calling router instance */
router: AnyRouter
location: ParsedLocation
/** mutable state, scoped to a `loadMatches` call */
firstBadMatchIndex?: number
/** mutable state, scoped to a `loadMatches` call */
rendered?: boolean
serialError?: unknown
updateMatch: UpdateMatchFn
matches: Array<AnyRouteMatch>
preload?: boolean
forceStaleReload?: boolean
onReady?: () => Promise<void>
sync?: boolean
}
const triggerOnReady = (inner: InnerLoadContext): void | Promise<void> => {
if (!inner.rendered) {
inner.rendered = true
return inner.onReady?.()
}
}
const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
return !!(
inner.preload && !inner.router.state.matches.some((d) => d.id === matchId)
)
}
/**
* Builds the accumulated context from router options and all matches up to (and optionally including) the given index.
* Merges __routeContext and __beforeLoadContext from each match.
*/
const buildMatchContext = (
inner: InnerLoadContext,
index: number,
includeCurrentMatch: boolean = true,
): Record<string, unknown> => {
const context: Record<string, unknown> = {
...(inner.router.options.context ?? {}),
}
const end = includeCurrentMatch ? index : index - 1
for (let i = 0; i <= end; i++) {
const innerMatch = inner.matches[i]
if (!innerMatch) continue
const m = inner.router.getMatch(innerMatch.id)
if (!m) continue
Object.assign(context, m.__routeContext, m.__beforeLoadContext)
}
return context
}
const getNotFoundBoundaryIndex = (
inner: InnerLoadContext,
err: NotFoundError,
): number | undefined => {
if (!inner.matches.length) {
return undefined
}
const requestedRouteId = err.routeId
const matchedRootIndex = inner.matches.findIndex(
(m) => m.routeId === inner.router.routeTree.id,
)
const rootIndex = matchedRootIndex >= 0 ? matchedRootIndex : 0
let startIndex = requestedRouteId
? inner.matches.findIndex((match) => match.routeId === requestedRouteId)
: (inner.firstBadMatchIndex ?? inner.matches.length - 1)
if (startIndex < 0) {
startIndex = rootIndex
}
for (let i = startIndex; i >= 0; i--) {
const match = inner.matches[i]!
const route = inner.router.looseRoutesById[match.routeId]!
if (route.options.notFoundComponent) {
return i
}
}
// If no boundary component is found, preserve explicit routeId targeting behavior,
// otherwise default to root for untargeted notFounds.
return requestedRouteId ? startIndex : rootIndex
}
const handleRedirectAndNotFound = (
inner: InnerLoadContext,
match: AnyRouteMatch | undefined,
err: unknown,
): void => {
if (!isRedirect(err) && !isNotFound(err)) return
if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) {
throw err
}
// in case of a redirecting match during preload, the match does not exist
if (match) {
match._nonReactive.beforeLoadPromise?.resolve()
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.beforeLoadPromise = undefined
match._nonReactive.loaderPromise = undefined
match._nonReactive.error = err
inner.updateMatch(match.id, (prev) => ({
...prev,
status: isRedirect(err)
? 'redirected'
: prev.status === 'pending'
? 'success'
: prev.status,
context: buildMatchContext(inner, match.index),
isFetching: false,
error: err,
}))
if (isNotFound(err) && !err.routeId) {
// Stamp the throwing match's routeId so that the finalization step in
// loadMatches knows where the notFound originated. The actual boundary
// resolution (walking up to the nearest notFoundComponent) is deferred to
// the finalization step, where firstBadMatchIndex is stable and
// headMaxIndex can be capped correctly.
err.routeId = match.routeId
}
match._nonReactive.loadPromise?.resolve()
}
if (isRedirect(err)) {
inner.rendered = true
err.options._fromLocation = inner.location
err.redirectHandled = true
err = inner.router.resolveRedirect(err)
}
throw err
}
const shouldSkipLoader = (
inner: InnerLoadContext,
matchId: string,
): boolean => {
const match = inner.router.getMatch(matchId)
if (!match) {
return true
}
// upon hydration, we skip the loader if the match has been dehydrated on the server
if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) {
return true
}
if ((isServer ?? inner.router.isServer) && match.ssr === false) {
return true
}
return false
}
const syncMatchContext = (
inner: InnerLoadContext,
matchId: string,
index: number,
): void => {
const nextContext = buildMatchContext(inner, index)
inner.updateMatch(matchId, (prev) => {
return {
...prev,
context: nextContext,
}
})
}
const handleSerialError = (
inner: InnerLoadContext,
index: number,
err: any,
routerCode: string,
): void => {
const { id: matchId, routeId } = inner.matches[index]!
const route = inner.router.looseRoutesById[routeId]!
// Much like suspense, we use a promise here to know if
// we've been outdated by a new loadMatches call and
// should abort the current async operation
if (err instanceof Promise) {
throw err
}
err.routerCode = routerCode
inner.firstBadMatchIndex ??= index
handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err)
try {
route.options.onError?.(err)
} catch (errorHandlerErr) {
err = errorHandlerErr
handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err)
}
inner.updateMatch(matchId, (prev) => {
prev._nonReactive.beforeLoadPromise?.resolve()
prev._nonReactive.beforeLoadPromise = undefined
prev._nonReactive.loadPromise?.resolve()
return {
...prev,
error: err,
status: 'error',
isFetching: false,
updatedAt: Date.now(),
abortController: new AbortController(),
}
})
if (!inner.preload && !isRedirect(err) && !isNotFound(err)) {
inner.serialError ??= err
}
}
const isBeforeLoadSsr = (
inner: InnerLoadContext,
matchId: string,
index: number,
route: AnyRoute,
): void | Promise<void> => {
const existingMatch = inner.router.getMatch(matchId)!
const parentMatchId = inner.matches[index - 1]?.id
const parentMatch = parentMatchId
? inner.router.getMatch(parentMatchId)!
: undefined
// in SPA mode, only SSR the root route
if (inner.router.isShell()) {
existingMatch.ssr = route.id === rootRouteId
return
}
if (parentMatch?.ssr === false) {
existingMatch.ssr = false
return
}
const parentOverride = (tempSsr: SSROption) => {
if (tempSsr === true && parentMatch?.ssr === 'data-only') {
return 'data-only'
}
return tempSsr
}
const defaultSsr = inner.router.options.defaultSsr ?? true
if (route.options.ssr === undefined) {
existingMatch.ssr = parentOverride(defaultSsr)
return
}
if (typeof route.options.ssr !== 'function') {
existingMatch.ssr = parentOverride(route.options.ssr)
return
}
const { search, params } = existingMatch
const ssrFnContext: SsrContextOptions<any, any, any> = {
search: makeMaybe(search, existingMatch.searchError),
params: makeMaybe(params, existingMatch.paramsError),
location: inner.location,
matches: inner.matches.map((match) => ({
index: match.index,
pathname: match.pathname,
fullPath: match.fullPath,
staticData: match.staticData,
id: match.id,
routeId: match.routeId,
search: makeMaybe(match.search, match.searchError),
params: makeMaybe(match.params, match.paramsError),
ssr: match.ssr,
})),
}
const tempSsr = route.options.ssr(ssrFnContext)
if (isPromise(tempSsr)) {
return tempSsr.then((ssr) => {
existingMatch.ssr = parentOverride(ssr ?? defaultSsr)
})
}
existingMatch.ssr = parentOverride(tempSsr ?? defaultSsr)
return
}
const setupPendingTimeout = (
inner: InnerLoadContext,
matchId: string,
route: AnyRoute,
match: AnyRouteMatch,
): void => {
if (match._nonReactive.pendingTimeout !== undefined) return
const pendingMs =
route.options.pendingMs ?? inner.router.options.defaultPendingMs
const shouldPending = !!(
inner.onReady &&
!(isServer ?? inner.router.isServer) &&
!resolvePreload(inner, matchId) &&
(route.options.loader ||
route.options.beforeLoad ||
routeNeedsPreload(route)) &&
typeof pendingMs === 'number' &&
pendingMs !== Infinity &&
(route.options.pendingComponent ??
(inner.router.options as any)?.defaultPendingComponent)
)
if (shouldPending) {
const pendingTimeout = setTimeout(() => {
// Update the match and prematurely resolve the loadMatches promise so that
// the pending component can start rendering
triggerOnReady(inner)
}, pendingMs)
match._nonReactive.pendingTimeout = pendingTimeout
}
}
const preBeforeLoadSetup = (
inner: InnerLoadContext,
matchId: string,
route: AnyRoute,
): void | Promise<void> => {
const existingMatch = inner.router.getMatch(matchId)!
// If we are in the middle of a load, either of these will be present
// (not to be confused with `loadPromise`, which is always defined)
if (
!existingMatch._nonReactive.beforeLoadPromise &&
!existingMatch._nonReactive.loaderPromise
)
return
setupPendingTimeout(inner, matchId, route, existingMatch)
const then = () => {
const match = inner.router.getMatch(matchId)!
if (
match.preload &&
(match.status === 'redirected' || match.status === 'notFound')
) {
handleRedirectAndNotFound(inner, match, match.error)
}
}
// Wait for the previous beforeLoad to resolve before we continue
return existingMatch._nonReactive.beforeLoadPromise
? existingMatch._nonReactive.beforeLoadPromise.then(then)
: then()
}
const executeBeforeLoad = (
inner: InnerLoadContext,
matchId: string,
index: number,
route: AnyRoute,
): void | Promise<void> => {
const match = inner.router.getMatch(matchId)!
// explicitly capture the previous loadPromise
let prevLoadPromise = match._nonReactive.loadPromise
match._nonReactive.loadPromise = createControlledPromise<void>(() => {
prevLoadPromise?.resolve()
prevLoadPromise = undefined
})
const { paramsError, searchError } = match
if (paramsError) {
handleSerialError(inner, index, paramsError, 'PARSE_PARAMS')
}
if (searchError) {
handleSerialError(inner, index, searchError, 'VALIDATE_SEARCH')
}
setupPendingTimeout(inner, matchId, route, match)
const abortController = new AbortController()
let isPending = false
const pending = () => {
if (isPending) return
isPending = true
inner.updateMatch(matchId, (prev) => ({
...prev,
isFetching: 'beforeLoad',
fetchCount: prev.fetchCount + 1,
abortController,
// Note: We intentionally don't update context here.
// Context should only be updated after beforeLoad resolves to avoid
// components seeing incomplete context during async beforeLoad execution.
}))
}
const resolve = () => {
match._nonReactive.beforeLoadPromise?.resolve()
match._nonReactive.beforeLoadPromise = undefined
inner.updateMatch(matchId, (prev) => ({
...prev,
isFetching: false,
}))
}
// if there is no `beforeLoad` option, just mark as pending and resolve
// Context will be updated later in loadRouteMatch after loader completes
if (!route.options.beforeLoad) {
batch(() => {
pending()
resolve()
})
return
}
match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
// Build context from all parent matches, excluding current match's __beforeLoadContext
// (since we're about to execute beforeLoad for this match)
const context = {
...buildMatchContext(inner, index, false),
...match.__routeContext,
}
const { search, params, cause } = match
const preload = resolvePreload(inner, matchId)
const beforeLoadFnContext: BeforeLoadContextOptions<
any,
any,
any,
any,
any,
any,
any,
any,
any
> = {
search,
abortController,
params,
preload,
context,
location: inner.location,
navigate: (opts: any) =>
inner.router.navigate({
...opts,
_fromLocation: inner.location,
}),
buildLocation: inner.router.buildLocation,
cause: preload ? 'preload' : cause,
matches: inner.matches,
routeId: route.id,
...inner.router.options.additionalContext,
}
const updateContext = (beforeLoadContext: any) => {
if (beforeLoadContext === undefined) {
batch(() => {
pending()
resolve()
})
return
}
if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
pending()
handleSerialError(inner, index, beforeLoadContext, 'BEFORE_LOAD')
}
batch(() => {
pending()
inner.updateMatch(matchId, (prev) => ({
...prev,
__beforeLoadContext: beforeLoadContext,
}))
resolve()
})
}
let beforeLoadContext
try {
beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext)
if (isPromise(beforeLoadContext)) {
pending()
return beforeLoadContext
.catch((err) => {
handleSerialError(inner, index, err, 'BEFORE_LOAD')
})
.then(updateContext)
}
} catch (err) {
pending()
handleSerialError(inner, index, err, 'BEFORE_LOAD')
}
updateContext(beforeLoadContext)
return
}
const handleBeforeLoad = (
inner: InnerLoadContext,
index: number,
): void | Promise<void> => {
const { id: matchId, routeId } = inner.matches[index]!
const route = inner.router.looseRoutesById[routeId]!
const serverSsr = () => {
// on the server, determine whether SSR the current match or not
if (isServer ?? inner.router.isServer) {
const maybePromise = isBeforeLoadSsr(inner, matchId, index, route)
if (isPromise(maybePromise)) return maybePromise.then(queueExecution)
}
return queueExecution()
}
const execute = () => executeBeforeLoad(inner, matchId, index, route)
const queueExecution = () => {
if (shouldSkipLoader(inner, matchId)) return
const result = preBeforeLoadSetup(inner, matchId, route)
return isPromise(result) ? result.then(execute) : execute()
}
return serverSsr()
}
const executeHead = (
inner: InnerLoadContext,
matchId: string,
route: AnyRoute,
): void | Promise<
Pick<
AnyRouteMatch,
'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles'
>
> => {
const match = inner.router.getMatch(matchId)
// in case of a redirecting match during preload, the match does not exist
if (!match) {
return
}
if (!route.options.head && !route.options.scripts && !route.options.headers) {
return
}
const assetContext = {
ssr: inner.router.options.ssr,
matches: inner.matches,
match,
params: match.params,
loaderData: match.loaderData,
}
return Promise.all([
route.options.head?.(assetContext),
route.options.scripts?.(assetContext),
route.options.headers?.(assetContext),
]).then(([headFnContent, scripts, headers]) => {
const meta = headFnContent?.meta
const links = headFnContent?.links
const headScripts = headFnContent?.scripts
const styles = headFnContent?.styles
return {
meta,
links,
headScripts,
headers,
scripts,
styles,
}
})
}
const getLoaderContext = (
inner: InnerLoadContext,
matchPromises: Array<Promise<AnyRouteMatch>>,
matchId: string,
index: number,
route: AnyRoute,
): LoaderFnContext => {
const parentMatchPromise = matchPromises[index - 1] as any
const { params, loaderDeps, abortController, cause } =
inner.router.getMatch(matchId)!
const context = buildMatchContext(inner, index)
const preload = resolvePreload(inner, matchId)
return {
params,
deps: loaderDeps,
preload: !!preload,
parentMatchPromise,
abortController,
context,
location: inner.location,
navigate: (opts) =>
inner.router.navigate({
...opts,
_fromLocation: inner.location,
}),
cause: preload ? 'preload' : cause,
route,
...inner.router.options.additionalContext,
}
}
const runLoader = async (
inner: InnerLoadContext,
matchPromises: Array<Promise<AnyRouteMatch>>,
matchId: string,
index: number,
route: AnyRoute,
): Promise<void> => {
try {
// If the Matches component rendered
// the pending component and needs to show it for
// a minimum duration, we''ll wait for it to resolve
// before committing to the match and resolving
// the loadPromise
const match = inner.router.getMatch(matchId)!
// Actually run the loader and handle the result
try {
if (!(isServer ?? inner.router.isServer) || match.ssr === true) {
loadRouteChunk(route)
}
// Kick off the loader!
const routeLoader = route.options.loader
const loader =
typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler
const loaderResult = loader?.(
getLoaderContext(inner, matchPromises, matchId, index, route),
)
const loaderResultIsPromise = !!loader && isPromise(loaderResult)
const willLoadSomething = !!(
loaderResultIsPromise ||
route._lazyPromise ||
route._componentsPromise ||
route.options.head ||
route.options.scripts ||
route.options.headers ||
match._nonReactive.minPendingPromise
)
if (willLoadSomething) {
inner.updateMatch(matchId, (prev) => ({
...prev,
isFetching: 'loader',
}))
}
if (loader) {
const loaderData = loaderResultIsPromise
? await loaderResult
: loaderResult
handleRedirectAndNotFound(
inner,
inner.router.getMatch(matchId),
loaderData,
)
if (loaderData !== undefined) {
inner.updateMatch(matchId, (prev) => ({
...prev,
loaderData,
}))
}
}
// Lazy option can modify the route options,
// so we need to wait for it to resolve before
// we can use the options
if (route._lazyPromise) await route._lazyPromise
const pendingPromise = match._nonReactive.minPendingPromise
if (pendingPromise) await pendingPromise
// Last but not least, wait for the the components
// to be preloaded before we resolve the match
if (route._componentsPromise) await route._componentsPromise
inner.updateMatch(matchId, (prev) => ({
...prev,
error: undefined,
context: buildMatchContext(inner, index),
status: 'success',
isFetching: false,
updatedAt: Date.now(),
}))
} catch (e) {
let error = e
if ((error as any)?.name === 'AbortError') {
if (match.abortController.signal.aborted) {
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loaderPromise = undefined
return
}
inner.updateMatch(matchId, (prev) => ({
...prev,
status: prev.status === 'pending' ? 'success' : prev.status,
isFetching: false,
context: buildMatchContext(inner, index),
}))
return
}
const pendingPromise = match._nonReactive.minPendingPromise
if (pendingPromise) await pendingPromise
if (isNotFound(e)) {
await (route.options.notFoundComponent as any)?.preload?.()
}
handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), e)
try {
route.options.onError?.(e)
} catch (onErrorError) {
error = onErrorError
handleRedirectAndNotFound(
inner,
inner.router.getMatch(matchId),
onErrorError,
)
}
if (!isRedirect(error) && !isNotFound(error)) {
await loadRouteChunk(route, ['errorComponent'])
}
inner.updateMatch(matchId, (prev) => ({
...prev,
error,
context: buildMatchContext(inner, index),
status: 'error',
isFetching: false,
}))
}
} catch (err) {
const match = inner.router.getMatch(matchId)
// in case of a redirecting match during preload, the match does not exist
if (match) {
match._nonReactive.loaderPromise = undefined
}
handleRedirectAndNotFound(inner, match, err)
}
}
const loadRouteMatch = async (
inner: InnerLoadContext,
matchPromises: Array<Promise<AnyRouteMatch>>,
index: number,
): Promise<AnyRouteMatch> => {
async function handleLoader(
preload: boolean,
prevMatch: AnyRouteMatch,
previousRouteMatchId: string | undefined,
match: AnyRouteMatch,
route: AnyRoute,
) {
const age = Date.now() - prevMatch.updatedAt
const staleAge = preload
? (route.options.preloadStaleTime ??
inner.router.options.defaultPreloadStaleTime ??
30_000) // 30 seconds for preloads by default
: (route.options.staleTime ?? inner.router.options.defaultStaleTime ?? 0)
const shouldReloadOption = route.options.shouldReload
// Default to reloading the route all the time
// Allow shouldReload to get the last say,
// if provided.
const shouldReload =
typeof shouldReloadOption === 'function'
? shouldReloadOption(
getLoaderContext(inner, matchPromises, matchId, index, route),
)
: shouldReloadOption
// If the route is successful and still fresh, just resolve
const { status, invalid } = match
const staleMatchShouldReload =
age > staleAge &&
(!!inner.forceStaleReload ||
match.cause === 'enter' ||
(previousRouteMatchId !== undefined &&
previousRouteMatchId !== match.id))
loaderShouldRunAsync =
status === 'success' &&
(invalid || (shouldReload ?? staleMatchShouldReload))
if (preload && route.options.preload === false) {
// Do nothing
} else if (
loaderShouldRunAsync &&
!inner.sync &&
shouldReloadInBackground
) {
loaderIsRunningAsync = true
;(async () => {
try {
await runLoader(inner, matchPromises, matchId, index, route)
const match = inner.router.getMatch(matchId)!
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loaderPromise = undefined
match._nonReactive.loadPromise = undefined
} catch (err) {
if (isRedirect(err)) {
await inner.router.navigate(err.options)
}
}
})()
} else if (status !== 'success' || loaderShouldRunAsync) {
await runLoader(inner, matchPromises, matchId, index, route)
} else {
syncMatchContext(inner, matchId, index)
}
}
const { id: matchId, routeId } = inner.matches[index]!
let loaderShouldRunAsync = false
let loaderIsRunningAsync = false
const route = inner.router.looseRoutesById[routeId]!
const routeLoader = route.options.loader
const shouldReloadInBackground =
((typeof routeLoader === 'function'
? undefined
: routeLoader?.staleReloadMode) ??
inner.router.options.defaultStaleReloadMode) !== 'blocking'
if (shouldSkipLoader(inner, matchId)) {
const match = inner.router.getMatch(matchId)
if (!match) {
return inner.matches[index]!
}
syncMatchContext(inner, matchId, index)
if (isServer ?? inner.router.isServer) {
return inner.router.getMatch(matchId)!
}
} else {
const prevMatch = inner.router.getMatch(matchId)! // This is where all of the stale-while-revalidate magic happens
const previousRouteMatchId =
inner.router.state.matches[index]?.routeId === routeId
? inner.router.state.matches[index]!.id
: inner.router.state.matches.find((d) => d.routeId === routeId)?.id
const preload = resolvePreload(inner, matchId)
// there is a loaderPromise, so we are in the middle of a load
if (prevMatch._nonReactive.loaderPromise) {
// do not block if we already have stale data we can show
// but only if the ongoing load is not a preload since error handling is different for preloads
// and we don't want to swallow errors
if (
prevMatch.status === 'success' &&
!inner.sync &&
!prevMatch.preload &&
shouldReloadInBackground
) {
return prevMatch
}
await prevMatch._nonReactive.loaderPromise
const match = inner.router.getMatch(matchId)!
const error = match._nonReactive.error || match.error
if (error) {
handleRedirectAndNotFound(inner, match, error)
}
if (match.status === 'pending') {
await handleLoader(
preload,
prevMatch,
previousRouteMatchId,
match,
route,
)
}
} else {
const nextPreload =
preload && !inner.router.state.matches.some((d) => d.id === matchId)
const match = inner.router.getMatch(matchId)!
match._nonReactive.loaderPromise = createControlledPromise<void>()
if (nextPreload !== match.preload) {
inner.updateMatch(matchId, (prev) => ({
...prev,
preload: nextPreload,
}))
}
await handleLoader(preload, prevMatch, previousRouteMatchId, match, route)
}
}
const match = inner.router.getMatch(matchId)!
if (!loaderIsRunningAsync) {
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loadPromise = undefined
}
clearTimeout(match._nonReactive.pendingTimeout)
match._nonReactive.pendingTimeout = undefined
if (!loaderIsRunningAsync) match._nonReactive.loaderPromise = undefined
match._nonReactive.dehydrated = undefined
const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false
if (nextIsFetching !== match.isFetching || match.invalid !== false) {
inner.updateMatch(matchId, (prev) => ({
...prev,
isFetching: nextIsFetching,
invalid: false,
}))
return inner.router.getMatch(matchId)!
} else {
return match
}
}
export async function loadMatches(arg: {
router: AnyRouter
location: ParsedLocation
matches: Array<AnyRouteMatch>
preload?: boolean
forceStaleReload?: boolean
onReady?: () => Promise<void>
updateMatch: UpdateMatchFn
sync?: boolean
}): Promise<Array<MakeRouteMatch>> {
const inner: InnerLoadContext = arg
const matchPromises: Array<Promise<AnyRouteMatch>> = []
// make sure the pending component is immediately rendered when hydrating a match that is not SSRed
// the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
if (
!(isServer ?? inner.router.isServer) &&
inner.router.state.matches.some((d) => d._forcePending)
) {
triggerOnReady(inner)
}
let beforeLoadNotFound: NotFoundError | undefined
// Execute all beforeLoads one by one
for (let i = 0; i < inner.matches.length; i++) {
try {
const beforeLoad = handleBeforeLoad(inner, i)
if (isPromise(beforeLoad)) await beforeLoad
} catch (err) {
if (isRedirect(err)) {
throw err
}
if (isNotFound(err)) {
beforeLoadNotFound = err
} else {
if (!inner.preload) throw err
}
break
}
if (inner.serialError) {
break
}
}
// Execute loaders once, with max index adapted for beforeLoad notFound handling.
const baseMaxIndexExclusive = inner.firstBadMatchIndex ?? inner.matches.length
const boundaryIndex =
beforeLoadNotFound && !inner.preload
? getNotFoundBoundaryIndex(inner, beforeLoadNotFound)
: undefined
const maxIndexExclusive =
beforeLoadNotFound && inner.preload
? 0
: boundaryIndex !== undefined
? Math.min(boundaryIndex + 1, baseMaxIndexExclusive)
: baseMaxIndexExclusive
let firstNotFound: NotFoundError | undefined
let firstUnhandledRejection: unknown
for (let i = 0; i < maxIndexExclusive; i++) {
matchPromises.push(loadRouteMatch(inner, matchPromises, i))
}
try {
await Promise.all(matchPromises)
} catch {
const settled = await Promise.allSettled(matchPromises)
for (const result of settled) {
if (result.status !== 'rejected') continue
const reason = result.reason
if (isRedirect(reason)) {
throw reason
}
if (isNotFound(reason)) {
firstNotFound ??= reason
} else {
firstUnhandledRejection ??= reason
}
}
if (firstUnhandledRejection !== undefined) {
throw firstUnhandledRejection
}
}
const notFoundToThrow =
firstNotFound ??
(beforeLoadNotFound && !inner.preload ? beforeLoadNotFound : undefined)
let headMaxIndex = inner.serialError
? (inner.firstBadMatchIndex ?? 0)
: inner.matches.length - 1
if (!notFoundToThrow && beforeLoadNotFound && inner.preload) {
return inner.matches
}
if (notFoundToThrow) {
// Determine once which matched route will actually render the
// notFoundComponent, then pass this precomputed index through the remaining
// finalization steps.
// This can differ from the throwing route when routeId targets an ancestor
// boundary (or when bubbling resolves to a parent/root boundary).
const renderedBoundaryIndex = getNotFoundBoundaryIndex(
inner,
notFoundToThrow,
)
invariant(
renderedBoundaryIndex !== undefined,
'Could not find match for notFound boundary',
)
const boundaryMatch = inner.matches[renderedBoundaryIndex]!
const boundaryRoute = inner.router.looseRoutesById[boundaryMatch.routeId]!
const defaultNotFoundComponent = (inner.router.options as any)
?.defaultNotFoundComponent
// Ensure a notFoundComponent exists on the boundary route
if (!boundaryRoute.options.notFoundComponent && defaultNotFoundComponent) {
boundaryRoute.options.notFoundComponent = defaultNotFoundComponent
}
notFoundToThrow.routeId = boundaryMatch.routeId
const boundaryIsRoot = boundaryMatch.routeId === inner.router.routeTree.id
inner.updateMatch(boundaryMatch.id, (prev) => ({
...prev,
...(boundaryIsRoot
? // For root boundary, use globalNotFound so the root component's
// shell still renders and <Outlet> handles the not-found display,
// instead of replacing the entire root shell via status='notFound'.
{ status: 'success' as const, globalNotFound: true, error: undefined }
: // For non-root boundaries, set status:'notFound' so MatchInner
// renders the notFoundComponent directly.
{ status: 'notFound' as const, error: notFoundToThrow }),
isFetching: false,
}))
headMaxIndex = renderedBoundaryIndex
// Ensure the rendering boundary route chunk (and its lazy components, including
// lazy notFoundComponent) is loaded before we continue to head execution/render.
await loadRouteChunk(boundaryRoute, ['notFoundComponent'])
} else if (!inner.preload) {
// Clear stale root global-not-found state on normal navigations that do not
// throw notFound. This must live here (instead of only in runLoader success)
// because the root loader may be skipped when data is still fresh.
const rootMatch = inner.matches[0]!
// `rootMatch` is the next match for this navigation. If it is not global
// not-found, then any currently stored root global-not-found is stale.
if (!rootMatch.globalNotFound) {
// `currentRootMatch` is the current store state (from the previous
// navigation/load). Update only when a stale flag is actually present.
const currentRootMatch = inner.router.getMatch(rootMatch.id)
if (currentRootMatch?.globalNotFound) {
inner.updateMatch(rootMatch.id, (prev) => ({
...prev,
globalNotFound: false,
error: undefined,
}))
}
}
}
// When a serial error occurred (e.g. beforeLoad threw a regular Error),
// the erroring route's lazy chunk wasn't loaded because loaders were skipped.
// We need to load it so the code-split errorComponent is available for rendering.
if (inner.serialError && inner.firstBadMatchIndex !== undefined) {
const errorRoute =
inner.router.looseRoutesById[
inner.matches[inner.firstBadMatchIndex]!.routeId
]!
await loadRouteChunk(errorRoute, ['errorComponent'])
}
// serially execute heads once after loaders/notFound handling, ensuring
// all head functions get a chance even if one throws.
for (let i = 0; i <= headMaxIndex; i++) {
const match = inner.matches[i]!
const { id: matchId, routeId } = match
const route = inner.router.looseRoutesById[routeId]!
try {
const headResult = executeHead(inner, matchId, route)
if (headResult) {
const head = await headResult
inner.updateMatch(matchId, (prev) => ({
...prev,
...head,
}))
}
} catch (err) {
console.error(`Error executing head for route ${routeId}:`, err)
}
}
const readyPromise = triggerOnReady(inner)
if (isPromise(readyPromise)) {
await readyPromise
}
if (notFoundToThrow) {
throw notFoundToThrow
}
if (inner.serialError && !inner.preload && !inner.onReady) {
throw inner.serialError
}
return inner.matches
}
export type RouteComponentType =
| 'component'
| 'errorComponent'
| 'pendingComponent'
| 'notFoundComponent'
function preloadRouteComponents(
route: AnyRoute,
componentTypesToLoad: Array<RouteComponentType>,
): Promise<void> | undefined {
const preloads = componentTypesToLoad
.map((type) => (route.options[type] as any)?.preload?.())
.filter(Boolean)
if (preloads.length === 0) return undefined
return Promise.all(preloads) as any as Promise<void>
}
export function loadRouteChunk(
route: AnyRoute,
componentTypesToLoad: Array<RouteComponentType> = componentTypes,
) {
if (!route._lazyLoaded && route._lazyPromise === undefined) {
if (route.lazyFn) {
route._lazyPromise = route.lazyFn().then((lazyRoute) => {
// explicitly don't copy over the lazy route's id
const { id: _id, ...options } = lazyRoute.options
Object.assign(route.options, options)
route._lazyLoaded = true
route._lazyPromise = undefined // gc promise, we won't need it anymore
})
} else {
route._lazyLoaded = true
}
}
const runAfterLazy = () =>
route._componentsLoaded
? undefined
: componentTypesToLoad === componentTypes
? (() => {
if (route._componentsPromise === undefined) {
const componentsPromise = preloadRouteComponents(
route,
componentTypes,
)
if (componentsPromise) {
route._componentsPromise = componentsPromise.then(() => {
route._componentsLoaded = true
route._componentsPromise = undefined // gc promise, we won't need it anymore
})
} else {
route._componentsLoaded = true
}
}
return route._componentsPromise
})()
: preloadRouteComponents(route, componentTypesToLoad)
return route._lazyPromise
? route._lazyPromise.then(runAfterLazy)
: runAfterLazy()
}
function makeMaybe<TValue, TError>(
value: TValue,
error: TError,
): { status: 'success'; value: TValue } | { status: 'error'; error: TError } {
if (error) {
return { status: 'error' as const, error }
}
return { status: 'success' as const, value }
}
export function routeNeedsPreload(route: AnyRoute) {
for (const componentType of componentTypes) {
if ((route.options[componentType] as any)?.preload) {
return true
}
}
return false
}
export const componentTypes: Array<RouteComponentType> = [
'component',
'errorComponent',
'pendingComponent',
'notFoundComponent',
] as const