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
6 changes: 0 additions & 6 deletions docs/router/framework/react/api/router/RouterStateType.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ type RouterState = {
isLoading: boolean
isTransitioning: boolean
matches: Array<RouteMatch>
pendingMatches: Array<RouteMatch>
location: ParsedLocation
resolvedLocation: ParsedLocation
}
Expand Down Expand Up @@ -41,11 +40,6 @@ The `RouterState` type contains all of the properties that are available on the
- Type: [`Array<RouteMatch>`](./RouteMatchType.md)
- An array of all of the route matches that have been resolved and are currently active.

### `pendingMatches` property

- Type: [`Array<RouteMatch>`](./RouteMatchType.md)
- An array of all of the route matches that are currently pending.

### `location` property

- Type: [`ParsedLocation`](./ParsedLocationType.md)
Expand Down
3 changes: 0 additions & 3 deletions docs/router/framework/react/api/router/useChildMatchesHook.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ title: useChildMatches hook

The `useChildMatches` hook returns all of the child [`RouteMatch`](./RouteMatchType.md) objects from the closest match down to the leaf-most match. **It does not include the current match, which can be obtained using the `useMatch` hook.**

> [!IMPORTANT]
> If the router has pending matches and they are showing their pending component fallbacks, `router.state.pendingMatches` will used instead of `router.state.matches`.
## useChildMatches options

The `useChildMatches` hook accepts a single _optional_ argument, an `options` object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ title: useParentMatches hook

The `useParentMatches` hook returns all of the parent [`RouteMatch`](./RouteMatchType.md) objects from the root down to the immediate parent of the current match in context. **It does not include the current match, which can be obtained using the `useMatch` hook.**

> [!IMPORTANT]
> If the router has pending matches and they are showing their pending component fallbacks, `router.state.pendingMatches` will used instead of `router.state.matches`.

## useParentMatches options

The `useParentMatches` hook accepts an optional `options` object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 12
expect(updates).toBeLessThanOrEqual(13)
expect(updates).toBeGreaterThanOrEqual(6) // WARN: this is flaky
expect(updates).toBeLessThanOrEqual(9)
})

test('redirection in preload', async () => {
Expand All @@ -155,7 +155,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBe(4)
expect(updates).toBe(3)
})

test('sync beforeLoad', async () => {
Expand All @@ -171,8 +171,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBeGreaterThanOrEqual(9) // WARN: this is flaky
expect(updates).toBeLessThanOrEqual(12)
expect(updates).toBeGreaterThanOrEqual(4) // WARN: this is flaky
expect(updates).toBeLessThanOrEqual(7)
})

test('nothing', async () => {
Expand All @@ -183,8 +183,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBeGreaterThanOrEqual(6) // WARN: this is flaky, and sometimes (rarely) is 9
expect(updates).toBeLessThanOrEqual(9)
expect(updates).toBeGreaterThanOrEqual(2) // WARN: this is flaky
expect(updates).toBeLessThanOrEqual(5)
})

test('not found in beforeLoad', async () => {
Expand All @@ -199,7 +199,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBe(7)
expect(updates).toBe(2)
})

test('hover preload, then navigate, w/ async loaders', async () => {
Expand All @@ -225,7 +225,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBe(16)
expect(updates).toBe(8)
})

test('navigate, w/ preloaded & async loaders', async () => {
Expand All @@ -241,8 +241,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBeGreaterThanOrEqual(7)
expect(updates).toBeLessThanOrEqual(8)
expect(updates).toBeGreaterThanOrEqual(2) // WARN: this is flaky
expect(updates).toBeLessThanOrEqual(5)
})

test('navigate, w/ preloaded & sync loaders', async () => {
Expand All @@ -258,7 +258,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBe(6)
expect(updates).toBe(3)
})

test('navigate, w/ previous navigation & async loader', async () => {
Expand All @@ -274,7 +274,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBe(5)
expect(updates).toBe(3)
})

