From 733188feb7747e63843a785c24cdd0dd968ed125 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 17:19:40 +0200 Subject: [PATCH 1/7] refactor(router-core): flatten loadMatches --- packages/router-core/src/router.ts | 1389 ++++++++++++++-------------- 1 file changed, 717 insertions(+), 672 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index adc0e1c9f75..ebc560b6b42 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -763,6 +763,17 @@ export type CreateRouterFn = < TDehydrated > +type InnerLoadContext = { + location: ParsedLocation + firstBadMatchIndex?: number + rendered?: boolean + updateMatch: UpdateMatchFn + matches: Array + preload?: boolean + onReady?: () => Promise + sync?: boolean +} + export class RouterCore< in out TRouteTree extends AnyRoute, in out TTrailingSlashOption extends TrailingSlashOption, @@ -1894,6 +1905,7 @@ export class RouterCore< 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 @@ -2083,718 +2095,744 @@ export class RouterCore< ) } - loadMatches = async ({ - location, - matches, - preload: allPreload, - onReady, - updateMatch = this.updateMatch, - sync, - }: { - location: ParsedLocation - matches: Array - preload?: boolean - onReady?: () => Promise - updateMatch?: ( - id: string, - updater: (match: AnyRouteMatch) => AnyRouteMatch, - ) => void - getMatch?: (matchId: string) => AnyRouteMatch | undefined - sync?: boolean - }): Promise> => { - let firstBadMatchIndex: number | undefined - let rendered = false + private executeHead( + route: AnyRoute, + matchId: string, + matches: Array, + ) { + 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, + 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 - const triggerOnReady = async () => { - if (!rendered) { - rendered = true - await onReady?.() + return { + meta, + links, + headScripts, + headers, + scripts, + styles, } + }) + } + + private handleSerialError( + innerLoadContext: InnerLoadContext, + index: number, + err: any, + routerCode: string, + ) { + 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, + ) } - const resolvePreload = (matchId: string) => { - return !!(allPreload && !this.state.matches.some((d) => d.id === matchId)) + 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 shouldSkipLoader(matchId: string) { + 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 } - // 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)) { - triggerOnReady() + if (this.isServer) { + if (match.ssr === false) { + return true + } } + return false + } - const handleRedirectAndNotFound = ( - match: AnyRouteMatch | undefined, - err: unknown, - ) => { - if (!isRedirect(err) && !isNotFound(err)) return + private resolvePreload(allPreload: boolean | undefined, matchId: string) { + return !!(allPreload && !this.state.matches.some((d) => d.id === matchId)) + } - if ( - isRedirect(err) && - err.redirectHandled && - !err.options.reloadDocument + async handleBeforeLoad(innerLoadContext: InnerLoadContext, index: number) { + const { id: matchId, routeId } = innerLoadContext.matches[index]! + const existingMatch = this.getMatch(matchId)! + const parentMatchId = innerLoadContext.matches[index - 1]?.id + const parentMatch = parentMatchId + ? this.getMatch(parentMatchId)! + : undefined + + const route = this.looseRoutesById[routeId]! + + const pendingMs = route.options.pendingMs ?? this.options.defaultPendingMs + + // on the server, determine whether SSR the current match or not + if (this.isServer) { + let ssr: boolean | 'data-only' + // in SPA mode, only SSR the root route + if (this.isShell()) { + ssr = matchId === rootRouteId + } else { + const defaultSsr = this.options.defaultSsr ?? true + if (parentMatch?.ssr === false) { + ssr = false + } else { + let tempSsr: boolean | 'data-only' + if (route.options.ssr === undefined) { + tempSsr = defaultSsr + } else if (typeof route.options.ssr === 'function') { + 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, + })), + } + tempSsr = (await route.options.ssr(ssrFnContext)) ?? defaultSsr + } else { + tempSsr = route.options.ssr + } + + if (tempSsr === true && parentMatch?.ssr === 'data-only') { + ssr = 'data-only' + } else { + ssr = tempSsr + } + } + } + existingMatch.ssr = ssr + } + + if (this.shouldSkipLoader(matchId)) { + return + } + + const shouldPending = !!( + innerLoadContext.onReady && + !this.isServer && + !this.resolvePreload(innerLoadContext.preload, matchId) && + (route.options.loader || + route.options.beforeLoad || + routeNeedsPreload(route)) && + typeof pendingMs === 'number' && + pendingMs !== Infinity && + (route.options.pendingComponent ?? + (this.options as any)?.defaultPendingComponent) + ) + + let executeBeforeLoad = true + const setupPendingTimeout = () => { + if (!shouldPending) return + const match = this.getMatch(matchId)! + if (match._nonReactive.pendingTimeout === undefined) { + const pendingTimeout = setTimeout(() => { + try { + // Update the match and prematurely resolve the loadMatches promise so that + // the pending component can start rendering + this.triggerOnReady(innerLoadContext) + } catch {} + }, pendingMs) + match._nonReactive.pendingTimeout = pendingTimeout + } + } + if ( + // 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) + existingMatch._nonReactive.beforeLoadPromise || + existingMatch._nonReactive.loaderPromise + ) { + setupPendingTimeout() + + // Wait for the beforeLoad to resolve before we continue + await existingMatch._nonReactive.beforeLoadPromise + const match = this.getMatch(matchId)! + if (match.status === 'error') { + executeBeforeLoad = true + } else if ( + match.preload && + (match.status === 'redirected' || match.status === 'notFound') ) { - throw err + this.handleRedirectAndNotFound(innerLoadContext, match, match.error) } + } + if (executeBeforeLoad) { + // If we are not in the middle of a load OR the previous load failed, start it + try { + 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 } = this.getMatch(matchId)! + + if (paramsError) { + this.handleSerialError( + innerLoadContext, + index, + paramsError, + 'PARSE_PARAMS', + ) + } - // 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 + if (searchError) { + this.handleSerialError( + innerLoadContext, + index, + searchError, + 'VALIDATE_SEARCH', + ) + } + + setupPendingTimeout() + + const abortController = new AbortController() - const status = isRedirect(err) ? 'redirected' : 'notFound' + const parentMatchContext = + parentMatch?.context ?? this.options.context ?? undefined - updateMatch(match.id, (prev) => ({ + innerLoadContext.updateMatch(matchId, (prev) => ({ ...prev, - status, - isFetching: false, - error: err, + isFetching: 'beforeLoad', + fetchCount: prev.fetchCount + 1, + abortController, + context: { + ...parentMatchContext, + ...prev.__routeContext, + }, })) - if (isNotFound(err) && !err.routeId) { - err.routeId = match.routeId + const { search, params, context, cause } = this.getMatch(matchId)! + + const preload = this.resolvePreload(innerLoadContext.preload, 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 beforeLoadContext = + await route.options.beforeLoad?.(beforeLoadFnContext) + + if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { + this.handleSerialError( + innerLoadContext, + index, + beforeLoadContext, + 'BEFORE_LOAD', + ) } - match._nonReactive.loadPromise?.resolve() + innerLoadContext.updateMatch(matchId, (prev) => { + return { + ...prev, + __beforeLoadContext: beforeLoadContext, + context: { + ...parentMatchContext, + ...prev.__routeContext, + ...beforeLoadContext, + }, + abortController, + } + }) + } catch (err) { + this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') } - if (isRedirect(err)) { - rendered = true - err.options._fromLocation = location - err.redirectHandled = true - err = this.resolveRedirect(err) - throw err - } else { - this._handleNotFound(matches, err, updateMatch) - throw err - } + innerLoadContext.updateMatch(matchId, (prev) => { + prev._nonReactive.beforeLoadPromise?.resolve() + prev._nonReactive.beforeLoadPromise = undefined + + return { + ...prev, + isFetching: false, + } + }) } + } - const shouldSkipLoader = (matchId: string) => { - 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 + private handleRedirectAndNotFound( + innerLoadContext: InnerLoadContext, + match: AnyRouteMatch | undefined, + err: unknown, + ) { + 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.matches, + err, + innerLoadContext.updateMatch, + ) + throw err + } + } + + private async triggerOnReady(innerLoadContext: InnerLoadContext) { + if (!innerLoadContext.rendered) { + innerLoadContext.rendered = true + if (innerLoadContext.onReady) await innerLoadContext.onReady() + } + } + + private async loadRouteMatch( + innerLoadContext: InnerLoadContext, + index: number, + matchPromises: Array>, + ): Promise { + const { id: matchId, routeId } = innerLoadContext.matches[index]! + let loaderShouldRunAsync = false + let loaderIsRunningAsync = false + const route = this.looseRoutesById[routeId]! + + const potentialPendingMinPromise = () => { + const latestMatch = this.getMatch(matchId)! + return latestMatch._nonReactive.minPendingPromise + } + + const prevMatch = this.getMatch(matchId)! + if (this.shouldSkipLoader(matchId)) { if (this.isServer) { - if (match.ssr === false) { - return true + const headResult = this.executeHead( + route, + matchId, + innerLoadContext.matches, + ) + if (headResult) { + const head = await headResult + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + ...head, + })) } + return this.getMatch(matchId)! } - return false } + // 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 { + const parentMatchPromise = matchPromises[index - 1] as any - try { - await new Promise((resolveAll, rejectAll) => { - ;(async () => { - try { - const handleSerialError = ( - index: number, - err: any, - routerCode: string, - ) => { - const { id: matchId, routeId } = 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 - } + const getLoaderContext = (): LoaderFnContext => { + const match = this.getMatch(matchId)! - err.routerCode = routerCode - firstBadMatchIndex = firstBadMatchIndex ?? index - handleRedirectAndNotFound(this.getMatch(matchId), err) + const preload = this.resolvePreload(innerLoadContext.preload, matchId) - try { - route.options.onError?.(err) - } catch (errorHandlerErr) { - err = errorHandlerErr - handleRedirectAndNotFound(this.getMatch(matchId), err) - } + return { + params: match.params, + deps: match.loaderDeps, + preload: !!preload, + parentMatchPromise, + abortController: match.abortController, + context: match.context, + location: innerLoadContext.location, + navigate: (opts) => + this.navigate({ + ...opts, + _fromLocation: innerLoadContext.location, + }), + cause: preload ? 'preload' : match.cause, + route, + } + } - 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(), - } - }) + // This is where all of the stale-while-revalidate magic happens + const age = Date.now() - this.getMatch(matchId)!.updatedAt + + const preload = this.resolvePreload(innerLoadContext.preload, 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(getLoaderContext()) + : shouldReloadOption + + innerLoadContext.updateMatch(matchId, (prev) => { + prev._nonReactive.loaderPromise = createControlledPromise() + return { + ...prev, + preload: + !!preload && !this.state.matches.some((d) => d.id === matchId), + } + }) + + const runLoader = async () => { + 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) } - for (const [index, { id: matchId, routeId }] of matches.entries()) { - const existingMatch = this.getMatch(matchId)! - const parentMatchId = matches[index - 1]?.id - const parentMatch = parentMatchId - ? this.getMatch(parentMatchId)! - : undefined - - const route = this.looseRoutesById[routeId]! - - const pendingMs = - route.options.pendingMs ?? this.options.defaultPendingMs - - // on the server, determine whether SSR the current match or not - if (this.isServer) { - let ssr: boolean | 'data-only' - // in SPA mode, only SSR the root route - if (this.isShell()) { - ssr = matchId === rootRouteId - } else { - const defaultSsr = this.options.defaultSsr ?? true - if (parentMatch?.ssr === false) { - ssr = false - } else { - let tempSsr: boolean | 'data-only' - if (route.options.ssr === undefined) { - tempSsr = defaultSsr - } else if (typeof route.options.ssr === 'function') { - const { search, params } = this.getMatch(matchId)! - - function makeMaybe(value: any, error: any) { - if (error) { - return { status: 'error' as const, error } - } - return { status: 'success' as const, value } - } - - const ssrFnContext: SsrContextOptions = { - search: makeMaybe(search, existingMatch.searchError), - params: makeMaybe(params, existingMatch.paramsError), - location, - matches: 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, - })), - } - tempSsr = - (await route.options.ssr(ssrFnContext)) ?? defaultSsr - } else { - tempSsr = route.options.ssr - } + // Kick off the loader! + const loaderResult = route.options.loader?.(getLoaderContext()) + 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 (tempSsr === true && parentMatch?.ssr === 'data-only') { - ssr = 'data-only' - } else { - ssr = tempSsr - } - } - } - existingMatch.ssr = ssr - } + if (willLoadSomething) { + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'loader', + })) + } - if (shouldSkipLoader(matchId)) { - continue - } + if (route.options.loader) { + const loaderData = loaderResultIsPromise + ? await loaderResult + : loaderResult - const shouldPending = !!( - onReady && - !this.isServer && - !resolvePreload(matchId) && - (route.options.loader || - route.options.beforeLoad || - routeNeedsPreload(route)) && - typeof pendingMs === 'number' && - pendingMs !== Infinity && - (route.options.pendingComponent ?? - (this.options as any)?.defaultPendingComponent) + this.handleRedirectAndNotFound( + innerLoadContext, + this.getMatch(matchId), + loaderData, ) + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + loaderData, + })) + } - let executeBeforeLoad = true - const setupPendingTimeout = () => { - const match = this.getMatch(matchId)! - if ( - shouldPending && - match._nonReactive.pendingTimeout === undefined - ) { - const pendingTimeout = setTimeout(() => { - try { - // Update the match and prematurely resolve the loadMatches promise so that - // the pending component can start rendering - triggerOnReady() - } catch {} - }, pendingMs) - match._nonReactive.pendingTimeout = pendingTimeout - } - } - if ( - // 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) - existingMatch._nonReactive.beforeLoadPromise || - existingMatch._nonReactive.loaderPromise - ) { - setupPendingTimeout() - - // Wait for the beforeLoad to resolve before we continue - await existingMatch._nonReactive.beforeLoadPromise - const match = this.getMatch(matchId)! - if (match.status === 'error') { - executeBeforeLoad = true - } else if ( - match.preload && - (match.status === 'redirected' || match.status === 'notFound') - ) { - handleRedirectAndNotFound(match, match.error) - } - } - if (executeBeforeLoad) { - // If we are not in the middle of a load OR the previous load failed, start it - try { - 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 } = this.getMatch(matchId)! - - if (paramsError) { - handleSerialError(index, paramsError, 'PARSE_PARAMS') - } - - if (searchError) { - handleSerialError(index, searchError, 'VALIDATE_SEARCH') - } - - setupPendingTimeout() - - const abortController = new AbortController() - - const parentMatchContext = - parentMatch?.context ?? this.options.context ?? undefined - - updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'beforeLoad', - fetchCount: prev.fetchCount + 1, - abortController, - context: { - ...parentMatchContext, - ...prev.__routeContext, - }, - })) - - const { search, params, context, cause } = - this.getMatch(matchId)! - - const preload = resolvePreload(matchId) - - const beforeLoadFnContext: BeforeLoadContextOptions< - any, - any, - any, - any, - any - > = { - search, - abortController, - params, - preload, - context, - location, - navigate: (opts: any) => - this.navigate({ ...opts, _fromLocation: location }), - buildLocation: this.buildLocation, - cause: preload ? 'preload' : cause, - matches, - } - - const beforeLoadContext = - await route.options.beforeLoad?.(beforeLoadFnContext) - - if ( - isRedirect(beforeLoadContext) || - isNotFound(beforeLoadContext) - ) { - handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD') - } - - updateMatch(matchId, (prev) => { - return { - ...prev, - __beforeLoadContext: beforeLoadContext, - context: { - ...parentMatchContext, - ...prev.__routeContext, - ...beforeLoadContext, - }, - abortController, - } - }) - } catch (err) { - handleSerialError(index, err, 'BEFORE_LOAD') - } - - updateMatch(matchId, (prev) => { - prev._nonReactive.beforeLoadPromise?.resolve() - prev._nonReactive.beforeLoadPromise = undefined - - return { - ...prev, - isFetching: false, - } - }) - } + // 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( + route, + matchId, + innerLoadContext.matches, + ) + const head = headResult ? await headResult : undefined + const pendingPromise = potentialPendingMinPromise() + 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 + + await potentialPendingMinPromise() + + 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( + route, + matchId, + innerLoadContext.matches, + ) + 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( + route, + matchId, + innerLoadContext.matches, + ) + if (headResult) { + const head = await headResult + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + ...head, + })) } + match._nonReactive.loaderPromise = undefined + } + this.handleRedirectAndNotFound(innerLoadContext, match, err) + } + } - const validResolvedMatches = matches.slice(0, firstBadMatchIndex) - const matchPromises: Array> = [] + // 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 runLoader() + 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 runLoader() + } 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( + route, + matchId, + innerLoadContext.matches, + ) + if (headResult) { + const head = await headResult + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + ...head, + })) + } + } + } + if (!loaderIsRunningAsync) { + const match = this.getMatch(matchId)! + match._nonReactive.loaderPromise?.resolve() + match._nonReactive.loadPromise?.resolve() + } - validResolvedMatches.forEach(({ id: matchId, routeId }, index) => { - matchPromises.push( - (async () => { - let loaderShouldRunAsync = false - let loaderIsRunningAsync = false - const route = this.looseRoutesById[routeId]! - - const executeHead = () => { - 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, - match, - params: match.params, - loaderData: match.loaderData, - } + innerLoadContext.updateMatch(matchId, (prev) => { + clearTimeout(prev._nonReactive.pendingTimeout) + prev._nonReactive.pendingTimeout = undefined + if (!loaderIsRunningAsync) prev._nonReactive.loaderPromise = undefined + prev._nonReactive.dehydrated = undefined + return { + ...prev, + isFetching: loaderIsRunningAsync ? prev.isFetching : false, + invalid: false, + } + }) + return this.getMatch(matchId)! + } - 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 = () => { - const latestMatch = this.getMatch(matchId)! - return latestMatch._nonReactive.minPendingPromise - } - - const prevMatch = this.getMatch(matchId)! - if (shouldSkipLoader(matchId)) { - if (this.isServer) { - const headResult = executeHead() - if (headResult) { - const head = await headResult - 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' && - !sync && - !prevMatch.preload - ) { - return this.getMatch(matchId)! - } - await prevMatch._nonReactive.loaderPromise - const match = this.getMatch(matchId)! - if (match.error) { - handleRedirectAndNotFound(match, match.error) - } - } else { - const parentMatchPromise = matchPromises[index - 1] as any - - const getLoaderContext = (): LoaderFnContext => { - const { - params, - loaderDeps, - abortController, - context, - cause, - } = this.getMatch(matchId)! - - const preload = resolvePreload(matchId) - - return { - params, - deps: loaderDeps, - preload: !!preload, - parentMatchPromise, - abortController: abortController, - context, - location, - navigate: (opts) => - this.navigate({ ...opts, _fromLocation: location }), - cause: preload ? 'preload' : cause, - route, - } - } + loadMatches = async (baseContext: { + location: ParsedLocation + matches: Array + preload?: boolean + onReady?: () => Promise + updateMatch: UpdateMatchFn + sync?: boolean + }): Promise> => { + const innerLoadContext = baseContext as InnerLoadContext - // This is where all of the stale-while-revalidate magic happens - const age = Date.now() - this.getMatch(matchId)!.updatedAt - - const preload = resolvePreload(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(getLoaderContext()) - : shouldReloadOption - - updateMatch(matchId, (prev) => { - prev._nonReactive.loaderPromise = - createControlledPromise() - return { - ...prev, - preload: - !!preload && - !this.state.matches.some((d) => d.id === matchId), - } - }) - - const runLoader = async () => { - 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?.(getLoaderContext()) - 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) { - updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'loader', - })) - } - - if (route.options.loader) { - const loaderData = loaderResultIsPromise - ? await loaderResult - : loaderResult - - handleRedirectAndNotFound( - this.getMatch(matchId), - loaderData, - ) - 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() - const head = headResult ? await headResult : undefined - const pendingPromise = potentialPendingMinPromise() - 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 - updateMatch(matchId, (prev) => ({ - ...prev, - error: undefined, - status: 'success', - isFetching: false, - updatedAt: Date.now(), - ...head, - })) - } catch (e) { - let error = e - - await potentialPendingMinPromise() - - handleRedirectAndNotFound(this.getMatch(matchId), e) - - try { - route.options.onError?.(e) - } catch (onErrorError) { - error = onErrorError - handleRedirectAndNotFound( - this.getMatch(matchId), - onErrorError, - ) - } - const headResult = executeHead() - const head = headResult ? await headResult : undefined - 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 = executeHead() - if (headResult) { - const head = await headResult - updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) - } - match._nonReactive.loaderPromise = undefined - } - handleRedirectAndNotFound(match, err) - } - } + // 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) + } - // 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 && !sync) { - loaderIsRunningAsync = true - ;(async () => { - try { - await runLoader() - 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 && sync) - ) { - await runLoader() - } 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() - if (headResult) { - const head = await headResult - updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) - } - } - } - if (!loaderIsRunningAsync) { - const match = this.getMatch(matchId)! - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - } - - updateMatch(matchId, (prev) => { - clearTimeout(prev._nonReactive.pendingTimeout) - prev._nonReactive.pendingTimeout = undefined - if (!loaderIsRunningAsync) - prev._nonReactive.loaderPromise = undefined - prev._nonReactive.dehydrated = undefined - return { - ...prev, - isFetching: loaderIsRunningAsync - ? prev.isFetching - : false, - invalid: false, - } - }) - return this.getMatch(matchId)! - })(), - ) - }) + try { + await new Promise((resolveAll, rejectAll) => { + ;(async () => { + try { + // Execute all `beforeLoad` one by one + for (let i = 0; i < innerLoadContext.matches.length; i++) { + await this.handleBeforeLoad(innerLoadContext, i) + } + // Execute all loaders in parallel + const matchPromises: Array> = [] + const skip = + innerLoadContext.firstBadMatchIndex ?? + innerLoadContext.matches.length + for (let i = 0; i < skip; i++) { + matchPromises.push( + this.loadRouteMatch(innerLoadContext, i, matchPromises), + ) + } await Promise.all(matchPromises) resolveAll() @@ -2803,18 +2841,18 @@ export class RouterCore< } })() }) - await triggerOnReady() + await this.triggerOnReady(innerLoadContext) } catch (err) { - if (isRedirect(err) || isNotFound(err)) { - if (isNotFound(err) && !allPreload) { - await triggerOnReady() - } - + if (isNotFound(err) && !innerLoadContext.preload) { + await this.triggerOnReady(innerLoadContext) + throw err + } + if (isRedirect(err)) { throw err } } - return matches + return innerLoadContext.matches } invalidate: InvalidateFn< @@ -3155,6 +3193,13 @@ export class SearchParamError extends Error {} export class PathParamError extends Error {} +function makeMaybe(value: any, error: any) { + 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) { From 1bd650724d8080baf6dd0467574e484509ff1172 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 17:34:38 +0200 Subject: [PATCH 2/7] reorder to make diff easier --- packages/router-core/src/router.ts | 210 ++++++++++++++--------------- 1 file changed, 105 insertions(+), 105 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ebc560b6b42..2581a3dd09f 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2095,49 +2095,80 @@ export class RouterCore< ) } - private executeHead( - route: AnyRoute, - matchId: string, - matches: Array, + private async triggerOnReady(innerLoadContext: InnerLoadContext) { + if (!innerLoadContext.rendered) { + innerLoadContext.rendered = true + if (innerLoadContext.onReady) await innerLoadContext.onReady() + } + } + + private resolvePreload(allPreload: boolean | undefined, matchId: string) { + return !!(allPreload && !this.state.matches.some((d) => d.id === matchId)) + } + + private handleRedirectAndNotFound( + innerLoadContext: InnerLoadContext, + match: AnyRouteMatch | undefined, + err: unknown, ) { - const match = this.getMatch(matchId) - // in case of a redirecting match during preload, the match does not exist - if (!match) { - return + if (!isRedirect(err) && !isNotFound(err)) return + + if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) { + throw err } - if ( - !route.options.head && - !route.options.scripts && - !route.options.headers - ) { - return + + // 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() } - const assetContext = { - matches, - match, - params: match.params, - loaderData: match.loaderData, + + if (isRedirect(err)) { + innerLoadContext.rendered = true + err.options._fromLocation = innerLoadContext.location + err.redirectHandled = true + err = this.resolveRedirect(err) + throw err + } else { + this._handleNotFound( + innerLoadContext.matches, + err, + innerLoadContext.updateMatch, + ) + throw err } + } - 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 + private shouldSkipLoader(matchId: string) { + 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 + } - return { - meta, - links, - headScripts, - headers, - scripts, - styles, + if (this.isServer) { + if (match.ssr === false) { + return true } - }) + } + return false } private handleSerialError( @@ -2191,25 +2222,6 @@ export class RouterCore< }) } - private shouldSkipLoader(matchId: string) { - 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 resolvePreload(allPreload: boolean | undefined, matchId: string) { - return !!(allPreload && !this.state.matches.some((d) => d.id === matchId)) - } - async handleBeforeLoad(innerLoadContext: InnerLoadContext, index: number) { const { id: matchId, routeId } = innerLoadContext.matches[index]! const existingMatch = this.getMatch(matchId)! @@ -2438,61 +2450,49 @@ export class RouterCore< } } - private handleRedirectAndNotFound( - innerLoadContext: InnerLoadContext, - match: AnyRouteMatch | undefined, - err: unknown, + private executeHead( + route: AnyRoute, + matchId: string, + matches: Array, ) { - if (!isRedirect(err) && !isNotFound(err)) return - - if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) { - throw err - } - + const match = this.getMatch(matchId) // 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 (!match) { + return } - - if (isRedirect(err)) { - innerLoadContext.rendered = true - err.options._fromLocation = innerLoadContext.location - err.redirectHandled = true - err = this.resolveRedirect(err) - throw err - } else { - this._handleNotFound( - innerLoadContext.matches, - err, - innerLoadContext.updateMatch, - ) - throw err + if ( + !route.options.head && + !route.options.scripts && + !route.options.headers + ) { + return } - } - - private async triggerOnReady(innerLoadContext: InnerLoadContext) { - if (!innerLoadContext.rendered) { - innerLoadContext.rendered = true - if (innerLoadContext.onReady) await innerLoadContext.onReady() + const assetContext = { + 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 async loadRouteMatch( From d054790a0ee7d97f748b9811abb9d71010d8a7d5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 18:42:03 +0200 Subject: [PATCH 3/7] use arrow functions --- packages/router-core/src/router.ts | 54 ++++++++++++++---------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2581a3dd09f..0e6057ba8e0 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1905,7 +1905,6 @@ export class RouterCore< 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,22 +2094,25 @@ export class RouterCore< ) } - private async triggerOnReady(innerLoadContext: InnerLoadContext) { + private triggerOnReady = async (innerLoadContext: InnerLoadContext) => { if (!innerLoadContext.rendered) { innerLoadContext.rendered = true if (innerLoadContext.onReady) await innerLoadContext.onReady() } } - private resolvePreload(allPreload: boolean | undefined, matchId: string) { + private resolvePreload = ( + allPreload: boolean | undefined, + matchId: string, + ) => { return !!(allPreload && !this.state.matches.some((d) => d.id === matchId)) } - private handleRedirectAndNotFound( + private handleRedirectAndNotFound = ( innerLoadContext: InnerLoadContext, match: AnyRouteMatch | undefined, err: unknown, - ) { + ) => { if (!isRedirect(err) && !isNotFound(err)) return if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) { @@ -2147,16 +2149,12 @@ export class RouterCore< err = this.resolveRedirect(err) throw err } else { - this._handleNotFound( - innerLoadContext.matches, - err, - innerLoadContext.updateMatch, - ) + this._handleNotFound(innerLoadContext, err) throw err } } - private shouldSkipLoader(matchId: string) { + private shouldSkipLoader = (matchId: string) => { 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) { @@ -2171,12 +2169,12 @@ export class RouterCore< return false } - private handleSerialError( + private handleSerialError = ( innerLoadContext: InnerLoadContext, index: number, err: any, routerCode: string, - ) { + ) => { const { id: matchId, routeId } = innerLoadContext.matches[index]! const route = this.looseRoutesById[routeId]! @@ -2222,7 +2220,10 @@ export class RouterCore< }) } - async handleBeforeLoad(innerLoadContext: InnerLoadContext, index: number) { + private handleBeforeLoad = async ( + innerLoadContext: InnerLoadContext, + index: number, + ) => { const { id: matchId, routeId } = innerLoadContext.matches[index]! const existingMatch = this.getMatch(matchId)! const parentMatchId = innerLoadContext.matches[index - 1]?.id @@ -2450,11 +2451,11 @@ export class RouterCore< } } - private executeHead( + private executeHead = ( route: AnyRoute, matchId: string, matches: Array, - ) { + ) => { const match = this.getMatch(matchId) // in case of a redirecting match during preload, the match does not exist if (!match) { @@ -2495,11 +2496,11 @@ export class RouterCore< }) } - private async loadRouteMatch( + private loadRouteMatch = async ( innerLoadContext: InnerLoadContext, index: number, matchPromises: Array>, - ): Promise { + ): Promise => { const { id: matchId, routeId } = innerLoadContext.matches[index]! let loaderShouldRunAsync = false let loaderIsRunningAsync = false @@ -2803,10 +2804,11 @@ export class RouterCore< matches: Array preload?: boolean onReady?: () => Promise - updateMatch: UpdateMatchFn + updateMatch?: UpdateMatchFn sync?: boolean }): Promise> => { const innerLoadContext = baseContext as InnerLoadContext + innerLoadContext.updateMatch ??= this.updateMatch // 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 @@ -3126,13 +3128,9 @@ export class RouterCore< serverSsr?: ServerSsr - _handleNotFound = ( - matches: Array, + private _handleNotFound = ( + innerLoadContext: InnerLoadContext, err: NotFoundError, - updateMatch: ( - id: string, - updater: (match: AnyRouteMatch) => AnyRouteMatch, - ) => void = this.updateMatch, ) => { // Find the route that should handle the not found error // First check if a specific route is requested to show the error @@ -3140,7 +3138,7 @@ export class RouterCore< const matchesByRouteId: Record = {} // Setup routesByRouteId object for quick access - for (const match of matches) { + for (const match of innerLoadContext.matches) { matchesByRouteId[match.routeId] = match } @@ -3169,7 +3167,7 @@ export class RouterCore< ) // Assign the error to the match - using non-null assertion since we've checked with invariant - updateMatch(matchForRoute.id, (prev) => ({ + innerLoadContext.updateMatch(matchForRoute.id, (prev) => ({ ...prev, status: 'notFound', error: err, @@ -3178,7 +3176,7 @@ export class RouterCore< if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) { err.routeId = routeCursor.parentRoute.id - this._handleNotFound(matches, err, updateMatch) + this._handleNotFound(innerLoadContext, err) } } From 89d1ec8e19a41f671c5e0c2088e5f78e5563e219 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 20:32:35 +0200 Subject: [PATCH 4/7] 2nd attempt --- packages/router-core/src/router.ts | 793 ++++++++++++++++------------- 1 file changed, 437 insertions(+), 356 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 0e6057ba8e0..e27cfdceb51 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -772,6 +772,7 @@ type InnerLoadContext = { preload?: boolean onReady?: () => Promise sync?: boolean + matchPromises: Array> } export class RouterCore< @@ -2097,15 +2098,18 @@ export class RouterCore< private triggerOnReady = async (innerLoadContext: InnerLoadContext) => { if (!innerLoadContext.rendered) { innerLoadContext.rendered = true - if (innerLoadContext.onReady) await innerLoadContext.onReady() + await innerLoadContext.onReady?.() } } private resolvePreload = ( - allPreload: boolean | undefined, + innerLoadContext: InnerLoadContext, matchId: string, ) => { - return !!(allPreload && !this.state.matches.some((d) => d.id === matchId)) + return !!( + innerLoadContext.preload && + !this.state.matches.some((d) => d.id === matchId) + ) } private handleRedirectAndNotFound = ( @@ -2220,77 +2224,86 @@ export class RouterCore< }) } - private handleBeforeLoad = async ( + private isBeforeLoadSsr = async ( innerLoadContext: InnerLoadContext, + matchId: string, index: number, + route: AnyRoute, ) => { - const { id: matchId, routeId } = innerLoadContext.matches[index]! const existingMatch = this.getMatch(matchId)! const parentMatchId = innerLoadContext.matches[index - 1]?.id const parentMatch = parentMatchId ? this.getMatch(parentMatchId)! : undefined - const route = this.looseRoutesById[routeId]! - - const pendingMs = route.options.pendingMs ?? this.options.defaultPendingMs + // in SPA mode, only SSR the root route + if (this.isShell()) { + existingMatch.ssr = matchId === rootRouteId + return + } - // on the server, determine whether SSR the current match or not - if (this.isServer) { - let ssr: boolean | 'data-only' - // in SPA mode, only SSR the root route - if (this.isShell()) { - ssr = matchId === rootRouteId - } else { - const defaultSsr = this.options.defaultSsr ?? true - if (parentMatch?.ssr === false) { - ssr = false - } else { - let tempSsr: boolean | 'data-only' - if (route.options.ssr === undefined) { - tempSsr = defaultSsr - } else if (typeof route.options.ssr === 'function') { - 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, - })), - } - tempSsr = (await route.options.ssr(ssrFnContext)) ?? defaultSsr - } else { - tempSsr = route.options.ssr - } + if (parentMatch?.ssr === false) { + existingMatch.ssr = false + return + } - if (tempSsr === true && parentMatch?.ssr === 'data-only') { - ssr = 'data-only' - } else { - ssr = tempSsr - } - } + const parentOverride = (tempSsr: boolean | 'data-only') => { + if (tempSsr === true && parentMatch?.ssr === 'data-only') { + return 'data-only' } - existingMatch.ssr = ssr + return tempSsr } - if (this.shouldSkipLoader(matchId)) { + 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, + ) => { + const pendingMs = route.options.pendingMs ?? this.options.defaultPendingMs const shouldPending = !!( innerLoadContext.onReady && !this.isServer && - !this.resolvePreload(innerLoadContext.preload, matchId) && + !this.resolvePreload(innerLoadContext, matchId) && (route.options.loader || route.options.beforeLoad || routeNeedsPreload(route)) && @@ -2299,121 +2312,150 @@ export class RouterCore< (route.options.pendingComponent ?? (this.options as any)?.defaultPendingComponent) ) - - let executeBeforeLoad = true - const setupPendingTimeout = () => { - if (!shouldPending) return - const match = this.getMatch(matchId)! - if (match._nonReactive.pendingTimeout === undefined) { - const pendingTimeout = setTimeout(() => { - try { - // Update the match and prematurely resolve the loadMatches promise so that - // the pending component can start rendering - this.triggerOnReady(innerLoadContext) - } catch {} - }, pendingMs) - match._nonReactive.pendingTimeout = pendingTimeout - } + const match = this.getMatch(matchId)! + if (shouldPending && match._nonReactive.pendingTimeout === undefined) { + const pendingTimeout = setTimeout(() => { + try { + // Update the match and prematurely resolve the loadMatches promise so that + // the pending component can start rendering + this.triggerOnReady(innerLoadContext) + } catch {} + }, pendingMs) + match._nonReactive.pendingTimeout = pendingTimeout } + } + + private shouldExecuteBeforeLoad = ( + innerLoadContext: InnerLoadContext, + matchId: string, + route: AnyRoute, + ) => { + 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 ( - // 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) - existingMatch._nonReactive.beforeLoadPromise || - existingMatch._nonReactive.loaderPromise - ) { - setupPendingTimeout() + !existingMatch._nonReactive.beforeLoadPromise && + !existingMatch._nonReactive.loaderPromise + ) + return true + + this.setupPendingTimeout(innerLoadContext, matchId, route) - // Wait for the beforeLoad to resolve before we continue - await existingMatch._nonReactive.beforeLoadPromise + const then = () => { + let shouldExecuteBeforeLoad = true const match = this.getMatch(matchId)! if (match.status === 'error') { - executeBeforeLoad = true + shouldExecuteBeforeLoad = true } else if ( match.preload && (match.status === 'redirected' || match.status === 'notFound') ) { this.handleRedirectAndNotFound(innerLoadContext, match, match.error) } + return shouldExecuteBeforeLoad } - if (executeBeforeLoad) { - // If we are not in the middle of a load OR the previous load failed, start it - try { - 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 } = this.getMatch(matchId)! + // Wait for the beforeLoad to resolve before we continue + return existingMatch._nonReactive.beforeLoadPromise + ? existingMatch._nonReactive.beforeLoadPromise.then(then) + : then() + } - if (paramsError) { - this.handleSerialError( - innerLoadContext, - index, - paramsError, - 'PARSE_PARAMS', - ) - } + private executeBeforeLoad = ( + innerLoadContext: InnerLoadContext, + matchId: string, + index: number, + route: AnyRoute, + ) => { + const resolve = () => { + innerLoadContext.updateMatch(matchId, (prev) => { + prev._nonReactive.beforeLoadPromise?.resolve() + prev._nonReactive.beforeLoadPromise = undefined - if (searchError) { - this.handleSerialError( - innerLoadContext, - index, - searchError, - 'VALIDATE_SEARCH', - ) + return { + ...prev, + isFetching: false, } + }) + } - setupPendingTimeout() + try { + 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 abortController = new AbortController() + const { paramsError, searchError } = this.getMatch(matchId)! - const parentMatchContext = - parentMatch?.context ?? this.options.context ?? undefined + if (paramsError) { + this.handleSerialError( + innerLoadContext, + index, + paramsError, + 'PARSE_PARAMS', + ) + } - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'beforeLoad', - fetchCount: prev.fetchCount + 1, - abortController, - context: { - ...parentMatchContext, - ...prev.__routeContext, - }, - })) + if (searchError) { + this.handleSerialError( + innerLoadContext, + index, + searchError, + 'VALIDATE_SEARCH', + ) + } - const { search, params, context, cause } = this.getMatch(matchId)! + this.setupPendingTimeout(innerLoadContext, matchId, route) - const preload = this.resolvePreload(innerLoadContext.preload, matchId) + const abortController = new AbortController() - 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 parentMatchId = innerLoadContext.matches[index - 1]?.id + const parentMatch = parentMatchId + ? this.getMatch(parentMatchId)! + : undefined + const parentMatchContext = + parentMatch?.context ?? this.options.context ?? undefined + + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'beforeLoad', + fetchCount: prev.fetchCount + 1, + abortController, + context: { + ...parentMatchContext, + ...prev.__routeContext, + }, + })) + + const { search, params, context, cause } = this.getMatch(matchId)! - const beforeLoadContext = - await route.options.beforeLoad?.(beforeLoadFnContext) + 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 then = (beforeLoadContext: any) => { if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { this.handleSerialError( innerLoadContext, @@ -2423,38 +2465,85 @@ export class RouterCore< ) } - innerLoadContext.updateMatch(matchId, (prev) => { - return { - ...prev, - __beforeLoadContext: beforeLoadContext, - context: { - ...parentMatchContext, - ...prev.__routeContext, - ...beforeLoadContext, - }, - abortController, - } - }) - } catch (err) { - this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') + innerLoadContext.updateMatch(matchId, (prev) => ({ + ...prev, + __beforeLoadContext: beforeLoadContext, + context: { + ...parentMatchContext, + ...prev.__routeContext, + ...beforeLoadContext, + }, + abortController, + })) } - innerLoadContext.updateMatch(matchId, (prev) => { - prev._nonReactive.beforeLoadPromise?.resolve() - prev._nonReactive.beforeLoadPromise = undefined + const beforeLoadContext = route.options.beforeLoad?.(beforeLoadFnContext) + if (isPromise(beforeLoadContext)) { + return beforeLoadContext + .then(then) + .catch((err) => { + this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') + }) + .then(resolve) + } else { + then(beforeLoadContext) + } + } catch (err) { + this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') + } - return { - ...prev, - isFetching: false, - } - }) + resolve() + return + } + + private handleBeforeLoad = ( + innerLoadContext: InnerLoadContext, + index: number, + ) => { + 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 = ( - route: AnyRoute, + innerLoadContext: InnerLoadContext, matchId: string, - matches: Array, + route: AnyRoute, ) => { const match = this.getMatch(matchId) // in case of a redirecting match during preload, the match does not exist @@ -2469,7 +2558,7 @@ export class RouterCore< return } const assetContext = { - matches, + matches: innerLoadContext.matches, match, params: match.params, loaderData: match.loaderData, @@ -2496,29 +2585,179 @@ export class RouterCore< }) } + private potentialPendingMinPromise = (matchId: string) => { + 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, + ) => { + 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, + ) + 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 + + await this.potentialPendingMinPromise(matchId) + + 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, - matchPromises: Array>, - ): Promise => { + ) => { const { id: matchId, routeId } = innerLoadContext.matches[index]! let loaderShouldRunAsync = false let loaderIsRunningAsync = false const route = this.looseRoutesById[routeId]! - const potentialPendingMinPromise = () => { - const latestMatch = this.getMatch(matchId)! - return latestMatch._nonReactive.minPendingPromise - } - const prevMatch = this.getMatch(matchId)! if (this.shouldSkipLoader(matchId)) { if (this.isServer) { - const headResult = this.executeHead( - route, - matchId, - innerLoadContext.matches, - ) + const headResult = this.executeHead(innerLoadContext, matchId, route) if (headResult) { const head = await headResult innerLoadContext.updateMatch(matchId, (prev) => ({ @@ -2547,35 +2786,10 @@ export class RouterCore< this.handleRedirectAndNotFound(innerLoadContext, match, match.error) } } else { - const parentMatchPromise = matchPromises[index - 1] as any - - const getLoaderContext = (): LoaderFnContext => { - const match = this.getMatch(matchId)! - - const preload = this.resolvePreload(innerLoadContext.preload, matchId) - - return { - params: match.params, - deps: match.loaderDeps, - preload: !!preload, - parentMatchPromise, - abortController: match.abortController, - context: match.context, - location: innerLoadContext.location, - navigate: (opts) => - this.navigate({ - ...opts, - _fromLocation: innerLoadContext.location, - }), - cause: preload ? 'preload' : match.cause, - route, - } - } - // This is where all of the stale-while-revalidate magic happens const age = Date.now() - this.getMatch(matchId)!.updatedAt - const preload = this.resolvePreload(innerLoadContext.preload, matchId) + const preload = this.resolvePreload(innerLoadContext, matchId) const staleAge = preload ? (route.options.preloadStaleTime ?? @@ -2590,7 +2804,9 @@ export class RouterCore< // if provided. const shouldReload = typeof shouldReloadOption === 'function' - ? shouldReloadOption(getLoaderContext()) + ? shouldReloadOption( + this.getLoaderContext(innerLoadContext, matchId, index, route), + ) : shouldReloadOption innerLoadContext.updateMatch(matchId, (prev) => { @@ -2602,139 +2818,6 @@ export class RouterCore< } }) - const runLoader = async () => { - 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?.(getLoaderContext()) - 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, - ) - 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( - route, - matchId, - innerLoadContext.matches, - ) - const head = headResult ? await headResult : undefined - const pendingPromise = potentialPendingMinPromise() - 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 - - await potentialPendingMinPromise() - - 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( - route, - matchId, - innerLoadContext.matches, - ) - 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( - route, - matchId, - innerLoadContext.matches, - ) - if (headResult) { - const head = await headResult - innerLoadContext.updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) - } - match._nonReactive.loaderPromise = undefined - } - this.handleRedirectAndNotFound(innerLoadContext, match, err) - } - } - // If the route is successful and still fresh, just resolve const { status, invalid } = this.getMatch(matchId)! loaderShouldRunAsync = @@ -2745,7 +2828,7 @@ export class RouterCore< loaderIsRunningAsync = true ;(async () => { try { - await runLoader() + await this.runLoader(innerLoadContext, matchId, index, route) const match = this.getMatch(matchId)! match._nonReactive.loaderPromise?.resolve() match._nonReactive.loadPromise?.resolve() @@ -2760,16 +2843,12 @@ export class RouterCore< status !== 'success' || (loaderShouldRunAsync && innerLoadContext.sync) ) { - await runLoader() + 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( - route, - matchId, - innerLoadContext.matches, - ) + const headResult = this.executeHead(innerLoadContext, matchId, route) if (headResult) { const head = await headResult innerLoadContext.updateMatch(matchId, (prev) => ({ @@ -2809,6 +2888,7 @@ export class RouterCore< }): 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 @@ -2820,22 +2900,23 @@ export class RouterCore< await new Promise((resolveAll, rejectAll) => { ;(async () => { try { - // Execute all `beforeLoad` one by one + // Execute all beforeLoads one by one for (let i = 0; i < innerLoadContext.matches.length; i++) { - await this.handleBeforeLoad(innerLoadContext, i) + const beforeLoad = this.handleBeforeLoad(innerLoadContext, i) + if (isPromise(beforeLoad)) await beforeLoad } // Execute all loaders in parallel - const matchPromises: Array> = [] - const skip = + const max = innerLoadContext.firstBadMatchIndex ?? innerLoadContext.matches.length - for (let i = 0; i < skip; i++) { - matchPromises.push( - this.loadRouteMatch(innerLoadContext, i, matchPromises), + for (let i = 0; i < max; i++) { + innerLoadContext.matchPromises.push( + this.loadRouteMatch(innerLoadContext, i), ) } - await Promise.all(matchPromises) + + await Promise.all(innerLoadContext.matchPromises) resolveAll() } catch (err) { From d984882b3511b876f87eb3f82e6d774245b38e1d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 20:33:12 +0200 Subject: [PATCH 5/7] typo --- packages/router-core/src/router.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e27cfdceb51..5c2785799fe 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2283,6 +2283,7 @@ export class RouterCore< ssr: match.ssr, })), } + const tempSsr = route.options.ssr(ssrFnContext) if (isPromise(tempSsr)) { return tempSsr.then((ssr) => { @@ -2455,7 +2456,7 @@ export class RouterCore< matches: innerLoadContext.matches, } - const then = (beforeLoadContext: any) => { + const updateContext = (beforeLoadContext: any) => { if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) { this.handleSerialError( innerLoadContext, @@ -2480,13 +2481,13 @@ export class RouterCore< const beforeLoadContext = route.options.beforeLoad?.(beforeLoadFnContext) if (isPromise(beforeLoadContext)) { return beforeLoadContext - .then(then) + .then(updateContext) .catch((err) => { this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') }) .then(resolve) } else { - then(beforeLoadContext) + updateContext(beforeLoadContext) } } catch (err) { this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD') From 205a663fa276e74ce1fbc8c2854855f4c8d86731 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 20:40:53 +0200 Subject: [PATCH 6/7] fix faulty solid-router Transitioner test --- packages/solid-router/tests/Transitioner.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/solid-router/tests/Transitioner.test.tsx b/packages/solid-router/tests/Transitioner.test.tsx index ffd3ffb1005..dffe2bd4fd5 100644 --- a/packages/solid-router/tests/Transitioner.test.tsx +++ b/packages/solid-router/tests/Transitioner.test.tsx @@ -30,13 +30,12 @@ describe('Transitioner', () => { // Mock router.load() to verify it gets called const loadSpy = vi.spyOn(router, 'load') - await router.load() - render(() => ) + await router.latestLoadPromise // Wait for the createRenderEffect to run and call router.load() await waitFor(() => { - expect(loadSpy).toHaveBeenCalledTimes(2) + expect(loadSpy).toHaveBeenCalledTimes(1) expect(loader).toHaveBeenCalledTimes(1) }) From 4e8d69e235bfc2e09ed6ebd6f1900a305e2e3997 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 15 Aug 2025 22:47:27 +0200 Subject: [PATCH 7/7] return types --- packages/router-core/src/router.ts | 52 +++++++++++++++++++----------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 5c2785799fe..1200bcc8924 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2095,17 +2095,19 @@ export class RouterCore< ) } - private triggerOnReady = async (innerLoadContext: InnerLoadContext) => { + private triggerOnReady = ( + innerLoadContext: InnerLoadContext, + ): void | Promise => { if (!innerLoadContext.rendered) { innerLoadContext.rendered = true - await innerLoadContext.onReady?.() + return innerLoadContext.onReady?.() } } private resolvePreload = ( innerLoadContext: InnerLoadContext, matchId: string, - ) => { + ): boolean => { return !!( innerLoadContext.preload && !this.state.matches.some((d) => d.id === matchId) @@ -2116,7 +2118,7 @@ export class RouterCore< innerLoadContext: InnerLoadContext, match: AnyRouteMatch | undefined, err: unknown, - ) => { + ): void => { if (!isRedirect(err) && !isNotFound(err)) return if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) { @@ -2158,7 +2160,7 @@ export class RouterCore< } } - private shouldSkipLoader = (matchId: string) => { + 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) { @@ -2178,7 +2180,7 @@ export class RouterCore< index: number, err: any, routerCode: string, - ) => { + ): void => { const { id: matchId, routeId } = innerLoadContext.matches[index]! const route = this.looseRoutesById[routeId]! @@ -2224,12 +2226,12 @@ export class RouterCore< }) } - private isBeforeLoadSsr = async ( + 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 @@ -2299,7 +2301,7 @@ export class RouterCore< innerLoadContext: InnerLoadContext, matchId: string, route: AnyRoute, - ) => { + ): void => { const pendingMs = route.options.pendingMs ?? this.options.defaultPendingMs const shouldPending = !!( innerLoadContext.onReady && @@ -2330,7 +2332,7 @@ export class RouterCore< 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 @@ -2368,7 +2370,7 @@ export class RouterCore< matchId: string, index: number, route: AnyRoute, - ) => { + ): void | Promise => { const resolve = () => { innerLoadContext.updateMatch(matchId, (prev) => { prev._nonReactive.beforeLoadPromise?.resolve() @@ -2500,7 +2502,7 @@ export class RouterCore< private handleBeforeLoad = ( innerLoadContext: InnerLoadContext, index: number, - ) => { + ): void | Promise => { const { id: matchId, routeId } = innerLoadContext.matches[index]! const route = this.looseRoutesById[routeId]! @@ -2545,7 +2547,12 @@ export class RouterCore< 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) { @@ -2586,7 +2593,9 @@ export class RouterCore< }) } - private potentialPendingMinPromise = (matchId: string) => { + private potentialPendingMinPromise = ( + matchId: string, + ): void | ControlledPromise => { const latestMatch = this.getMatch(matchId)! return latestMatch._nonReactive.minPendingPromise } @@ -2623,7 +2632,7 @@ export class RouterCore< matchId: string, index: number, route: AnyRoute, - ) => { + ): Promise => { try { // If the Matches component rendered // the pending component and needs to show it for @@ -2749,7 +2758,7 @@ export class RouterCore< private loadRouteMatch = async ( innerLoadContext: InnerLoadContext, index: number, - ) => { + ): Promise => { const { id: matchId, routeId } = innerLoadContext.matches[index]! let loaderShouldRunAsync = false let loaderIsRunningAsync = false @@ -2925,10 +2934,12 @@ export class RouterCore< } })() }) - await this.triggerOnReady(innerLoadContext) + const readyPromise = this.triggerOnReady(innerLoadContext) + if (isPromise(readyPromise)) await readyPromise } catch (err) { if (isNotFound(err) && !innerLoadContext.preload) { - await this.triggerOnReady(innerLoadContext) + const readyPromise = this.triggerOnReady(innerLoadContext) + if (isPromise(readyPromise)) await readyPromise throw err } if (isRedirect(err)) { @@ -3273,7 +3284,10 @@ export class SearchParamError extends Error {} export class PathParamError extends Error {} -function makeMaybe(value: any, error: any) { +function makeMaybe( + value: TValue, + error: TError, +): { status: 'success'; value: TValue } | { status: 'error'; error: TError } { if (error) { return { status: 'error' as const, error } }