diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 1b6f4dea87..5568cf1811 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -39,6 +39,8 @@ export type { RequiredToOptions, } from './link' +export { componentTypes } from './load-matches' + export type { RouteToPath, TrailingSlashOptionByRouter, @@ -197,7 +199,6 @@ export { defaultSerializeError, getLocationChangeInfo, RouterCore, - componentTypes, lazyFn, SearchParamError, PathParamError, diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts new file mode 100644 index 0000000000..6940488cab --- /dev/null +++ b/packages/router-core/src/load-matches.ts @@ -0,0 +1,982 @@ +import { batch } from '@tanstack/store' +import invariant from 'tiny-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 { ControlledPromise } from './utils' +import type { ParsedLocation } from './location' +import type { + AnyRoute, + BeforeLoadContextOptions, + LoaderFnContext, + SsrContextOptions, +} from './route' +import type { AnyRouteMatch, MakeRouteMatch } from './Matches' +import type { AnyRouter, 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 + updateMatch: UpdateMatchFn + matches: Array + preload?: boolean + onReady?: () => Promise + sync?: boolean + /** mutable state, scoped to a `loadMatches` call */ + matchPromises: Array> +} + +const triggerOnReady = (inner: InnerLoadContext): void | Promise => { + 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) + ) +} + +const _handleNotFound = (inner: InnerLoadContext, err: NotFoundError) => { + // Find the route that should handle the not found error + // First check if a specific route is requested to show the error + const routeCursor = + inner.router.routesById[err.routeId ?? ''] ?? inner.router.routeTree + const matchesByRouteId: Record = {} + + // Setup routesByRouteId object for quick access + for (const match of inner.matches) { + matchesByRouteId[match.routeId] = match + } + + // Ensure a NotFoundComponent exists on the route + if ( + !routeCursor.options.notFoundComponent && + (inner.router.options as any)?.defaultNotFoundComponent + ) { + routeCursor.options.notFoundComponent = ( + inner.router.options as any + ).defaultNotFoundComponent + } + + // Ensure we have a notFoundComponent + invariant( + routeCursor.options.notFoundComponent, + 'No notFoundComponent found. Please set a notFoundComponent on your route or provide a defaultNotFoundComponent to the router.', + ) + + // Find the match for this route + const matchForRoute = matchesByRouteId[routeCursor.id] + + invariant(matchForRoute, 'Could not find match for route: ' + routeCursor.id) + + // Assign the error to the match - using non-null assertion since we've checked with invariant + inner.updateMatch(matchForRoute.id, (prev) => ({ + ...prev, + status: 'notFound', + error: err, + isFetching: false, + })) + + if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) { + err.routeId = routeCursor.parentRoute.id + _handleNotFound(inner, err) + } +} + +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 + + const status = isRedirect(err) ? 'redirected' : 'notFound' + + inner.updateMatch(match.id, (prev) => ({ + ...prev, + status, + isFetching: false, + error: err, + })) + + if (isNotFound(err) && !err.routeId) { + 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 + } else { + _handleNotFound(inner, err) + throw err + } +} + +const shouldSkipLoader = ( + inner: InnerLoadContext, + matchId: string, +): boolean => { + const match = inner.router.getMatch(matchId)! + // upon hydration, we skip the loader if the match has been dehydrated on the server + if (!inner.router.isServer && match._nonReactive.dehydrated) { + return true + } + + if (inner.router.isServer) { + if (match.ssr === false) { + return true + } + } + return false +} + +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(), + } + }) +} + +const isBeforeLoadSsr = ( + inner: InnerLoadContext, + matchId: string, + index: number, + route: AnyRoute, +): void | Promise => { + 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 = matchId === rootRouteId + return + } + + if (parentMatch?.ssr === false) { + existingMatch.ssr = false + return + } + + const parentOverride = (tempSsr: boolean | 'data-only') => { + 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 } = inner.router.getMatch(matchId)! + + const ssrFnContext: SsrContextOptions = { + 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, +): void => { + const match = inner.router.getMatch(matchId)! + if (match._nonReactive.pendingTimeout !== undefined) return + + const pendingMs = + route.options.pendingMs ?? inner.router.options.defaultPendingMs + const shouldPending = !!( + inner.onReady && + !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 shouldExecuteBeforeLoad = ( + inner: InnerLoadContext, + matchId: string, + route: AnyRoute, +): boolean | Promise => { + 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 true + + setupPendingTimeout(inner, matchId, route) + + const then = () => { + let shouldExecuteBeforeLoad = true + const match = inner.router.getMatch(matchId)! + if (match.status === 'error') { + shouldExecuteBeforeLoad = true + } else if ( + match.preload && + (match.status === 'redirected' || match.status === 'notFound') + ) { + handleRedirectAndNotFound(inner, match, match.error) + } + return shouldExecuteBeforeLoad + } + + // Wait for the 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 => { + const match = inner.router.getMatch(matchId)! + + match._nonReactive.beforeLoadPromise = createControlledPromise() + // explicitly capture the previous loadPromise + const prevLoadPromise = match._nonReactive.loadPromise + match._nonReactive.loadPromise = createControlledPromise(() => { + prevLoadPromise?.resolve() + }) + + const { paramsError, searchError } = match + + if (paramsError) { + handleSerialError(inner, index, paramsError, 'PARSE_PARAMS') + } + + if (searchError) { + handleSerialError(inner, index, searchError, 'VALIDATE_SEARCH') + } + + setupPendingTimeout(inner, matchId, route) + + const abortController = new AbortController() + + const parentMatchId = inner.matches[index - 1]?.id + const parentMatch = parentMatchId + ? inner.router.getMatch(parentMatchId)! + : undefined + const parentMatchContext = + parentMatch?.context ?? inner.router.options.context ?? undefined + + const context = { ...parentMatchContext, ...match.__routeContext } + + let isPending = false + const pending = () => { + if (isPending) return + isPending = true + inner.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'beforeLoad', + fetchCount: prev.fetchCount + 1, + abortController, + context, + })) + } + + const resolve = () => { + match._nonReactive.beforeLoadPromise?.resolve() + match._nonReactive.beforeLoadPromise = undefined + inner.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: false, + })) + } + + // if there is no `beforeLoad` option, skip everything, batch update the store, return early + if (!route.options.beforeLoad) { + batch(() => { + pending() + resolve() + }) + return + } + + const { search, params, cause } = match + const preload = resolvePreload(inner, matchId) + const beforeLoadFnContext: BeforeLoadContextOptions = + { + 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, + } + + 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, + context: { + ...prev.context, + ...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 => { + 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 (inner.router.isServer) { + const maybePromise = isBeforeLoadSsr(inner, matchId, index, route) + if (isPromise(maybePromise)) return maybePromise.then(queueExecution) + } + return queueExecution() + } + + const queueExecution = () => { + if (shouldSkipLoader(inner, matchId)) return + const shouldExecuteBeforeLoadResult = shouldExecuteBeforeLoad( + inner, + matchId, + route, + ) + return isPromise(shouldExecuteBeforeLoadResult) + ? shouldExecuteBeforeLoadResult.then(execute) + : execute(shouldExecuteBeforeLoadResult) + } + + const execute = (shouldExecuteBeforeLoad: boolean) => { + if (shouldExecuteBeforeLoad) { + // If we are not in the middle of a load OR the previous load failed, start it + return executeBeforeLoad(inner, matchId, index, route) + } + return + } + + 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 = { + 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 potentialPendingMinPromise = ( + inner: InnerLoadContext, + matchId: string, +): void | ControlledPromise => { + const latestMatch = inner.router.getMatch(matchId)! + return latestMatch._nonReactive.minPendingPromise +} + +const getLoaderContext = ( + inner: InnerLoadContext, + matchId: string, + index: number, + route: AnyRoute, +): LoaderFnContext => { + const parentMatchPromise = inner.matchPromises[index - 1] as any + const { params, loaderDeps, abortController, context, cause } = + inner.router.getMatch(matchId)! + + const preload = resolvePreload(inner, matchId) + + return { + params, + deps: loaderDeps, + preload: !!preload, + parentMatchPromise, + abortController: abortController, + context, + location: inner.location, + navigate: (opts) => + inner.router.navigate({ + ...opts, + _fromLocation: inner.location, + }), + cause: preload ? 'preload' : cause, + route, + } +} + +const runLoader = async ( + inner: InnerLoadContext, + matchId: string, + index: number, + route: AnyRoute, +): Promise => { + 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 + + // Actually run the loader and handle the result + try { + if ( + !inner.router.isServer || + inner.router.getMatch(matchId)!.ssr === true + ) { + loadRouteChunk(route) + } + + // Kick off the loader! + const loaderResult = route.options.loader?.( + getLoaderContext(inner, matchId, index, route), + ) + const loaderResultIsPromise = + route.options.loader && isPromise(loaderResult) + + const willLoadSomething = !!( + loaderResultIsPromise || + route._lazyPromise || + route._componentsPromise || + route.options.head || + route.options.scripts || + route.options.headers || + inner.router.getMatch(matchId)!._nonReactive.minPendingPromise + ) + + if (willLoadSomething) { + inner.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'loader', + })) + } + + if (route.options.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 headResult = executeHead(inner, matchId, route) + const head = headResult ? await headResult : undefined + const pendingPromise = potentialPendingMinPromise(inner, matchId) + 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, + status: 'success', + isFetching: false, + updatedAt: Date.now(), + ...head, + })) + } catch (e) { + let error = e + + const pendingPromise = potentialPendingMinPromise(inner, matchId) + if (pendingPromise) await pendingPromise + + handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), e) + + try { + route.options.onError?.(e) + } catch (onErrorError) { + error = onErrorError + handleRedirectAndNotFound( + inner, + inner.router.getMatch(matchId), + onErrorError, + ) + } + const headResult = executeHead(inner, matchId, route) + const head = headResult ? await headResult : undefined + inner.updateMatch(matchId, (prev) => ({ + ...prev, + error, + status: 'error', + isFetching: false, + ...head, + })) + } + } catch (err) { + const match = inner.router.getMatch(matchId) + // in case of a redirecting match during preload, the match does not exist + if (match) { + const headResult = executeHead(inner, matchId, route) + if (headResult) { + const head = await headResult + inner.updateMatch(matchId, (prev) => ({ + ...prev, + ...head, + })) + } + match._nonReactive.loaderPromise = undefined + } + handleRedirectAndNotFound(inner, match, err) + } +} + +const loadRouteMatch = async ( + inner: InnerLoadContext, + index: number, +): Promise => { + const { id: matchId, routeId } = inner.matches[index]! + let loaderShouldRunAsync = false + let loaderIsRunningAsync = false + const route = inner.router.looseRoutesById[routeId]! + + const prevMatch = inner.router.getMatch(matchId)! + if (shouldSkipLoader(inner, matchId)) { + if (inner.router.isServer) { + const headResult = executeHead(inner, matchId, route) + if (headResult) { + const head = await headResult + inner.updateMatch(matchId, (prev) => ({ + ...prev, + ...head, + })) + } + return inner.router.getMatch(matchId)! + } + } + // there is a loaderPromise, so we are in the middle of a load + else 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) { + return inner.router.getMatch(matchId)! + } + await prevMatch._nonReactive.loaderPromise + const match = inner.router.getMatch(matchId)! + if (match.error) { + handleRedirectAndNotFound(inner, match, match.error) + } + } else { + // This is where all of the stale-while-revalidate magic happens + const age = Date.now() - inner.router.getMatch(matchId)!.updatedAt + + const preload = resolvePreload(inner, matchId) + + 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, matchId, index, route)) + : shouldReloadOption + + const nextPreload = + !!preload && !inner.router.state.matches.some((d) => d.id === matchId) + const match = inner.router.getMatch(matchId)! + match._nonReactive.loaderPromise = createControlledPromise() + if (nextPreload !== match.preload) { + inner.updateMatch(matchId, (prev) => ({ + ...prev, + preload: nextPreload, + })) + } + + // If the route is successful and still fresh, just resolve + const { status, invalid } = inner.router.getMatch(matchId)! + loaderShouldRunAsync = + status === 'success' && (invalid || (shouldReload ?? age > staleAge)) + if (preload && route.options.preload === false) { + // Do nothing + } else if (loaderShouldRunAsync && !inner.sync) { + loaderIsRunningAsync = true + ;(async () => { + try { + await runLoader(inner, matchId, index, route) + const match = inner.router.getMatch(matchId)! + match._nonReactive.loaderPromise?.resolve() + match._nonReactive.loadPromise?.resolve() + match._nonReactive.loaderPromise = undefined + } catch (err) { + if (isRedirect(err)) { + await inner.router.navigate(err.options) + } + } + })() + } else if (status !== 'success' || (loaderShouldRunAsync && inner.sync)) { + await runLoader(inner, matchId, index, route) + } else { + // if the loader did not run, still update head. + // reason: parent's beforeLoad may have changed the route context + // and only now do we know the route context (and that the loader would not run) + const headResult = executeHead(inner, matchId, route) + if (headResult) { + const head = await headResult + inner.updateMatch(matchId, (prev) => ({ + ...prev, + ...head, + })) + } + } + } + const match = inner.router.getMatch(matchId)! + if (!loaderIsRunningAsync) { + match._nonReactive.loaderPromise?.resolve() + match._nonReactive.loadPromise?.resolve() + } + + 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)! +} + +export async function loadMatches(arg: { + router: AnyRouter + location: ParsedLocation + matches: Array + preload?: boolean + onReady?: () => Promise + updateMatch: UpdateMatchFn + sync?: boolean +}): Promise> { + const inner: InnerLoadContext = Object.assign(arg, { + matchPromises: [], + }) + + // 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 ( + !inner.router.isServer && + inner.router.state.matches.some((d) => d._forcePending) + ) { + triggerOnReady(inner) + } + + try { + // Execute all beforeLoads one by one + for (let i = 0; i < inner.matches.length; i++) { + const beforeLoad = handleBeforeLoad(inner, i) + if (isPromise(beforeLoad)) await beforeLoad + } + + // Execute all loaders in parallel + const max = inner.firstBadMatchIndex ?? inner.matches.length + for (let i = 0; i < max; i++) { + inner.matchPromises.push(loadRouteMatch(inner, i)) + } + await Promise.all(inner.matchPromises) + + const readyPromise = triggerOnReady(inner) + if (isPromise(readyPromise)) await readyPromise + } catch (err) { + if (isNotFound(err) && !inner.preload) { + const readyPromise = triggerOnReady(inner) + if (isPromise(readyPromise)) await readyPromise + throw err + } + if (isRedirect(err)) { + throw err + } + } + + return inner.matches +} + +export async function loadRouteChunk(route: AnyRoute) { + 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 + } + } + + // If for some reason lazy resolves more lazy components... + // We'll wait for that before we attempt to preload the + // components themselves. + if (!route._componentsLoaded && route._componentsPromise === undefined) { + const loadComponents = () => { + const preloads = [] + for (const type of componentTypes) { + const preload = (route.options[type] as any)?.preload + if (preload) preloads.push(preload()) + } + if (preloads.length) + return Promise.all(preloads).then(() => { + route._componentsLoaded = true + route._componentsPromise = undefined // gc promise, we won't need it anymore + }) + route._componentsLoaded = true + route._componentsPromise = undefined // gc promise, we won't need it anymore + return + } + route._componentsPromise = route._lazyPromise + ? route._lazyPromise.then(loadComponents) + : loadComponents() + } + return route._componentsPromise +} + +function makeMaybe( + 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 = [ + 'component', + 'errorComponent', + 'pendingComponent', + 'notFoundComponent', +] as const diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 5b36456e94..c4409961b8 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -9,7 +9,6 @@ import { createControlledPromise, deepEqual, functionalUpdate, - isPromise, last, pick, replaceEqualDeep, @@ -35,6 +34,7 @@ import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' import { createLRUCache } from './lru-cache' +import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' import type { ParsePathnameCache, Segment } from './path' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' @@ -57,13 +57,10 @@ import type { AnyContext, AnyRoute, AnyRouteWithContext, - BeforeLoadContextOptions, - LoaderFnContext, MakeRemountDepsOptionsUnion, RouteContextOptions, RouteMask, SearchMiddleware, - SsrContextOptions, } from './route' import type { FullSearchSchema, @@ -763,18 +760,6 @@ export type CreateRouterFn = < TDehydrated > -type InnerLoadContext = { - location: ParsedLocation - firstBadMatchIndex?: number - rendered?: boolean - updateMatch: UpdateMatchFn - matches: Array - preload?: boolean - onReady?: () => Promise - sync?: boolean - matchPromises: Array> -} - export class RouterCore< in out TRouteTree extends AnyRoute, in out TTrailingSlashOption extends TrailingSlashOption, @@ -1902,10 +1887,12 @@ export class RouterCore< }), }) - await this.loadMatches({ + await loadMatches({ + router: this, sync: opts?.sync, matches: this.state.pendingMatches as Array, location: next, + updateMatch: this.updateMatch, // eslint-disable-next-line @typescript-eslint/require-await onReady: async () => { // eslint-disable-next-line @typescript-eslint/require-await @@ -2095,873 +2082,6 @@ export class RouterCore< ) } - private triggerOnReady = ( - innerLoadContext: InnerLoadContext, - ): void | Promise => { - if (!innerLoadContext.rendered) { - innerLoadContext.rendered = true - return innerLoadContext.onReady?.() - } - } - - private resolvePreload = ( - innerLoadContext: InnerLoadContext, - matchId: string, - ): boolean => { - return !!( - innerLoadContext.preload && - !this.state.matches.some((d) => d.id === matchId) - ) - } - - private handleRedirectAndNotFound = ( - innerLoadContext: 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 - - const status = isRedirect(err) ? 'redirected' : 'notFound' - - innerLoadContext.updateMatch(match.id, (prev) => ({ - ...prev, - status, - isFetching: false, - error: err, - })) - - if (isNotFound(err) && !err.routeId) { - err.routeId = match.routeId - } - - match._nonReactive.loadPromise?.resolve() - } - - if (isRedirect(err)) { - innerLoadContext.rendered = true - err.options._fromLocation = innerLoadContext.location - err.redirectHandled = true - err = this.resolveRedirect(err) - throw err - } else { - this._handleNotFound(innerLoadContext, err) - throw err - } - } - - private shouldSkipLoader = (matchId: string): boolean => { - const match = this.getMatch(matchId)! - // upon hydration, we skip the loader if the match has been dehydrated on the server - if (!this.isServer && match._nonReactive.dehydrated) { - return true - } - - if (this.isServer) { - if (match.ssr === false) { - return true - } - } - return false - } - - private handleSerialError = ( - innerLoadContext: InnerLoadContext, - index: number, - err: any, - routerCode: string, - ): void => { - const { id: matchId, routeId } = innerLoadContext.matches[index]! - const route = this.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 - innerLoadContext.firstBadMatchIndex ??= index - this.handleRedirectAndNotFound( - innerLoadContext, - this.getMatch(matchId), - err, - ) - - try { - route.options.onError?.(err) - } catch (errorHandlerErr) { - err = errorHandlerErr - this.handleRedirectAndNotFound( - innerLoadContext, - this.getMatch(matchId), - err, - ) - } - - innerLoadContext.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(), - } - }) - } - - private isBeforeLoadSsr = ( - innerLoadContext: InnerLoadContext, - matchId: string, - index: number, - route: AnyRoute, - ): void | Promise => { - const existingMatch = this.getMatch(matchId)! - const parentMatchId = innerLoadContext.matches[index - 1]?.id - const parentMatch = parentMatchId - ? this.getMatch(parentMatchId)! - : undefined - - // in SPA mode, only SSR the root route - if (this.isShell()) { - existingMatch.ssr = matchId === rootRouteId - return - } - - if (parentMatch?.ssr === false) { - existingMatch.ssr = false - return - } - - const parentOverride = (tempSsr: boolean | 'data-only') => { - if (tempSsr === true && parentMatch?.ssr === 'data-only') { - return 'data-only' - } - return tempSsr - } - - const defaultSsr = this.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 } = this.getMatch(matchId)! - - const ssrFnContext: SsrContextOptions = { - search: makeMaybe(search, existingMatch.searchError), - params: makeMaybe(params, existingMatch.paramsError), - location: innerLoadContext.location, - matches: innerLoadContext.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 - } - - private setupPendingTimeout = ( - innerLoadContext: InnerLoadContext, - matchId: string, - route: AnyRoute, - ): void => { - const pendingMs = route.options.pendingMs ?? this.options.defaultPendingMs - const shouldPending = !!( - innerLoadContext.onReady && - !this.isServer && - !this.resolvePreload(innerLoadContext, matchId) && - (route.options.loader || - route.options.beforeLoad || - routeNeedsPreload(route)) && - typeof pendingMs === 'number' && - pendingMs !== Infinity && - (route.options.pendingComponent ?? - (this.options as any)?.defaultPendingComponent) - ) - const match = this.getMatch(matchId)! - if (shouldPending && match._nonReactive.pendingTimeout === undefined) { - const pendingTimeout = setTimeout(() => { - // Update the match and prematurely resolve the loadMatches promise so that - // the pending component can start rendering - this.triggerOnReady(innerLoadContext) - }, pendingMs) - match._nonReactive.pendingTimeout = pendingTimeout - } - } - - private shouldExecuteBeforeLoad = ( - innerLoadContext: InnerLoadContext, - matchId: string, - route: AnyRoute, - ): boolean | Promise => { - const existingMatch = this.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 true - - this.setupPendingTimeout(innerLoadContext, matchId, route) - - const then = () => { - let shouldExecuteBeforeLoad = true - const match = this.getMatch(matchId)! - if (match.status === 'error') { - shouldExecuteBeforeLoad = true - } else if ( - match.preload && - (match.status === 'redirected' || match.status === 'notFound') - ) { - this.handleRedirectAndNotFound(innerLoadContext, match, match.error) - } - return shouldExecuteBeforeLoad - } - - // Wait for the beforeLoad to resolve before we continue - return existingMatch._nonReactive.beforeLoadPromise - ? existingMatch._nonReactive.beforeLoadPromise.then(then) - : then() - } - - private executeBeforeLoad = ( - innerLoadContext: InnerLoadContext, - matchId: string, - index: number, - route: AnyRoute, - ): void | Promise => { - const match = this.getMatch(matchId)! - - match._nonReactive.beforeLoadPromise = createControlledPromise() - // explicitly capture the previous loadPromise - const prevLoadPromise = match._nonReactive.loadPromise - match._nonReactive.loadPromise = createControlledPromise(() => { - prevLoadPromise?.resolve() - }) - - const { paramsError, searchError } = match - - if (paramsError) { - this.handleSerialError( - innerLoadContext, - index, - paramsError, - 'PARSE_PARAMS', - ) - } - - if (searchError) { - this.handleSerialError( - innerLoadContext, - index, - searchError, - 'VALIDATE_SEARCH', - ) - } - - this.setupPendingTimeout(innerLoadContext, matchId, route) - - const abortController = new AbortController() - - const parentMatchId = innerLoadContext.matches[index - 1]?.id - const parentMatch = parentMatchId - ? this.getMatch(parentMatchId)! - : undefined - const parentMatchContext = - parentMatch?.context ?? this.options.context ?? undefined - - const context = { ...parentMatchContext, ...match.__routeContext } - - let isPending = false - const pending = () => { - if (isPending) return - isPending = true - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'beforeLoad', - fetchCount: prev.fetchCount + 1, - abortController, - context, - })) - } - - const resolve = () => { - match._nonReactive.beforeLoadPromise?.resolve() - match._nonReactive.beforeLoadPromise = undefined - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: false, - })) - } - - // if there is no `beforeLoad` option, skip everything, batch update the store, return early - if (!route.options.beforeLoad) { - batch(() => { - pending() - resolve() - }) - return - } - - const { search, params, cause } = match - const preload = this.resolvePreload(innerLoadContext, matchId) - const beforeLoadFnContext: BeforeLoadContextOptions< - any, - any, - any, - any, - any - > = { - search, - abortController, - params, - preload, - context, - location: innerLoadContext.location, - navigate: (opts: any) => - this.navigate({ ...opts, _fromLocation: innerLoadContext.location }), - buildLocation: this.buildLocation, - cause: preload ? 'preload' : cause, - matches: innerLoadContext.matches, - } - - const updateContext = (beforeLoadContext: any) => { - if (beforeLoadContext === undefined) { - batch(() => { - pending() - resolve() - }) - return - } - if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { - pending() - this.handleSerialError( - innerLoadContext, - index, - beforeLoadContext, - 'BEFORE_LOAD', - ) - } - - batch(() => { - pending() - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - __beforeLoadContext: beforeLoadContext, - context: { - ...prev.context, - ...beforeLoadContext, - }, - })) - resolve() - }) - } - - let beforeLoadContext - try { - beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext) - if (isPromise(beforeLoadContext)) { - pending() - return beforeLoadContext - .catch((err) => { - this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') - }) - .then(updateContext) - } - } catch (err) { - pending() - this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') - } - - updateContext(beforeLoadContext) - return - } - - private handleBeforeLoad = ( - innerLoadContext: InnerLoadContext, - index: number, - ): void | Promise => { - const { id: matchId, routeId } = innerLoadContext.matches[index]! - const route = this.looseRoutesById[routeId]! - - const serverSsr = () => { - // on the server, determine whether SSR the current match or not - if (this.isServer) { - const maybePromise = this.isBeforeLoadSsr( - innerLoadContext, - matchId, - index, - route, - ) - if (isPromise(maybePromise)) return maybePromise.then(queueExecution) - } - return queueExecution() - } - - const queueExecution = () => { - if (this.shouldSkipLoader(matchId)) return - const shouldExecuteBeforeLoadResult = this.shouldExecuteBeforeLoad( - innerLoadContext, - matchId, - route, - ) - return isPromise(shouldExecuteBeforeLoadResult) - ? shouldExecuteBeforeLoadResult.then(execute) - : execute(shouldExecuteBeforeLoadResult) - } - - const execute = (shouldExecuteBeforeLoad: boolean) => { - if (shouldExecuteBeforeLoad) { - // If we are not in the middle of a load OR the previous load failed, start it - return this.executeBeforeLoad(innerLoadContext, matchId, index, route) - } - return - } - - return serverSsr() - } - - private executeHead = ( - innerLoadContext: InnerLoadContext, - matchId: string, - route: AnyRoute, - ): void | Promise< - Pick< - AnyRouteMatch, - 'meta' | 'links' | 'headScripts' | 'headers' | 'scripts' | 'styles' - > - > => { - const match = this.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 = { - matches: innerLoadContext.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, - } - }) - } - - private potentialPendingMinPromise = ( - matchId: string, - ): void | ControlledPromise => { - const latestMatch = this.getMatch(matchId)! - return latestMatch._nonReactive.minPendingPromise - } - - private getLoaderContext = ( - innerLoadContext: InnerLoadContext, - matchId: string, - index: number, - route: AnyRoute, - ): LoaderFnContext => { - const parentMatchPromise = innerLoadContext.matchPromises[index - 1] as any - const { params, loaderDeps, abortController, context, cause } = - this.getMatch(matchId)! - - const preload = this.resolvePreload(innerLoadContext, matchId) - - return { - params, - deps: loaderDeps, - preload: !!preload, - parentMatchPromise, - abortController: abortController, - context, - location: innerLoadContext.location, - navigate: (opts) => - this.navigate({ ...opts, _fromLocation: innerLoadContext.location }), - cause: preload ? 'preload' : cause, - route, - } - } - - private runLoader = async ( - innerLoadContext: InnerLoadContext, - matchId: string, - index: number, - route: AnyRoute, - ): Promise => { - 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 - - // Actually run the loader and handle the result - try { - if (!this.isServer || this.getMatch(matchId)!.ssr === true) { - this.loadRouteChunk(route) - } - - // Kick off the loader! - const loaderResult = route.options.loader?.( - this.getLoaderContext(innerLoadContext, matchId, index, route), - ) - const loaderResultIsPromise = - route.options.loader && isPromise(loaderResult) - - const willLoadSomething = !!( - loaderResultIsPromise || - route._lazyPromise || - route._componentsPromise || - route.options.head || - route.options.scripts || - route.options.headers || - this.getMatch(matchId)!._nonReactive.minPendingPromise - ) - - if (willLoadSomething) { - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'loader', - })) - } - - if (route.options.loader) { - const loaderData = loaderResultIsPromise - ? await loaderResult - : loaderResult - - this.handleRedirectAndNotFound( - innerLoadContext, - this.getMatch(matchId), - loaderData, - ) - if (loaderData !== undefined) { - innerLoadContext.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 headResult = this.executeHead(innerLoadContext, matchId, route) - const head = headResult ? await headResult : undefined - const pendingPromise = this.potentialPendingMinPromise(matchId) - 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 - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - error: undefined, - status: 'success', - isFetching: false, - updatedAt: Date.now(), - ...head, - })) - } catch (e) { - let error = e - - const pendingPromise = this.potentialPendingMinPromise(matchId) - if (pendingPromise) await pendingPromise - - this.handleRedirectAndNotFound( - innerLoadContext, - this.getMatch(matchId), - e, - ) - - try { - route.options.onError?.(e) - } catch (onErrorError) { - error = onErrorError - this.handleRedirectAndNotFound( - innerLoadContext, - this.getMatch(matchId), - onErrorError, - ) - } - const headResult = this.executeHead(innerLoadContext, matchId, route) - const head = headResult ? await headResult : undefined - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - error, - status: 'error', - isFetching: false, - ...head, - })) - } - } catch (err) { - const match = this.getMatch(matchId) - // in case of a redirecting match during preload, the match does not exist - if (match) { - const headResult = this.executeHead(innerLoadContext, matchId, route) - if (headResult) { - const head = await headResult - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) - } - match._nonReactive.loaderPromise = undefined - } - this.handleRedirectAndNotFound(innerLoadContext, match, err) - } - } - - private loadRouteMatch = async ( - innerLoadContext: InnerLoadContext, - index: number, - ): Promise => { - const { id: matchId, routeId } = innerLoadContext.matches[index]! - let loaderShouldRunAsync = false - let loaderIsRunningAsync = false - const route = this.looseRoutesById[routeId]! - - const prevMatch = this.getMatch(matchId)! - if (this.shouldSkipLoader(matchId)) { - if (this.isServer) { - const headResult = this.executeHead(innerLoadContext, matchId, route) - if (headResult) { - const head = await headResult - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) - } - return this.getMatch(matchId)! - } - } - // there is a loaderPromise, so we are in the middle of a load - else 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' && - !innerLoadContext.sync && - !prevMatch.preload - ) { - return this.getMatch(matchId)! - } - await prevMatch._nonReactive.loaderPromise - const match = this.getMatch(matchId)! - if (match.error) { - this.handleRedirectAndNotFound(innerLoadContext, match, match.error) - } - } else { - // This is where all of the stale-while-revalidate magic happens - const age = Date.now() - this.getMatch(matchId)!.updatedAt - - const preload = this.resolvePreload(innerLoadContext, matchId) - - const staleAge = preload - ? (route.options.preloadStaleTime ?? - this.options.defaultPreloadStaleTime ?? - 30_000) // 30 seconds for preloads by default - : (route.options.staleTime ?? this.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( - this.getLoaderContext(innerLoadContext, matchId, index, route), - ) - : shouldReloadOption - - const nextPreload = - !!preload && !this.state.matches.some((d) => d.id === matchId) - const match = this.getMatch(matchId)! - match._nonReactive.loaderPromise = createControlledPromise() - if (nextPreload !== match.preload) { - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - preload: nextPreload, - })) - } - - // If the route is successful and still fresh, just resolve - const { status, invalid } = this.getMatch(matchId)! - loaderShouldRunAsync = - status === 'success' && (invalid || (shouldReload ?? age > staleAge)) - if (preload && route.options.preload === false) { - // Do nothing - } else if (loaderShouldRunAsync && !innerLoadContext.sync) { - loaderIsRunningAsync = true - ;(async () => { - try { - await this.runLoader(innerLoadContext, matchId, index, route) - const match = this.getMatch(matchId)! - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - match._nonReactive.loaderPromise = undefined - } catch (err) { - if (isRedirect(err)) { - await this.navigate(err.options) - } - } - })() - } else if ( - status !== 'success' || - (loaderShouldRunAsync && innerLoadContext.sync) - ) { - await this.runLoader(innerLoadContext, matchId, index, route) - } else { - // if the loader did not run, still update head. - // reason: parent's beforeLoad may have changed the route context - // and only now do we know the route context (and that the loader would not run) - const headResult = this.executeHead(innerLoadContext, matchId, route) - if (headResult) { - const head = await headResult - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) - } - } - } - const match = this.getMatch(matchId)! - if (!loaderIsRunningAsync) { - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - } - - 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) { - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: nextIsFetching, - invalid: false, - })) - } - return this.getMatch(matchId)! - } - - loadMatches = async (baseContext: { - location: ParsedLocation - matches: Array - preload?: boolean - onReady?: () => Promise - updateMatch?: UpdateMatchFn - sync?: boolean - }): Promise> => { - const innerLoadContext = baseContext as InnerLoadContext - innerLoadContext.updateMatch ??= this.updateMatch - innerLoadContext.matchPromises = [] - - // 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 (!this.isServer && this.state.matches.some((d) => d._forcePending)) { - this.triggerOnReady(innerLoadContext) - } - - try { - // Execute all beforeLoads one by one - for (let i = 0; i < innerLoadContext.matches.length; i++) { - const beforeLoad = this.handleBeforeLoad(innerLoadContext, i) - if (isPromise(beforeLoad)) await beforeLoad - } - - // Execute all loaders in parallel - const max = - innerLoadContext.firstBadMatchIndex ?? innerLoadContext.matches.length - for (let i = 0; i < max; i++) { - innerLoadContext.matchPromises.push( - this.loadRouteMatch(innerLoadContext, i), - ) - } - await Promise.all(innerLoadContext.matchPromises) - - const readyPromise = this.triggerOnReady(innerLoadContext) - if (isPromise(readyPromise)) await readyPromise - } catch (err) { - if (isNotFound(err) && !innerLoadContext.preload) { - const readyPromise = this.triggerOnReady(innerLoadContext) - if (isPromise(readyPromise)) await readyPromise - throw err - } - if (isRedirect(err)) { - throw err - } - } - - return innerLoadContext.matches - } - invalidate: InvalidateFn< RouterCore< TRouteTree, @@ -3055,47 +2175,7 @@ export class RouterCore< this.clearCache({ filter }) } - loadRouteChunk = (route: AnyRoute) => { - 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 - } - } - - // If for some reason lazy resolves more lazy components... - // We'll wait for that before we attempt to preload the - // components themselves. - if (!route._componentsLoaded && route._componentsPromise === undefined) { - const loadComponents = () => { - const preloads = [] - for (const type of componentTypes) { - const preload = (route.options[type] as any)?.preload - if (preload) preloads.push(preload()) - } - if (preloads.length) - return Promise.all(preloads).then(() => { - route._componentsLoaded = true - route._componentsPromise = undefined // gc promise, we won't need it anymore - }) - route._componentsLoaded = true - route._componentsPromise = undefined // gc promise, we won't need it anymore - return - } - route._componentsPromise = route._lazyPromise - ? route._lazyPromise.then(loadComponents) - : loadComponents() - } - - return route._componentsPromise - } + loadRouteChunk = loadRouteChunk preloadRoute: PreloadRouteFn< TRouteTree, @@ -3135,7 +2215,8 @@ export class RouterCore< }) try { - matches = await this.loadMatches({ + matches = await loadMatches({ + router: this, matches, location: next, preload: true, @@ -3233,58 +2314,6 @@ export class RouterCore< serverSsr?: ServerSsr - private _handleNotFound = ( - innerLoadContext: InnerLoadContext, - err: NotFoundError, - ) => { - // Find the route that should handle the not found error - // First check if a specific route is requested to show the error - const routeCursor = this.routesById[err.routeId ?? ''] ?? this.routeTree - const matchesByRouteId: Record = {} - - // Setup routesByRouteId object for quick access - for (const match of innerLoadContext.matches) { - matchesByRouteId[match.routeId] = match - } - - // Ensure a NotFoundComponent exists on the route - if ( - !routeCursor.options.notFoundComponent && - (this.options as any)?.defaultNotFoundComponent - ) { - routeCursor.options.notFoundComponent = ( - this.options as any - ).defaultNotFoundComponent - } - - // Ensure we have a notFoundComponent - invariant( - routeCursor.options.notFoundComponent, - 'No notFoundComponent found. Please set a notFoundComponent on your route or provide a defaultNotFoundComponent to the router.', - ) - - // Find the match for this route - const matchForRoute = matchesByRouteId[routeCursor.id] - - invariant( - matchForRoute, - 'Could not find match for route: ' + routeCursor.id, - ) - - // Assign the error to the match - using non-null assertion since we've checked with invariant - innerLoadContext.updateMatch(matchForRoute.id, (prev) => ({ - ...prev, - status: 'notFound', - error: err, - isFetching: false, - })) - - if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) { - err.routeId = routeCursor.parentRoute.id - this._handleNotFound(innerLoadContext, err) - } - } - hasNotFoundMatch = () => { return this.__store.state.matches.some( (d) => d.status === 'notFound' || d.globalNotFound, @@ -3296,16 +2325,6 @@ export class SearchParamError extends Error {} export class PathParamError extends Error {} -function makeMaybe( - 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 } -} - const normalize = (str: string) => str.endsWith('/') && str.length > 1 ? str.slice(0, -1) : str function comparePaths(a: string, b: string) { @@ -3372,22 +2391,6 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { return {} } -export const componentTypes = [ - 'component', - 'errorComponent', - 'pendingComponent', - 'notFoundComponent', -] as const - -function routeNeedsPreload(route: AnyRoute) { - for (const componentType of componentTypes) { - if ((route.options[componentType] as any)?.preload) { - return true - } - } - return false -} - interface RouteLike { id: string isRoot?: boolean