From ce450adb9a402d0a53f4d328f58c77c07d61e72d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 21 Mar 2026 11:55:23 +0100 Subject: [PATCH 1/4] fix(router-core): avoid preload cleanup crashes after invalidation --- packages/router-core/src/load-matches.ts | 38 +++++-- packages/router-core/tests/load.test.ts | 120 +++++++++++++++++++++++ 2 files changed, 148 insertions(+), 10 deletions(-) diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 84071950781..a27f1861844 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -834,18 +834,19 @@ const loadRouteMatch = async ( shouldReloadInBackground ) { loaderIsRunningAsync = true + const matchForCleanup = prevMatch ;(async () => { try { await runLoader(inner, matchPromises, matchId, index, route) - const match = inner.router.getMatch(matchId)! - match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - match._nonReactive.loaderPromise = undefined - match._nonReactive.loadPromise = undefined } catch (err) { if (isRedirect(err)) { await inner.router.navigate(err.options) } + } finally { + matchForCleanup._nonReactive.loaderPromise?.resolve() + matchForCleanup._nonReactive.loadPromise?.resolve() + matchForCleanup._nonReactive.loaderPromise = undefined + matchForCleanup._nonReactive.loadPromise = undefined } })() } else if (status !== 'success' || loaderShouldRunAsync) { @@ -878,7 +879,12 @@ const loadRouteMatch = async ( return inner.router.getMatch(matchId)! } } else { - const prevMatch = inner.router.getMatch(matchId)! // This is where all of the stale-while-revalidate magic happens + const prevMatch = inner.router.getMatch(matchId) + if (!prevMatch) { + return inner.matches[index]! + } + + // This is where all of the stale-while-revalidate magic happens const activeIdAtIndex = inner.router.stores.matchesId.state[index] const activeAtIndex = (activeIdAtIndex && @@ -906,7 +912,11 @@ const loadRouteMatch = async ( return prevMatch } await prevMatch._nonReactive.loaderPromise - const match = inner.router.getMatch(matchId)! + const match = inner.router.getMatch(matchId) + if (!match) { + return inner.matches[index]! + } + const error = match._nonReactive.error || match.error if (error) { handleRedirectAndNotFound(inner, match, error) @@ -924,7 +934,11 @@ const loadRouteMatch = async ( } else { const nextPreload = preload && !inner.router.stores.activeMatchStoresById.has(matchId) - const match = inner.router.getMatch(matchId)! + const match = inner.router.getMatch(matchId) + if (!match) { + return inner.matches[index]! + } + match._nonReactive.loaderPromise = createControlledPromise() if (nextPreload !== match.preload) { inner.updateMatch(matchId, (prev) => ({ @@ -936,7 +950,11 @@ const loadRouteMatch = async ( await handleLoader(preload, prevMatch, previousRouteMatchId, match, route) } } - const match = inner.router.getMatch(matchId)! + const match = inner.router.getMatch(matchId) + if (!match) { + return inner.matches[index]! + } + if (!loaderIsRunningAsync) { match._nonReactive.loaderPromise?.resolve() match._nonReactive.loadPromise?.resolve() @@ -955,7 +973,7 @@ const loadRouteMatch = async ( isFetching: nextIsFetching, invalid: false, })) - return inner.router.getMatch(matchId)! + return inner.router.getMatch(matchId) ?? match } else { return match } diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index 142fea1fe1b..e6eacd88578 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -381,6 +381,126 @@ describe('loader skip or exec', () => { expect(loader).toHaveBeenCalledTimes(1) }) + test('does not error if cache gc clears an in-flight preload', async () => { + let resolveLoader: ((value: { ok: true }) => void) | undefined + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const loader: Loader = vi.fn( + () => + new Promise<{ ok: true }>((resolve) => { + resolveLoader = resolve + }), + ) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + preloadGcTime: 0, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute]), + history: createMemoryHistory(), + defaultPreloadGcTime: 0, + }) + + const preloadPromise = router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + + expect( + router.stores.cachedMatchesSnapshot.state.some( + (match) => match.routeId === fooRoute.id, + ), + ).toBe(true) + + router.clearExpiredCache() + + expect( + router.stores.cachedMatchesSnapshot.state.some( + (match) => match.routeId === fooRoute.id, + ), + ).toBe(false) + + resolveLoader?.({ ok: true }) + + await preloadPromise + // the route load won't throw, but it will log errors to the console if any + expect(consoleErrorSpy).not.toHaveBeenCalled() + + expect( + router.stores.cachedMatchesSnapshot.state.some( + (match) => match.routeId === fooRoute.id, + ), + ).toBe(false) + consoleErrorSpy.mockRestore() + }) + + test('does not error when invalidate clears an in-flight preload', async () => { + let resolveFooLoader: ((value: { ok: true }) => void) | undefined + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const fooLoader: Loader = vi.fn( + () => + new Promise<{ ok: true }>((resolve) => { + resolveFooLoader = resolve + }), + ) + const barLoader: Loader = vi.fn(() => ({ ok: true })) + + const rootRoute = new BaseRootRoute({}) + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader: fooLoader, + preloadGcTime: 0, + }) + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + loader: barLoader, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([fooRoute, barRoute]), + history: createMemoryHistory(), + defaultPreloadGcTime: 0, + }) + + await router.navigate({ to: '/bar' }) + + const preloadPromise = router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + + expect( + router.stores.cachedMatchesSnapshot.state.some( + (match) => match.routeId === fooRoute.id, + ), + ).toBe(true) + + const invalidatePromise = router.invalidate() + await Promise.resolve() + + resolveFooLoader?.({ ok: true }) + + await Promise.all([preloadPromise, invalidatePromise]) + + expect(barLoader).toHaveBeenCalledTimes(2) + // the route load won't throw, but it will log errors to the console if any + expect(consoleErrorSpy).not.toHaveBeenCalled() + expect( + router.stores.cachedMatchesSnapshot.state.some( + (match) => match.routeId === fooRoute.id, + ), + ).toBe(false) + consoleErrorSpy.mockRestore() + }) + test('exec if rejected preload (notFound)', async () => { const loader: Loader = vi.fn(async ({ preload }) => { if (preload) throw notFound() From 911a46e1d83e6d942357516a64748ab5804c3902 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 21 Mar 2026 12:02:46 +0100 Subject: [PATCH 2/4] changeset --- .changeset/moody-kings-travel.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/moody-kings-travel.md diff --git a/.changeset/moody-kings-travel.md b/.changeset/moody-kings-travel.md new file mode 100644 index 00000000000..fd4cc1e35ef --- /dev/null +++ b/.changeset/moody-kings-travel.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +evicting cache during preload does not error, return early From d86b13dc52ad789085817370ff83edc563bdd05f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 21 Mar 2026 17:05:19 +0100 Subject: [PATCH 3/4] error based solution --- packages/router-core/src/load-matches.ts | 79 +++++++++++++++++------- packages/router-core/src/router.ts | 10 ++- packages/router-core/tests/load.test.ts | 8 ++- 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index a27f1861844..ba22b44ddc6 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -37,6 +37,40 @@ type InnerLoadContext = { sync?: boolean } +const reason = 'Match removed before complete load' +class MatchLoadCancelledError extends Error { + constructor() { + super(reason) + this.name = 'MatchCancel' + } +} + +export const isMatchLoadCancelledError = ( + err: unknown, +): err is MatchLoadCancelledError => err instanceof MatchLoadCancelledError + +const getMatchOrThrowCancelled = ( + inner: InnerLoadContext, + matchId: string, + cleanupMatch?: AnyRouteMatch, +): AnyRouteMatch => { + const match = inner.router.getMatch(matchId) + if (match) return match as AnyRouteMatch + if (cleanupMatch) { + const s = cleanupMatch._nonReactive + s.beforeLoadPromise?.resolve() + s.loaderPromise?.resolve() + s.loadPromise?.resolve() + cleanupMatch.abortController.abort(reason) + clearTimeout(s.pendingTimeout) + s.beforeLoadPromise = undefined + s.loaderPromise = undefined + s.loadPromise = undefined + s.pendingTimeout = undefined + } + throw new MatchLoadCancelledError() +} + const triggerOnReady = (inner: InnerLoadContext): void | Promise => { if (!inner.rendered) { inner.rendered = true @@ -788,6 +822,8 @@ const loadRouteMatch = async ( matchPromises: Array>, index: number, ): Promise => { + let cleanupMatch: AnyRouteMatch | undefined + async function handleLoader( preload: boolean, prevMatch: AnyRouteMatch, @@ -868,10 +904,9 @@ const loadRouteMatch = async ( inner.router.options.defaultStaleReloadMode) !== 'blocking' if (shouldSkipLoader(inner, matchId)) { - const match = inner.router.getMatch(matchId) - if (!match) { - return inner.matches[index]! - } + const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) + + cleanupMatch = match syncMatchContext(inner, matchId, index) @@ -879,10 +914,7 @@ const loadRouteMatch = async ( return inner.router.getMatch(matchId)! } } else { - const prevMatch = inner.router.getMatch(matchId) - if (!prevMatch) { - return inner.matches[index]! - } + const prevMatch = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) // This is where all of the stale-while-revalidate magic happens const activeIdAtIndex = inner.router.stores.matchesId.state[index] @@ -912,10 +944,8 @@ const loadRouteMatch = async ( return prevMatch } await prevMatch._nonReactive.loaderPromise - const match = inner.router.getMatch(matchId) - if (!match) { - return inner.matches[index]! - } + const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) + cleanupMatch = match const error = match._nonReactive.error || match.error if (error) { @@ -934,10 +964,8 @@ const loadRouteMatch = async ( } else { const nextPreload = preload && !inner.router.stores.activeMatchStoresById.has(matchId) - const match = inner.router.getMatch(matchId) - if (!match) { - return inner.matches[index]! - } + const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) + cleanupMatch = match match._nonReactive.loaderPromise = createControlledPromise() if (nextPreload !== match.preload) { @@ -950,11 +978,7 @@ const loadRouteMatch = async ( await handleLoader(preload, prevMatch, previousRouteMatchId, match, route) } } - const match = inner.router.getMatch(matchId) - if (!match) { - return inner.matches[index]! - } - + const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) if (!loaderIsRunningAsync) { match._nonReactive.loaderPromise?.resolve() match._nonReactive.loadPromise?.resolve() @@ -973,10 +997,8 @@ const loadRouteMatch = async ( isFetching: nextIsFetching, invalid: false, })) - return inner.router.getMatch(matchId) ?? match - } else { - return match } + return match } export async function loadMatches(arg: { @@ -1042,6 +1064,7 @@ export async function loadMatches(arg: { let firstNotFound: NotFoundError | undefined let firstUnhandledRejection: unknown + let firstCancelledMatch: MatchLoadCancelledError | undefined for (let i = 0; i < maxIndexExclusive; i++) { matchPromises.push(loadRouteMatch(inner, matchPromises, i)) @@ -1056,6 +1079,10 @@ export async function loadMatches(arg: { if (result.status !== 'rejected') continue const reason = result.reason + if (isMatchLoadCancelledError(reason)) { + firstCancelledMatch ??= reason + continue + } if (isRedirect(reason)) { throw reason } @@ -1066,6 +1093,10 @@ export async function loadMatches(arg: { } } + if (firstCancelledMatch) { + throw firstCancelledMatch + } + if (firstUnhandledRejection !== undefined) { throw firstUnhandledRejection } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 965fefda339..3eb79c47cff 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -34,7 +34,12 @@ import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' -import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' +import { + isMatchLoadCancelledError, + loadMatches, + loadRouteChunk, + routeNeedsPreload, +} from './load-matches' import { composeRewrites, executeRewriteInput, @@ -2852,6 +2857,9 @@ export class RouterCore< return matches } catch (err) { + if (isMatchLoadCancelledError(err)) { + return undefined + } if (isRedirect(err)) { if (err.options.reloadDocument) { return undefined diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index e6eacd88578..ddcf3016e4d 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -427,7 +427,7 @@ describe('loader skip or exec', () => { resolveLoader?.({ ok: true }) - await preloadPromise + await expect(preloadPromise).resolves.toBeUndefined() // the route load won't throw, but it will log errors to the console if any expect(consoleErrorSpy).not.toHaveBeenCalled() @@ -488,9 +488,13 @@ describe('loader skip or exec', () => { resolveFooLoader?.({ ok: true }) - await Promise.all([preloadPromise, invalidatePromise]) + const [preloadResult] = await Promise.all([ + preloadPromise, + invalidatePromise, + ]) expect(barLoader).toHaveBeenCalledTimes(2) + expect(preloadResult).toBeUndefined() // the route load won't throw, but it will log errors to the console if any expect(consoleErrorSpy).not.toHaveBeenCalled() expect( From 4bf77c51a6e7062ae0c528ee0c94568a6f3b657c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 21 Mar 2026 18:26:59 +0100 Subject: [PATCH 4/4] fix(router-core): avoid preload cleanup crashes after invalidation 2 --- packages/react-router/src/Match.tsx | 39 ++++++-- packages/router-core/src/load-matches.ts | 115 ++++++++++------------- packages/router-core/src/router.ts | 73 +++++++++++--- 3 files changed, 140 insertions(+), 87 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 36b41ef1240..c860fc7274a 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -101,6 +101,29 @@ type MatchViewState = { parentRouteId: string | undefined } +function getMatchNonReactivePromise( + router: ReturnType, + match: { + id: string + _nonReactive: { + displayPendingPromise?: Promise + minPendingPromise?: Promise + loadPromise?: Promise + } + }, + key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise', +) { + const promise = + router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key] + + invariant( + promise, + `Missing suspense promise '${key}' for match '${match.id}'`, + ) + + return promise +} + function MatchView({ router, matchId, @@ -270,15 +293,15 @@ export const MatchInner = React.memo(function MatchInnerImpl({ const out = Comp ? : if (match._displayPending) { - throw router.getMatch(match.id)?._nonReactive.displayPendingPromise + throw getMatchNonReactivePromise(router, match, 'displayPendingPromise') } if (match._forcePending) { - throw router.getMatch(match.id)?._nonReactive.minPendingPromise + throw getMatchNonReactivePromise(router, match, 'minPendingPromise') } if (match.status === 'pending') { - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getMatchNonReactivePromise(router, match, 'loadPromise') } if (match.status === 'notFound') { @@ -288,7 +311,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ if (match.status === 'redirected') { invariant(isRedirect(match.error), 'Expected a redirect error') - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getMatchNonReactivePromise(router, match, 'loadPromise') } if (match.status === 'error') { @@ -351,11 +374,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({ }, [key, route.options.component, router.options.defaultComponent]) if (match._displayPending) { - throw router.getMatch(match.id)?._nonReactive.displayPendingPromise + throw getMatchNonReactivePromise(router, match, 'displayPendingPromise') } if (match._forcePending) { - throw router.getMatch(match.id)?._nonReactive.minPendingPromise + throw getMatchNonReactivePromise(router, match, 'minPendingPromise') } // see also hydrate() in packages/router-core/src/ssr/ssr-client.ts @@ -380,7 +403,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } } } - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getMatchNonReactivePromise(router, match, 'loadPromise') } if (match.status === 'notFound') { @@ -397,7 +420,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ // false, // 'Tried to render a redirected route match! This is a weird circumstance, please file an issue!', // ) - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getMatchNonReactivePromise(router, match, 'loadPromise') } if (match.status === 'error') { diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index ba22b44ddc6..4c1bd814a3a 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -37,40 +37,6 @@ type InnerLoadContext = { sync?: boolean } -const reason = 'Match removed before complete load' -class MatchLoadCancelledError extends Error { - constructor() { - super(reason) - this.name = 'MatchCancel' - } -} - -export const isMatchLoadCancelledError = ( - err: unknown, -): err is MatchLoadCancelledError => err instanceof MatchLoadCancelledError - -const getMatchOrThrowCancelled = ( - inner: InnerLoadContext, - matchId: string, - cleanupMatch?: AnyRouteMatch, -): AnyRouteMatch => { - const match = inner.router.getMatch(matchId) - if (match) return match as AnyRouteMatch - if (cleanupMatch) { - const s = cleanupMatch._nonReactive - s.beforeLoadPromise?.resolve() - s.loaderPromise?.resolve() - s.loadPromise?.resolve() - cleanupMatch.abortController.abort(reason) - clearTimeout(s.pendingTimeout) - s.beforeLoadPromise = undefined - s.loaderPromise = undefined - s.loadPromise = undefined - s.pendingTimeout = undefined - } - throw new MatchLoadCancelledError() -} - const triggerOnReady = (inner: InnerLoadContext): void | Promise => { if (!inner.rendered) { inner.rendered = true @@ -284,16 +250,23 @@ const handleSerialError = ( } } +const matchIsAvailable = ( + match: AnyRouteMatch | undefined, +): match is AnyRouteMatch => + Boolean(match && !match.abortController.signal.aborted) + const isBeforeLoadSsr = ( inner: InnerLoadContext, matchId: string, index: number, route: AnyRoute, ): void | Promise => { - const existingMatch = inner.router.getMatch(matchId)! + const existingMatch = inner.router.getMatch(matchId) + if (!existingMatch) return + const parentMatchId = inner.matches[index - 1]?.id const parentMatch = parentMatchId - ? inner.router.getMatch(parentMatchId)! + ? inner.router.getMatch(parentMatchId) : undefined // in SPA mode, only SSR the root route @@ -393,7 +366,8 @@ const preBeforeLoadSetup = ( matchId: string, route: AnyRoute, ): void | Promise => { - const existingMatch = inner.router.getMatch(matchId)! + const existingMatch = inner.router.getMatch(matchId) + if (!existingMatch) return // 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) @@ -406,7 +380,9 @@ const preBeforeLoadSetup = ( setupPendingTimeout(inner, matchId, route, existingMatch) const then = () => { - const match = inner.router.getMatch(matchId)! + const match = inner.router.getMatch(matchId) + if (!match) return + if ( match.preload && (match.status === 'redirected' || match.status === 'notFound') @@ -427,7 +403,8 @@ const executeBeforeLoad = ( index: number, route: AnyRoute, ): void | Promise => { - const match = inner.router.getMatch(matchId)! + const match = inner.router.getMatch(matchId) + if (!match) return // explicitly capture the previous loadPromise let prevLoadPromise = match._nonReactive.loadPromise @@ -643,17 +620,16 @@ const executeHead = ( const getLoaderContext = ( inner: InnerLoadContext, matchPromises: Array>, - matchId: string, + match: AnyRouteMatch, index: number, route: AnyRoute, ): LoaderFnContext => { const parentMatchPromise = matchPromises[index - 1] as any - const { params, loaderDeps, abortController, cause } = - inner.router.getMatch(matchId)! + const { params, loaderDeps, abortController, cause } = match const context = buildMatchContext(inner, index) - const preload = resolvePreload(inner, matchId) + const preload = resolvePreload(inner, match.id) return { params, @@ -688,7 +664,8 @@ const runLoader = async ( // before committing to the match and resolving // the loadPromise - const match = inner.router.getMatch(matchId)! + const match = inner.router.getMatch(matchId) + if (!matchIsAvailable(match)) return // Actually run the loader and handle the result try { @@ -701,7 +678,7 @@ const runLoader = async ( const loader = typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler const loaderResult = loader?.( - getLoaderContext(inner, matchPromises, matchId, index, route), + getLoaderContext(inner, matchPromises, match, index, route), ) const loaderResultIsPromise = !!loader && isPromise(loaderResult) @@ -727,6 +704,8 @@ const runLoader = async ( ? await loaderResult : loaderResult + if (match.abortController.signal.aborted) return + handleRedirectAndNotFound( inner, inner.router.getMatch(matchId), @@ -750,6 +729,9 @@ const runLoader = async ( // Last but not least, wait for the the components // to be preloaded before we resolve the match if (route._componentsPromise) await route._componentsPromise + + if (match.abortController.signal.aborted) return + inner.updateMatch(matchId, (prev) => ({ ...prev, error: undefined, @@ -761,6 +743,8 @@ const runLoader = async ( } catch (e) { let error = e + if (match.abortController.signal.aborted) return + if ((error as any)?.name === 'AbortError') { if (match.abortController.signal.aborted) { match._nonReactive.loaderPromise?.resolve() @@ -822,8 +806,6 @@ const loadRouteMatch = async ( matchPromises: Array>, index: number, ): Promise => { - let cleanupMatch: AnyRouteMatch | undefined - async function handleLoader( preload: boolean, prevMatch: AnyRouteMatch, @@ -831,6 +813,8 @@ const loadRouteMatch = async ( match: AnyRouteMatch, route: AnyRoute, ) { + if (match.abortController.signal.aborted) return + const age = Date.now() - prevMatch.updatedAt const staleAge = preload @@ -847,7 +831,7 @@ const loadRouteMatch = async ( const shouldReload = typeof shouldReloadOption === 'function' ? shouldReloadOption( - getLoaderContext(inner, matchPromises, matchId, index, route), + getLoaderContext(inner, matchPromises, match, index, route), ) : shouldReloadOption @@ -904,9 +888,8 @@ const loadRouteMatch = async ( inner.router.options.defaultStaleReloadMode) !== 'blocking' if (shouldSkipLoader(inner, matchId)) { - const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) - - cleanupMatch = match + const match = inner.router.getMatch(matchId) + if (!matchIsAvailable(match)) return inner.matches[index]! syncMatchContext(inner, matchId, index) @@ -914,7 +897,8 @@ const loadRouteMatch = async ( return inner.router.getMatch(matchId)! } } else { - const prevMatch = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) + const prevMatch = inner.router.getMatch(matchId) + if (!matchIsAvailable(prevMatch)) return inner.matches[index]! // This is where all of the stale-while-revalidate magic happens const activeIdAtIndex = inner.router.stores.matchesId.state[index] @@ -944,8 +928,10 @@ const loadRouteMatch = async ( return prevMatch } await prevMatch._nonReactive.loaderPromise - const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) - cleanupMatch = match + if (prevMatch.abortController.signal.aborted) return inner.matches[index]! + + const match = inner.router.getMatch(matchId) + if (!matchIsAvailable(match)) return inner.matches[index]! const error = match._nonReactive.error || match.error if (error) { @@ -964,8 +950,8 @@ const loadRouteMatch = async ( } else { const nextPreload = preload && !inner.router.stores.activeMatchStoresById.has(matchId) - const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) - cleanupMatch = match + const match = inner.router.getMatch(matchId) + if (!matchIsAvailable(match)) return inner.matches[index]! match._nonReactive.loaderPromise = createControlledPromise() if (nextPreload !== match.preload) { @@ -978,7 +964,9 @@ const loadRouteMatch = async ( await handleLoader(preload, prevMatch, previousRouteMatchId, match, route) } } - const match = getMatchOrThrowCancelled(inner, matchId, cleanupMatch) + const match = inner.router.getMatch(matchId) + if (!matchIsAvailable(match)) return inner.matches[index]! + if (!loaderIsRunningAsync) { match._nonReactive.loaderPromise?.resolve() match._nonReactive.loadPromise?.resolve() @@ -1064,7 +1052,6 @@ export async function loadMatches(arg: { let firstNotFound: NotFoundError | undefined let firstUnhandledRejection: unknown - let firstCancelledMatch: MatchLoadCancelledError | undefined for (let i = 0; i < maxIndexExclusive; i++) { matchPromises.push(loadRouteMatch(inner, matchPromises, i)) @@ -1079,10 +1066,6 @@ export async function loadMatches(arg: { if (result.status !== 'rejected') continue const reason = result.reason - if (isMatchLoadCancelledError(reason)) { - firstCancelledMatch ??= reason - continue - } if (isRedirect(reason)) { throw reason } @@ -1093,10 +1076,6 @@ export async function loadMatches(arg: { } } - if (firstCancelledMatch) { - throw firstCancelledMatch - } - if (firstUnhandledRejection !== undefined) { throw firstUnhandledRejection } @@ -1163,6 +1142,10 @@ export async function loadMatches(arg: { // lazy notFoundComponent) is loaded before we continue to head execution/render. await loadRouteChunk(boundaryRoute, ['notFoundComponent']) } else if (!inner.preload) { + if (inner.router.stores.location.state.href !== inner.location.href) { + return inner.matches + } + // Clear stale root global-not-found state on normal navigations that do not // throw notFound. This must live here (instead of only in runLoader success) // because the root loader may be skipped when data is still fresh. diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 3eb79c47cff..bf8593d98e6 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -34,12 +34,7 @@ import { setupScrollRestoration } from './scroll-restoration' import { defaultParseSearch, defaultStringifySearch } from './searchParams' import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' -import { - isMatchLoadCancelledError, - loadMatches, - loadRouteChunk, - routeNeedsPreload, -} from './load-matches' +import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' import { composeRewrites, executeRewriteInput, @@ -1543,6 +1538,9 @@ export class RouterCore< let match: AnyRouteMatch if (existingMatch) { + const shouldResetExistingMatch = + existingMatch.abortController.signal.aborted + match = { ...existingMatch, cause, @@ -1552,6 +1550,31 @@ export class RouterCore< ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) : nullReplaceEqualDeep(existingMatch.search, preMatchSearch), _strictSearch: strictMatchSearch, + _nonReactive: { + ...existingMatch._nonReactive, + loadPromise: + existingMatch._nonReactive.loadPromise ?? + createControlledPromise(), + }, + ...(shouldResetExistingMatch + ? { + isFetching: false, + abortController: new AbortController(), + _displayPending: undefined, + _forcePending: undefined, + _nonReactive: { + ...existingMatch._nonReactive, + beforeLoadPromise: undefined, + loaderPromise: undefined, + pendingTimeout: undefined, + loadPromise: createControlledPromise(), + displayPendingPromise: undefined, + minPendingPromise: undefined, + dehydrated: undefined, + error: undefined, + }, + } + : undefined), } } else { const status = @@ -1764,7 +1787,11 @@ export class RouterCore< if (!match) return - match.abortController.abort() + match._nonReactive.beforeLoadPromise?.resolve() + match._nonReactive.loaderPromise?.resolve() + match._nonReactive.beforeLoadPromise = undefined + match._nonReactive.loaderPromise = undefined + match.abortController.abort('Cancelled match') clearTimeout(match._nonReactive.pendingTimeout) match._nonReactive.pendingTimeout = undefined } @@ -2765,12 +2792,31 @@ export class RouterCore< } clearCache: ClearCacheFn = (opts) => { + const cachedMatches = this.stores.cachedMatchesSnapshot.state const filter = opts?.filter + + const matchesToClear = + filter !== undefined + ? cachedMatches.filter((match) => + filter(match as MakeRouteMatchUnion), + ) + : cachedMatches + + matchesToClear.forEach((match) => { + if ( + match.status === 'pending' || + match.isFetching || + match._nonReactive.beforeLoadPromise || + match._nonReactive.loaderPromise || + match._nonReactive.loadPromise + ) { + this.cancelMatch(match.id) + } + }) + if (filter !== undefined) { this.stores.setCachedMatches( - this.stores.cachedMatchesSnapshot.state.filter( - (m) => !filter(m as MakeRouteMatchUnion), - ), + cachedMatches.filter((m) => !filter(m as MakeRouteMatchUnion)), ) } else { this.stores.setCachedMatches([]) @@ -2855,11 +2901,12 @@ export class RouterCore< }, }) - return matches - } catch (err) { - if (isMatchLoadCancelledError(err)) { + if (matches.some((match) => !this.getMatch(match.id))) { return undefined } + + return matches + } catch (err) { if (isRedirect(err)) { if (err.options.reloadDocument) { return undefined