Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/moody-kings-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

evicting cache during preload does not error, return early
39 changes: 31 additions & 8 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ type MatchViewState = {
parentRouteId: string | undefined
}

function getMatchNonReactivePromise(
router: ReturnType<typeof useRouter>,
match: {
id: string
_nonReactive: {
displayPendingPromise?: Promise<void>
minPendingPromise?: Promise<void>
loadPromise?: Promise<void>
}
},
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,
Expand Down Expand Up @@ -270,15 +293,15 @@ export const MatchInner = React.memo(function MatchInnerImpl({
const out = Comp ? <Comp key={key} /> : <Outlet />

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') {
Expand All @@ -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') {
Expand Down Expand Up @@ -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
Expand All @@ -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') {
Expand All @@ -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') {
Expand Down
86 changes: 59 additions & 27 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,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<void> => {
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
Expand Down Expand Up @@ -359,7 +366,8 @@ const preBeforeLoadSetup = (
matchId: string,
route: AnyRoute,
): void | Promise<void> => {
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)
Expand All @@ -372,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')
Expand All @@ -393,7 +403,8 @@ const executeBeforeLoad = (
index: number,
route: AnyRoute,
): void | Promise<void> => {
const match = inner.router.getMatch(matchId)!
const match = inner.router.getMatch(matchId)
if (!match) return
Comment on lines +406 to +407
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip beforeLoad when the match was already canceled

cancelMatch() now resolves _nonReactive.beforeLoadPromise/loaderPromise before aborting the match (packages/router-core/src/router.ts:1790-1794). That wakes any concurrent load that was blocked in preBeforeLoadSetup(), but this entry point only checks for a missing match, not an aborted one. If the canceled match is still present in the active/pending pool (for example during an invalidate() or same-URL reload while an async beforeLoad is in flight), the stale load continues into route.options.beforeLoad and can still redirect or mutate __beforeLoadContext after the navigation was canceled.

Useful? React with 👍 / 👎.


// explicitly capture the previous loadPromise
let prevLoadPromise = match._nonReactive.loadPromise
Expand Down Expand Up @@ -609,17 +620,16 @@ const executeHead = (
const getLoaderContext = (
inner: InnerLoadContext,
matchPromises: Array<Promise<AnyRouteMatch>>,
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,
Expand Down Expand Up @@ -654,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 {
Expand All @@ -667,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)

Expand All @@ -693,6 +704,8 @@ const runLoader = async (
? await loaderResult
: loaderResult

if (match.abortController.signal.aborted) return

handleRedirectAndNotFound(
inner,
inner.router.getMatch(matchId),
Expand All @@ -716,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,
Expand All @@ -727,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()
Expand Down Expand Up @@ -795,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
Expand All @@ -811,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

Expand All @@ -834,18 +854,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) {
Expand All @@ -868,17 +889,18 @@ const loadRouteMatch = async (

if (shouldSkipLoader(inner, matchId)) {
const match = inner.router.getMatch(matchId)
if (!match) {
return inner.matches[index]!
}
if (!matchIsAvailable(match)) return inner.matches[index]!

syncMatchContext(inner, matchId, index)

if (isServer ?? inner.router.isServer) {
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 (!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]
const activeAtIndex =
(activeIdAtIndex &&
Expand Down Expand Up @@ -906,7 +928,11 @@ const loadRouteMatch = async (
return prevMatch
}
await prevMatch._nonReactive.loaderPromise
const match = inner.router.getMatch(matchId)!
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) {
handleRedirectAndNotFound(inner, match, error)
Expand All @@ -924,7 +950,9 @@ 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 (!matchIsAvailable(match)) return inner.matches[index]!

match._nonReactive.loaderPromise = createControlledPromise<void>()
if (nextPreload !== match.preload) {
inner.updateMatch(matchId, (prev) => ({
Expand All @@ -936,7 +964,9 @@ const loadRouteMatch = async (
await handleLoader(preload, prevMatch, previousRouteMatchId, match, route)
}
}
const match = inner.router.getMatch(matchId)!
const match = inner.router.getMatch(matchId)
if (!matchIsAvailable(match)) return inner.matches[index]!

if (!loaderIsRunningAsync) {
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
Expand All @@ -955,10 +985,8 @@ const loadRouteMatch = async (
isFetching: nextIsFetching,
invalid: false,
}))
return inner.router.getMatch(matchId)!
} else {
return match
}
return match
}

export async function loadMatches(arg: {
Expand Down Expand Up @@ -1114,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.
Expand Down
Loading
Loading