Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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.
53 changes: 35 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,11 @@ 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 +395,12 @@ 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 +844,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 +914,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 +947,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 +969,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))
}