diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 315b3e528e..e5313880b0 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, test } from 'vitest' -import { act, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, test } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' import ReactDOMServer from 'react-dom/server' import { @@ -13,6 +13,10 @@ import { } from '../src' import { Scripts } from '../src/Scripts' +afterEach(() => { + cleanup() +}) + describe('ssr scripts', () => { test('it works', async () => { const rootRoute = createRootRoute({ @@ -61,7 +65,8 @@ describe('ssr scripts', () => { isServer: true, }) - await router.load() + render() + await act(() => router.load()) expect(router.state.matches.map((d) => d.headScripts).flat(1)).toEqual([ { src: 'script.js' }, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e602bfc20e..ad578d81cc 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2179,588 +2179,570 @@ export class RouterCore< } 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 - } - - err.routerCode = routerCode - firstBadMatchIndex = firstBadMatchIndex ?? index - handleRedirectAndNotFound(this.getMatch(matchId)!, err) - - try { - route.options.onError?.(err) - } catch (errorHandlerErr) { - err = errorHandlerErr - handleRedirectAndNotFound(this.getMatch(matchId)!, err) - } + 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 + } - updateMatch(matchId, (prev) => { - prev.beforeLoadPromise?.resolve() - prev.loadPromise?.resolve() + err.routerCode = routerCode + firstBadMatchIndex = firstBadMatchIndex ?? index + handleRedirectAndNotFound(this.getMatch(matchId)!, err) - return { - ...prev, - error: err, - status: 'error', - isFetching: false, - updatedAt: Date.now(), - abortController: new AbortController(), - beforeLoadPromise: undefined, - } - }) - } + try { + route.options.onError?.(err) + } catch (errorHandlerErr) { + err = errorHandlerErr + handleRedirectAndNotFound(this.getMatch(matchId)!, err) + } - 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 + updateMatch(matchId, (prev) => { + prev.beforeLoadPromise?.resolve() + prev.loadPromise?.resolve() + + return { + ...prev, + error: err, + status: 'error', + isFetching: false, + updatedAt: Date.now(), + abortController: new AbortController(), + beforeLoadPromise: undefined, + } + }) + } - const route = this.looseRoutesById[routeId]! + 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 pendingMs = - route.options.pendingMs ?? this.options.defaultPendingMs + const route = this.looseRoutesById[routeId]! - // 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 - } + const pendingMs = + route.options.pendingMs ?? this.options.defaultPendingMs - if (tempSsr === true && parentMatch?.ssr === 'data-only') { - ssr = 'data-only' - } else { - ssr = tempSsr - } + // 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 } } - updateMatch(matchId, (prev) => ({ - ...prev, - ssr, - })) + + 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 } - if (shouldSkipLoader(matchId)) { - continue + if (tempSsr === true && parentMatch?.ssr === 'data-only') { + ssr = 'data-only' + } else { + ssr = tempSsr } + } + } + updateMatch(matchId, (prev) => ({ + ...prev, + ssr, + })) + } - 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) - ) + if (shouldSkipLoader(matchId)) { + continue + } - let executeBeforeLoad = true - const setupPendingTimeout = () => { - if ( - shouldPending && - this.getMatch(matchId)!.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) - updateMatch(matchId, (prev) => ({ - ...prev, - 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.beforeLoadPromise || - existingMatch.loaderPromise - ) { - setupPendingTimeout() - - // Wait for the beforeLoad to resolve before we continue - await existingMatch.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) - } + 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) + ) + + let executeBeforeLoad = true + const setupPendingTimeout = () => { + if ( + shouldPending && + this.getMatch(matchId)!.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) + updateMatch(matchId, (prev) => ({ + ...prev, + 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.beforeLoadPromise || + existingMatch.loaderPromise + ) { + setupPendingTimeout() + + // Wait for the beforeLoad to resolve before we continue + await existingMatch.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 { + updateMatch(matchId, (prev) => { + // explicitly capture the previous loadPromise + const prevLoadPromise = prev.loadPromise + return { + ...prev, + loadPromise: createControlledPromise(() => { + prevLoadPromise?.resolve() + }), + beforeLoadPromise: createControlledPromise(), } - if (executeBeforeLoad) { - // If we are not in the middle of a load OR the previous load failed, start it - try { - updateMatch(matchId, (prev) => { - // explicitly capture the previous loadPromise - const prevLoadPromise = prev.loadPromise - return { - ...prev, - loadPromise: createControlledPromise(() => { - prevLoadPromise?.resolve() - }), - beforeLoadPromise: createControlledPromise(), - } - }) + }) - const { paramsError, searchError } = this.getMatch(matchId)! + const { paramsError, searchError } = this.getMatch(matchId)! - if (paramsError) { - handleSerialError(index, paramsError, 'PARSE_PARAMS') - } + if (paramsError) { + handleSerialError(index, paramsError, 'PARSE_PARAMS') + } - if (searchError) { - handleSerialError(index, searchError, 'VALIDATE_SEARCH') - } + if (searchError) { + handleSerialError(index, searchError, 'VALIDATE_SEARCH') + } - setupPendingTimeout() + setupPendingTimeout() - const abortController = new AbortController() + const abortController = new AbortController() - const parentMatchContext = - parentMatch?.context ?? this.options.context ?? {} + const parentMatchContext = + parentMatch?.context ?? this.options.context ?? {} - updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'beforeLoad', - fetchCount: prev.fetchCount + 1, - abortController, - context: { - ...parentMatchContext, - ...prev.__routeContext, - }, - })) + 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 { 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) - const beforeLoadContext = - await route.options.beforeLoad?.(beforeLoadFnContext) + if ( + isRedirect(beforeLoadContext) || + isNotFound(beforeLoadContext) + ) { + handleSerialError(index, beforeLoadContext, 'BEFORE_LOAD') + } - 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) => { - return { - ...prev, - __beforeLoadContext: beforeLoadContext, - context: { - ...parentMatchContext, - ...prev.__routeContext, - ...beforeLoadContext, - }, - abortController, - } - }) - } catch (err) { - handleSerialError(index, err, 'BEFORE_LOAD') - } + updateMatch(matchId, (prev) => { + prev.beforeLoadPromise?.resolve() - updateMatch(matchId, (prev) => { - prev.beforeLoadPromise?.resolve() + return { + ...prev, + beforeLoadPromise: undefined, + isFetching: false, + } + }) + } + } - return { - ...prev, - beforeLoadPromise: undefined, - isFetching: false, - } - }) + const validResolvedMatches = matches.slice(0, firstBadMatchIndex) + const matchPromises: Array> = [] + + validResolvedMatches.forEach(({ id: matchId, routeId }, index) => { + matchPromises.push( + (async () => { + let loaderShouldRunAsync = false + let loaderIsRunningAsync = false + const route = this.looseRoutesById[routeId]! + + const executeHead = async () => { + const match = this.getMatch(matchId) + // in case of a redirecting match during preload, the match does not exist + if (!match) { + return + } + const assetContext = { + matches, + match, + params: match.params, + loaderData: match.loaderData, + } + const headFnContent = await route.options.head?.(assetContext) + const meta = headFnContent?.meta + const links = headFnContent?.links + const headScripts = headFnContent?.scripts + const styles = headFnContent?.styles + + const scripts = await route.options.scripts?.(assetContext) + const headers = await route.options.headers?.(assetContext) + return { + meta, + links, + headScripts, + headers, + scripts, + styles, } } - const validResolvedMatches = matches.slice(0, firstBadMatchIndex) - const matchPromises: Array> = [] - - validResolvedMatches.forEach(({ id: matchId, routeId }, index) => { - matchPromises.push( - (async () => { - let loaderShouldRunAsync = false - let loaderIsRunningAsync = false - const route = this.looseRoutesById[routeId]! - - const executeHead = async () => { - const match = this.getMatch(matchId) - // in case of a redirecting match during preload, the match does not exist - if (!match) { - return - } - const assetContext = { - matches, - match, - params: match.params, - loaderData: match.loaderData, - } - const headFnContent = - await route.options.head?.(assetContext) - const meta = headFnContent?.meta - const links = headFnContent?.links - const headScripts = headFnContent?.scripts - const styles = headFnContent?.styles - - const scripts = await route.options.scripts?.(assetContext) - const headers = await route.options.headers?.(assetContext) - return { - meta, - links, - headScripts, - headers, - scripts, - styles, - } - } + const potentialPendingMinPromise = async () => { + const latestMatch = this.getMatch(matchId)! + if (latestMatch.minPendingPromise) { + await latestMatch.minPendingPromise + } + } - const potentialPendingMinPromise = async () => { - const latestMatch = this.getMatch(matchId)! - if (latestMatch.minPendingPromise) { - await latestMatch.minPendingPromise - } - } + const prevMatch = this.getMatch(matchId)! + if (shouldSkipLoader(matchId)) { + if (this.isServer) { + const head = await executeHead() + 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.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.loaderPromise + const match = this.getMatch(matchId)! + if (match.error) { + handleRedirectAndNotFound(match, match.error) + } + } else { + const parentMatchPromise = matchPromises[index - 1] as any - const prevMatch = this.getMatch(matchId)! - if (shouldSkipLoader(matchId)) { - if (this.isServer) { - const head = await executeHead() - 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.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 + 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, + } + } + + // 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, + loaderPromise: createControlledPromise(), + 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 ( - prevMatch.status === 'success' && - !sync && - !prevMatch.preload + !this.isServer || + (this.isServer && this.getMatch(matchId)!.ssr === true) ) { - return this.getMatch(matchId)! + this.loadRouteChunk(route) } - await prevMatch.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, - } - } - - // This is where all of the stale-while-revalidate magic happens - const age = Date.now() - this.getMatch(matchId)!.updatedAt - const preload = resolvePreload(matchId) + updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'loader', + })) - const staleAge = preload - ? (route.options.preloadStaleTime ?? - this.options.defaultPreloadStaleTime ?? - 30_000) // 30 seconds for preloads by default - : (route.options.staleTime ?? - this.options.defaultStaleTime ?? - 0) + // Kick off the loader! + const loaderData = + await route.options.loader?.(getLoaderContext()) - const shouldReloadOption = route.options.shouldReload + handleRedirectAndNotFound( + this.getMatch(matchId)!, + loaderData, + ) + updateMatch(matchId, (prev) => ({ + ...prev, + loaderData, + })) - // 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 + // Lazy option can modify the route options, + // so we need to wait for it to resolve before + // we can use the options + await route._lazyPromise + const head = await executeHead() + await potentialPendingMinPromise() + // Last but not least, wait for the the components + // to be preloaded before we resolve the match + await route._componentsPromise updateMatch(matchId, (prev) => ({ ...prev, - loaderPromise: createControlledPromise(), - preload: - !!preload && - !this.state.matches.some((d) => d.id === matchId), + error: undefined, + status: 'success', + isFetching: false, + updatedAt: Date.now(), + ...head, })) + } catch (e) { + let error = e - 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.isServer && - this.getMatch(matchId)!.ssr === true) - ) { - this.loadRouteChunk(route) - } - - updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'loader', - })) - - // Kick off the loader! - const loaderData = - await route.options.loader?.(getLoaderContext()) - - 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 - await route._lazyPromise - const head = await executeHead() - await potentialPendingMinPromise() - - // Last but not least, wait for the the components - // to be preloaded before we resolve the match - 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 head = await executeHead() - updateMatch(matchId, (prev) => ({ - ...prev, - error, - status: 'error', - isFetching: false, - ...head, - })) - } - } catch (err) { - const head = await executeHead() - - updateMatch(matchId, (prev) => ({ - ...prev, - loaderPromise: undefined, - ...head, - })) - handleRedirectAndNotFound(this.getMatch(matchId)!, err) - } - } + await potentialPendingMinPromise() - // 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 { loaderPromise, loadPromise } = - this.getMatch(matchId)! - loaderPromise?.resolve() - loadPromise?.resolve() - updateMatch(matchId, (prev) => ({ - ...prev, - 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 head = await executeHead() - updateMatch(matchId, (prev) => ({ - ...prev, - ...head, - })) + handleRedirectAndNotFound(this.getMatch(matchId)!, e) + + try { + route.options.onError?.(e) + } catch (onErrorError) { + error = onErrorError + handleRedirectAndNotFound( + this.getMatch(matchId)!, + onErrorError, + ) } + const head = await executeHead() + updateMatch(matchId, (prev) => ({ + ...prev, + error, + status: 'error', + isFetching: false, + ...head, + })) } - if (!loaderIsRunningAsync) { + } catch (err) { + const head = await executeHead() + + updateMatch(matchId, (prev) => ({ + ...prev, + loaderPromise: undefined, + ...head, + })) + handleRedirectAndNotFound(this.getMatch(matchId)!, err) + } + } + + // 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 + runLoader() + .then(() => { const { loaderPromise, loadPromise } = this.getMatch(matchId)! loaderPromise?.resolve() loadPromise?.resolve() - } - - updateMatch(matchId, (prev) => { - clearTimeout(prev.pendingTimeout) - return { + updateMatch(matchId, (prev) => ({ ...prev, - isFetching: loaderIsRunningAsync - ? prev.isFetching - : false, - loaderPromise: loaderIsRunningAsync - ? prev.loaderPromise - : undefined, - invalid: false, - pendingTimeout: undefined, - _dehydrated: undefined, + loaderPromise: undefined, + })) + }) + .catch((err) => { + if (isRedirect(err)) { + return this.navigate(err.options) } + return }) - return this.getMatch(matchId)! - })(), - ) + } 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 head = await executeHead() + updateMatch(matchId, (prev) => ({ + ...prev, + ...head, + })) + } + } + if (!loaderIsRunningAsync) { + const { loaderPromise, loadPromise } = this.getMatch(matchId)! + loaderPromise?.resolve() + loadPromise?.resolve() + } + + updateMatch(matchId, (prev) => { + clearTimeout(prev.pendingTimeout) + return { + ...prev, + isFetching: loaderIsRunningAsync ? prev.isFetching : false, + loaderPromise: loaderIsRunningAsync + ? prev.loaderPromise + : undefined, + invalid: false, + pendingTimeout: undefined, + _dehydrated: undefined, + } }) + return this.getMatch(matchId)! + })(), + ) + }) - await Promise.all(matchPromises) + await Promise.all(matchPromises) + + // TODO: this should absolutely be removed, but right now removing it can cause `head` to not be entirely up to date when `load()` resolves + await Promise.resolve() - resolveAll() - } catch (err) { - rejectAll(err) - } - })() - }) await triggerOnReady() } catch (err) { if (isRedirect(err) || isNotFound(err)) {