Dev Build / cleanup (push) Canceled after 0s
Dev Build / typecheck (push) Canceled after 0s
Dev Build / build (linux-arm64, ubuntu-24.04-arm, linux-arm64) (push) Canceled after 0s
Dev Build / build (linux-x64, ubuntu-latest, linux-x64) (push) Canceled after 0s
Dev Build / build (win-x64, windows-latest, win32-x64) (push) Canceled after 0s
Dev Build / dispatch-dev-docker (push) Canceled after 0s
1281 lines
37 KiB
Plaintext
1281 lines
37 KiB
Plaintext
import { isServer } from '@tanstack/router-core/isServer'
|
|
import { invariant } from './invariant'
|
|
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 hasForcePendingActiveMatch = (router: AnyRouter): boolean => {
|
|
return router.stores.matchesId.get().some((matchId) => {
|
|
return router.stores.matchStores.get(matchId)?.get()._forcePending
|
|
})
|
|
}
|
|
|
|
const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
|
|
return !!(inner.preload && !inner.router.stores.matchStores.has(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'
|
|
: isNotFound(err)
|
|
? 'notFound'
|
|
: 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) {
|
|
inner.router.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) {
|
|
inner.router.batch(() => {
|
|
pending()
|
|
resolve()
|
|
})
|
|
return
|
|
}
|
|
if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
|
|
pending()
|
|
handleSerialError(inner, index, beforeLoadContext, 'BEFORE_LOAD')
|
|
}
|
|
|
|
inner.router.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 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 activeIdAtIndex = inner.router.stores.matchesId.get()[index]
|
|
const activeAtIndex =
|
|
(activeIdAtIndex &&
|
|
inner.router.stores.matchStores.get(activeIdAtIndex)) ||
|
|
null
|
|
const previousRouteMatchId =
|
|
activeAtIndex?.routeId === routeId
|
|
? activeIdAtIndex
|
|
: inner.router.stores.matches.get().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.stores.matchStores.has(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) &&
|
|
hasForcePendingActiveMatch(inner.router)
|
|
) {
|
|
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 || inner.firstBadMatchIndex != null) {
|
|
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.firstBadMatchIndex !== undefined
|
|
? inner.firstBadMatchIndex
|
|
: 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,
|
|
)
|
|
|
|
if (renderedBoundaryIndex === undefined) {
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
throw new Error(
|
|
'Invariant failed: Could not find match for notFound boundary',
|
|
)
|
|
}
|
|
|
|
invariant()
|
|
}
|
|
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
|