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
5 changes: 5 additions & 0 deletions .changeset/await-current-shared-settle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Make each load transaction's completion follow its current successor so a burst of back-to-back loads wakes each superseded waiter once instead of once per successor. Previously every superseded `load()` re-polled the current transaction on each later completion, which was quadratic in microtask work.
14 changes: 8 additions & 6 deletions packages/router-core/src/load-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1655,16 +1655,19 @@ function commitMatches(
runRouteLifecycle(router, previous, matches, tx)
}

/**
* Resolve once the router has settled on a transaction other than `owner`.
* Each transaction's completion follows its current successor, so all waiters
* share the same chain instead of polling every successor independently.
*/
async function awaitCurrent(
router: CoordinatorRouter,
owner?: LoadTransaction,
): Promise<void> {
let current = router._tx
while (current && current !== owner) {
owner = current
await current[5 /* done */]
if (router._tx === current) {
return
}
current = router._tx
}
}
Expand Down Expand Up @@ -1974,7 +1977,7 @@ export async function loadClientRoute(
location,
matches,
Date.now(),
done,
done.then(() => awaitCurrent(router, tx)),
]
if (process.env.NODE_ENV !== 'production' && rematerialize) {
tx[6 /* refresh */] = [handoff]
Expand Down Expand Up @@ -2021,8 +2024,7 @@ export async function loadClientRoute(
}
// Let explicit synchronous loads publish ready pending work before paint.
settle?.(run())
await done
await awaitCurrent(router, tx)
await tx[5 /* done */]
}

export async function refreshClientRoute(
Expand Down
66 changes: 66 additions & 0 deletions packages/router-core/tests/navigation-burst-settle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'vitest'
import { createMemoryHistory } from '@tanstack/history'
import { BaseRootRoute, BaseRoute } from '../src'
import { createTestRouter } from './routerTestUtils'

/**
* A superseded navigation resolves only once the router has settled on a
* transaction nobody replaced. A burst of same-tick navigations used to make
* every superseded waiter re-poll `router._tx` on each successor's
* completion, which was quadratic in microtask work and kept every
* superseded transaction alive until the router went idle. The waiters now
* share one memoized settle chain per transaction; this pins the observable
* contract that the chain preserves.
*/
describe('same-tick navigation burst', () => {
test('every superseded navigate() resolves after the router settles on the last one', async () => {
const rootRoute = new BaseRootRoute({})
const indexRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/',
})
const itemRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/items/$id',
})

const router = createTestRouter({
routeTree: rootRoute.addChildren([indexRoute, itemRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})

await router.load()
const unsubscribe = router.history.subscribe(router.load)

try {
// Location and status observed by each navigation when it resolved.
const observed: Array<[pathname: string, status: string]> = []
const burst = Array.from({ length: 25 }, (_, index) =>
router
.navigate({ to: '/items/$id', params: { id: String(index) } })
.then(() => {
observed.push([router.state.location.pathname, router.state.status])
}),
)

await Promise.all(burst)

expect(router.state.location.pathname).toBe('/items/24')
expect(router.state.status).toBe('idle')
// No navigation resolves before the router settled on the winner.
expect(observed).toHaveLength(25)
expect(new Set(observed.map(([pathname]) => pathname))).toEqual(
new Set(['/items/24']),
)
expect(new Set(observed.map(([, status]) => status))).toEqual(
new Set(['idle']),
)
expect(router.state.matches.map((m) => m.status)).toEqual([
'success',
'success',
])
} finally {
unsubscribe()
}
})
})
Loading