Skip to content

Commit ce450ad

Browse files
committed
fix(router-core): avoid preload cleanup crashes after invalidation
1 parent 0d11d5e commit ce450ad

2 files changed

Lines changed: 148 additions & 10 deletions

File tree

packages/router-core/src/load-matches.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -834,18 +834,19 @@ const loadRouteMatch = async (
834834
shouldReloadInBackground
835835
) {
836836
loaderIsRunningAsync = true
837+
const matchForCleanup = prevMatch
837838
;(async () => {
838839
try {
839840
await runLoader(inner, matchPromises, matchId, index, route)
840-
const match = inner.router.getMatch(matchId)!
841-
match._nonReactive.loaderPromise?.resolve()
842-
match._nonReactive.loadPromise?.resolve()
843-
match._nonReactive.loaderPromise = undefined
844-
match._nonReactive.loadPromise = undefined
845841
} catch (err) {
846842
if (isRedirect(err)) {
847843
await inner.router.navigate(err.options)
848844
}
845+
} finally {
846+
matchForCleanup._nonReactive.loaderPromise?.resolve()
847+
matchForCleanup._nonReactive.loadPromise?.resolve()
848+
matchForCleanup._nonReactive.loaderPromise = undefined
849+
matchForCleanup._nonReactive.loadPromise = undefined
849850
}
850851
})()
851852
} else if (status !== 'success' || loaderShouldRunAsync) {
@@ -878,7 +879,12 @@ const loadRouteMatch = async (
878879
return inner.router.getMatch(matchId)!
879880
}
880881
} else {
881-
const prevMatch = inner.router.getMatch(matchId)! // This is where all of the stale-while-revalidate magic happens
882+
const prevMatch = inner.router.getMatch(matchId)
883+
if (!prevMatch) {
884+
return inner.matches[index]!
885+
}
886+
887+
// This is where all of the stale-while-revalidate magic happens
882888
const activeIdAtIndex = inner.router.stores.matchesId.state[index]
883889
const activeAtIndex =
884890
(activeIdAtIndex &&
@@ -906,7 +912,11 @@ const loadRouteMatch = async (
906912
return prevMatch
907913
}
908914
await prevMatch._nonReactive.loaderPromise
909-
const match = inner.router.getMatch(matchId)!
915+
const match = inner.router.getMatch(matchId)
916+
if (!match) {
917+
return inner.matches[index]!
918+
}
919+
910920
const error = match._nonReactive.error || match.error
911921
if (error) {
912922
handleRedirectAndNotFound(inner, match, error)
@@ -924,7 +934,11 @@ const loadRouteMatch = async (
924934
} else {
925935
const nextPreload =
926936
preload && !inner.router.stores.activeMatchStoresById.has(matchId)
927-
const match = inner.router.getMatch(matchId)!
937+
const match = inner.router.getMatch(matchId)
938+
if (!match) {
939+
return inner.matches[index]!
940+
}
941+
928942
match._nonReactive.loaderPromise = createControlledPromise<void>()
929943
if (nextPreload !== match.preload) {
930944
inner.updateMatch(matchId, (prev) => ({
@@ -936,7 +950,11 @@ const loadRouteMatch = async (
936950
await handleLoader(preload, prevMatch, previousRouteMatchId, match, route)
937951
}
938952
}
939-
const match = inner.router.getMatch(matchId)!
953+
const match = inner.router.getMatch(matchId)
954+
if (!match) {
955+
return inner.matches[index]!
956+
}
957+
940958
if (!loaderIsRunningAsync) {
941959
match._nonReactive.loaderPromise?.resolve()
942960
match._nonReactive.loadPromise?.resolve()
@@ -955,7 +973,7 @@ const loadRouteMatch = async (
955973
isFetching: nextIsFetching,
956974
invalid: false,
957975
}))
958-
return inner.router.getMatch(matchId)!
976+
return inner.router.getMatch(matchId) ?? match
959977
} else {
960978
return match
961979
}

packages/router-core/tests/load.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,126 @@ describe('loader skip or exec', () => {
381381
expect(loader).toHaveBeenCalledTimes(1)
382382
})
383383

384+
test('does not error if cache gc clears an in-flight preload', async () => {
385+
let resolveLoader: ((value: { ok: true }) => void) | undefined
386+
const consoleErrorSpy = vi
387+
.spyOn(console, 'error')
388+
.mockImplementation(() => {})
389+
390+
const loader: Loader = vi.fn(
391+
() =>
392+
new Promise<{ ok: true }>((resolve) => {
393+
resolveLoader = resolve
394+
}),
395+
)
396+
397+
const rootRoute = new BaseRootRoute({})
398+
const fooRoute = new BaseRoute({
399+
getParentRoute: () => rootRoute,
400+
path: '/foo',
401+
loader,
402+
preloadGcTime: 0,
403+
})
404+
405+
const router = createTestRouter({
406+
routeTree: rootRoute.addChildren([fooRoute]),
407+
history: createMemoryHistory(),
408+
defaultPreloadGcTime: 0,
409+
})
410+
411+
const preloadPromise = router.preloadRoute({ to: '/foo' })
412+
await Promise.resolve()
413+
414+
expect(
415+
router.stores.cachedMatchesSnapshot.state.some(
416+
(match) => match.routeId === fooRoute.id,
417+
),
418+
).toBe(true)
419+
420+
router.clearExpiredCache()
421+
422+
expect(
423+
router.stores.cachedMatchesSnapshot.state.some(
424+
(match) => match.routeId === fooRoute.id,
425+
),
426+
).toBe(false)
427+
428+
resolveLoader?.({ ok: true })
429+
430+
await preloadPromise
431+
// the route load won't throw, but it will log errors to the console if any
432+
expect(consoleErrorSpy).not.toHaveBeenCalled()
433+
434+
expect(
435+
router.stores.cachedMatchesSnapshot.state.some(
436+
(match) => match.routeId === fooRoute.id,
437+
),
438+
).toBe(false)
439+
consoleErrorSpy.mockRestore()
440+
})
441+
442+
test('does not error when invalidate clears an in-flight preload', async () => {
443+
let resolveFooLoader: ((value: { ok: true }) => void) | undefined
444+
const consoleErrorSpy = vi
445+
.spyOn(console, 'error')
446+
.mockImplementation(() => {})
447+
448+
const fooLoader: Loader = vi.fn(
449+
() =>
450+
new Promise<{ ok: true }>((resolve) => {
451+
resolveFooLoader = resolve
452+
}),
453+
)
454+
const barLoader: Loader = vi.fn(() => ({ ok: true }))
455+
456+
const rootRoute = new BaseRootRoute({})
457+
const fooRoute = new BaseRoute({
458+
getParentRoute: () => rootRoute,
459+
path: '/foo',
460+
loader: fooLoader,
461+
preloadGcTime: 0,
462+
})
463+
const barRoute = new BaseRoute({
464+
getParentRoute: () => rootRoute,
465+
path: '/bar',
466+
loader: barLoader,
467+
})
468+
469+
const router = createTestRouter({
470+
routeTree: rootRoute.addChildren([fooRoute, barRoute]),
471+
history: createMemoryHistory(),
472+
defaultPreloadGcTime: 0,
473+
})
474+
475+
await router.navigate({ to: '/bar' })
476+
477+
const preloadPromise = router.preloadRoute({ to: '/foo' })
478+
await Promise.resolve()
479+
480+
expect(
481+
router.stores.cachedMatchesSnapshot.state.some(
482+
(match) => match.routeId === fooRoute.id,
483+
),
484+
).toBe(true)
485+
486+
const invalidatePromise = router.invalidate()
487+
await Promise.resolve()
488+
489+
resolveFooLoader?.({ ok: true })
490+
491+
await Promise.all([preloadPromise, invalidatePromise])
492+
493+
expect(barLoader).toHaveBeenCalledTimes(2)
494+
// the route load won't throw, but it will log errors to the console if any
495+
expect(consoleErrorSpy).not.toHaveBeenCalled()
496+
expect(
497+
router.stores.cachedMatchesSnapshot.state.some(
498+
(match) => match.routeId === fooRoute.id,
499+
),
500+
).toBe(false)
501+
consoleErrorSpy.mockRestore()
502+
})
503+
384504
test('exec if rejected preload (notFound)', async () => {
385505
const loader: Loader = vi.fn(async ({ preload }) => {
386506
if (preload) throw notFound()

0 commit comments

Comments
 (0)