Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fix-preload-evicted-match-guards.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 31 additions & 18 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -391,7 +393,10 @@ const executeBeforeLoad = (
index: number,
route: AnyRoute,
): void | Promise<void> => {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -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()
Expand All @@ -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
}
Expand Down
100 changes: 100 additions & 0 deletions packages/router-core/tests/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
releaseLoader = resolve
})
let loaderStarted!: () => void
const loaderStartedPromise = new Promise<void>((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<void>((resolve) => {
releaseLoader = resolve
})
let loaderStarted!: () => void
const loaderStartedPromise = new Promise<void>((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<void>((resolve) => setTimeout(resolve, ms))
}