test('preload a preloaded route w/ async loader', async () => {
Expand Down
68 changes: 51 additions & 17 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ const resolvePreload = (inner: InnerLoadContext, matchId: string): boolean => {
/**
* Builds the accumulated context from router options and all matches up to (and optionally including) the given index.
* Merges __routeContext and __beforeLoadContext from each match.
*
* Note: We use inner.matches directly (which is __pendingMatches during loading)
* rather than looking up via router.getMatch() to ensure we always get the latest
* updates from updateMatch during the loading phase. This is critical because
* __matchesById might be rebuilt by other operations (e.g., onReady commit)
* which could cause timing issues in SPA mode.
*/
const buildMatchContext = (
inner: InnerLoadContext,
Expand All @@ -65,11 +71,9 @@ const buildMatchContext = (
}
const end = includeCurrentMatch ? index : index - 1
for (let i = 0; i <= end; i++) {
const innerMatch = inner.matches[i]
if (!innerMatch) continue
const m = inner.router.getMatch(innerMatch.id)
if (!m) continue
Object.assign(context, m.__routeContext, m.__beforeLoadContext)
const match = inner.matches[i]
if (!match) continue
Object.assign(context, match.__routeContext, match.__beforeLoadContext)
}
return context
}
Expand Down Expand Up @@ -473,16 +477,16 @@ const executeBeforeLoad = (
handleSerialError(inner, index, beforeLoadContext, 'BEFORE_LOAD')
}

batch(() => {
pending()
// Only store __beforeLoadContext here, don't update context yet
// Context will be updated in loadRouteMatch after loader completes
inner.updateMatch(matchId, (prev) => ({
...prev,
__beforeLoadContext: beforeLoadContext,
}))
resolve()
})
// Execute the update synchronously to ensure the match is updated
// before the next beforeLoad runs. We avoid batch() here because
// batching reactive notifications can cause timing issues where
// the child route's beforeLoad runs before the parent's context is available.
pending()
inner.updateMatch(matchId, (prev) => ({
...prev,
__beforeLoadContext: beforeLoadContext,
}))
resolve()
}

let beforeLoadContext
Expand Down Expand Up @@ -586,8 +590,10 @@ const getLoaderContext = (
route: AnyRoute,
): LoaderFnContext => {
const parentMatchPromise = inner.matchPromises[index - 1] as any
const { params, loaderDeps, abortController, cause } =
inner.router.getMatch(matchId)!
// Use inner.matches[index] directly to ensure we get the latest updates
// from the pending matches array during loading
const match = inner.matches[index]!
const { params, loaderDeps, abortController, cause } = match

const context = buildMatchContext(inner, index)

Expand Down Expand Up @@ -866,8 +872,36 @@ export async function loadMatches(arg: {
updateMatch: UpdateMatchFn
sync?: boolean
}): Promise<Array<MakeRouteMatch>> {
// Create a local matches index for O(1) lookup during loading
// This is necessary because the router's __pendingMatchesIndex may be cleared
// by other operations (like transitions or navigation) during async loading
const matchesIndex = new Map<string, number>()
arg.matches.forEach((match, i) => {
matchesIndex.set(match.id, i)
})

// Save the original updateMatch before wrapping
const originalUpdateMatch = arg.updateMatch

// Wrap updateMatch to update the local matches array directly
// This ensures updates are applied even if __pendingMatches is cleared
const localUpdateMatch: UpdateMatchFn = (id, updater) => {
const index = matchesIndex.get(id)
if (index !== undefined) {
const prev = arg.matches[index]!
const next = updater(prev)
if (next !== prev) {
arg.matches[index] = next
}
}
// Also call the original updateMatch to keep __matchesById in sync
// and handle any other side effects
originalUpdateMatch(id, updater)
}

const inner: InnerLoadContext = Object.assign(arg, {
matchPromises: [],
updateMatch: localUpdateMatch,
})

// make sure the pending component is immediately rendered when hydrating a match that is not SSRed
Expand Down
Loading
Loading