From 51fe6a1abd8c44f6914c4ebe819fa34f49b8ab3a Mon Sep 17 00:00:00 2001 From: thribhuvan003 Date: Thu, 9 Jul 2026 01:20:23 +0530 Subject: [PATCH 1/2] fix(router-core): guard against evicted matches during in-flight preloads --- .../fix-preload-evicted-match-guards.md | 9 ++ packages/router-core/src/load-matches.ts | 49 +++++---- packages/router-core/tests/load.test.ts | 100 ++++++++++++++++++ 3 files changed, 140 insertions(+), 18 deletions(-) create mode 100644 .changeset/fix-preload-evicted-match-guards.md diff --git a/.changeset/fix-preload-evicted-match-guards.md b/.changeset/fix-preload-evicted-match-guards.md new file mode 100644 index 0000000000..60feb93b30 --- /dev/null +++ b/.changeset/fix-preload-evicted-match-guards.md @@ -0,0 +1,9 @@ +--- +'@tanstack/router-core': patch +--- + +fix(router-core): guard against evicted matches during in-flight preloads + +When a preload's cached match is evicted while its async work is still in flight (e.g. the cache is cleared, garbage-collected, or the user navigates mid-preload), the load pipeline re-looked-up the match after each `await` with a non-null assertion and crashed with `TypeError: Cannot read properties of undefined (reading '_nonReactive')`. The re-lookups are now guarded, and the post-load cleanup falls back to the matched object so in-flight promises still settle and concurrent loads awaiting them don't hang. + +Fixes #7759. diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index f901a0c97d..a837937659 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -370,7 +370,9 @@ const preBeforeLoadSetup = ( setupPendingTimeout(inner, matchId, route, existingMatch) const then = () => { - const match = inner.router.getMatch(matchId)! + const match = inner.router.getMatch(matchId) + // in case the match was evicted while awaiting the previous beforeLoad + if (!match) return if ( match.preload && (match.status === 'redirected' || match.status === 'notFound') @@ -391,7 +393,10 @@ const executeBeforeLoad = ( index: number, route: AnyRoute, ): void | Promise => { - const match = inner.router.getMatch(matchId)! + const match = inner.router.getMatch(matchId) + // in case the match was evicted while `preBeforeLoadSetup` was awaiting the + // previous beforeLoad + if (!match) return // explicitly capture the previous loadPromise let prevLoadPromise = match._nonReactive.loadPromise @@ -835,7 +840,9 @@ const loadRouteMatch = async ( ;(async () => { try { await runLoader(inner, matchPromises, matchId, index, route) - const match = inner.router.getMatch(matchId)! + // in case the match was evicted while the loader was running, fall + // back to the matched object so its promises still settle + const match = inner.router.getMatch(matchId) ?? inner.matches[index]! match._nonReactive.loaderPromise?.resolve() match._nonReactive.loadPromise?.resolve() match._nonReactive.loaderPromise = undefined @@ -903,20 +910,23 @@ const loadRouteMatch = async ( return prevMatch } await prevMatch._nonReactive.loaderPromise - const match = inner.router.getMatch(matchId)! - const error = match._nonReactive.error || match.error - if (error) { - handleRedirectAndNotFound(inner, match, error) - } + const match = inner.router.getMatch(matchId) + // in case the match was evicted while awaiting the in-flight load + if (match) { + const error = match._nonReactive.error || match.error + if (error) { + handleRedirectAndNotFound(inner, match, error) + } - if (match.status === 'pending') { - await handleLoader( - preload, - prevMatch, - previousRouteMatchId, - match, - route, - ) + if (match.status === 'pending') { + await handleLoader( + preload, + prevMatch, + previousRouteMatchId, + match, + route, + ) + } } } else { const nextPreload = @@ -933,7 +943,10 @@ const loadRouteMatch = async ( await handleLoader(preload, prevMatch, previousRouteMatchId, match, route) } } - const match = inner.router.getMatch(matchId)! + // In case the match was evicted while the loader was in flight, fall back to + // the matched object (`_nonReactive` is stable across store updates) so its + // promises and timeout still settle and concurrent loads don't hang. + const match = inner.router.getMatch(matchId) ?? inner.matches[index]! if (!loaderIsRunningAsync) { match._nonReactive.loaderPromise?.resolve() match._nonReactive.loadPromise?.resolve() @@ -952,7 +965,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 1ea6fca30e..c8d1506138 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -2176,6 +2176,106 @@ describe('routeId in context options', () => { }) }) +describe('preload survives cache eviction during an in-flight load', () => { + // https://github.com/TanStack/router/issues/7759 + const setup = ({ loader }: { loader: Loader }) => { + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + }) + + const routeTree = rootRoute.addChildren([fooRoute]) + + const router = createTestRouter({ + routeTree, + history: createMemoryHistory(), + }) + + return router + } + + test('preloadRoute settles cleanly when the cached match is evicted while its loader is in flight', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + let releaseLoader!: () => void + const loaderGate = new Promise((resolve) => { + releaseLoader = resolve + }) + let loaderStarted!: () => void + const loaderStartedPromise = new Promise((resolve) => { + loaderStarted = resolve + }) + + const router = setup({ + loader: async () => { + loaderStarted() + await loaderGate + }, + }) + + const preload = router.preloadRoute({ to: '/foo' }) + await loaderStartedPromise + + // evict the preload's cached match while its loader is still running + router.clearCache() + releaseLoader() + + const matches = await preload + + expect(matches).toBeDefined() + expect(consoleError).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + + test('a second preload awaiting the in-flight load settles after eviction', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + let releaseLoader!: () => void + const loaderGate = new Promise((resolve) => { + releaseLoader = resolve + }) + let loaderStarted!: () => void + const loaderStartedPromise = new Promise((resolve) => { + loaderStarted = resolve + }) + + const router = setup({ + loader: async () => { + loaderStarted() + await loaderGate + }, + }) + + const firstPreload = router.preloadRoute({ to: '/foo' }) + await loaderStartedPromise + + // joins the first preload by awaiting its loaderPromise + const secondPreload = router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await Promise.resolve() + + router.clearCache() + releaseLoader() + + // without the eviction guards the first preload throws instead of + // resolving the shared loaderPromise, so the second one never settles + const [firstMatches, secondMatches] = await Promise.all([ + firstPreload, + secondPreload, + ]) + + expect(firstMatches).toBeDefined() + expect(secondMatches).toBeDefined() + expect(consoleError).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) +}) + function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } From bb4816fabec1535f34b4731bb0d652d2cb7dcf27 Mon Sep 17 00:00:00 2001 From: thribhuvan003 Date: Thu, 9 Jul 2026 06:00:58 +0530 Subject: [PATCH 2/2] chore: braces on guard returns per style guide --- packages/router-core/src/load-matches.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index a837937659..05fcf41294 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -372,7 +372,9 @@ const preBeforeLoadSetup = ( const then = () => { const match = inner.router.getMatch(matchId) // in case the match was evicted while awaiting the previous beforeLoad - if (!match) return + if (!match) { + return + } if ( match.preload && (match.status === 'redirected' || match.status === 'notFound') @@ -396,7 +398,9 @@ const executeBeforeLoad = ( const match = inner.router.getMatch(matchId) // in case the match was evicted while `preBeforeLoadSetup` was awaiting the // previous beforeLoad - if (!match) return + if (!match) { + return + } // explicitly capture the previous loadPromise let prevLoadPromise = match._nonReactive.loadPromise