Skip to content
Merged
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
53 changes: 7 additions & 46 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,32 +35,6 @@ type InnerLoadContext = {
sync?: boolean
/** mutable state, scoped to a `loadMatches` call */
matchPromises: Array<Promise<AnyRouteMatch>>
/**
* Generation number for this load operation (only set for non-preload loads).
* Used to detect when this load has been superseded by a newer one.
* Compared against router._loadGeneration to abort stale async operations.
*/
loadGeneration?: number
}

/**
* Checks if this load operation has been superseded by a newer one.
* This prevents stale async operations from updating router state.
*
* Returns true if the operation should be aborted in these cases:
* 1. Navigation to a different location
* 2. Route invalidation on the same location (new loader dispatch)
* 3. Any other scenario that triggers a new loadMatches() call with preload=false
*
* For preload operations (inner.loadGeneration undefined), never abort.
*/
const shouldAbortLoad = (inner: InnerLoadContext): boolean => {
// Preloads don't have a generation number and should never be aborted by this check
if (inner.loadGeneration === undefined) {
return false
}
// Check if a newer load operation has started (higher generation number)
return inner.loadGeneration !== inner.router._loadGeneration
}

const triggerOnReady = (inner: InnerLoadContext): void | Promise<void> => {
Expand Down Expand Up @@ -610,25 +584,15 @@ const executeHead = (
}

const executeAllHeadFns = async (inner: InnerLoadContext) => {
// Check if this load operation has been superseded before starting
if (shouldAbortLoad(inner)) return

// Serially execute head functions for all matches
// Each execution is wrapped in try-catch to ensure all heads run even if one fails
for (const match of inner.matches) {
// Check before each match in case we get aborted during iteration
if (shouldAbortLoad(inner)) return

const { id: matchId, routeId } = match
const route = inner.router.looseRoutesById[routeId]!
try {
const headResult = executeHead(inner, matchId, route)
if (headResult) {
const head = await headResult

// Check again after async operation completes
if (shouldAbortLoad(inner)) return

inner.updateMatch(matchId, (prev) => ({
...prev,
...head,
Expand Down Expand Up @@ -937,12 +901,6 @@ export async function loadMatches(arg: {
matchPromises: [],
})

// For non-preload operations, assign a generation number to detect stale operations later
// This handles both navigation (different location) and invalidation (same location)
if (!inner.preload) {
inner.loadGeneration = ++inner.router._loadGeneration
}

// make sure the pending component is immediately rendered when hydrating a match that is not SSRed
// the pending component was already rendered on the server and we want to keep it shown on the client until minPendingMs is reached
if (
Expand Down Expand Up @@ -1001,15 +959,18 @@ export async function loadMatches(arg: {
if (asyncLoaderPromises.length > 0) {
// Schedule re-execution after all async loaders complete (non-blocking)
// Use allSettled to handle both successful and failed loaders
Promise.allSettled(asyncLoaderPromises).then(() => {
// Only execute if this load operation hasn't been superseded
const rerunPromise: Promise<void> = Promise.allSettled(
asyncLoaderPromises,
).then(async () => {
// Only execute if this is still the latest scheduled re-run
// This handles both:
// 1. Navigation to a different location
// 2. Route invalidation on the same location (new loader dispatch)
if (!shouldAbortLoad(inner)) {
executeAllHeadFns(inner)
if (inner.router.latestHeadRerunPromise === rerunPromise) {
await executeAllHeadFns(inner)
}
})
inner.router.latestHeadRerunPromise = rerunPromise
}

// Throw notFound after head execution
Expand Down
18 changes: 6 additions & 12 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,20 +895,14 @@ export class RouterCore<
isScrollRestoring = false
isScrollRestorationSetup = false
/**
* Internal: Generation counter for tracking load operations (excludes preloads).
* Incremented each time loadMatches() is called with preload=false.
* Internal: Tracks the latest scheduled head() re-run promise.
* Used to detect when a head re-run has been superseded by a newer one.
*
* Purpose: Detects stale async operations (like detached head re-runs) when a new
* load starts. Handles both navigation to different locations AND invalidation on
* the same location.
*
* Example: If async loaders complete and schedule a head re-run, but a new navigation
* or invalidation has started (incrementing this counter), the old head re-run will
* detect staleness and abort before updating state.
*
* Why a counter: Simple, no circular references, standard pattern in reactive systems.
* When async loaders complete, we schedule a head re-run. If a new navigation
* or invalidation starts before the re-run executes, a new promise is assigned.
* The old re-run checks if it's still the latest before executing.
*/
_loadGeneration: number = 0
latestHeadRerunPromise?: Promise<void>

// Must build in constructor
__store!: Store<RouterState<TRouteTree>>
Expand Down