From 8c76f3390d246830cbc62c3ec348c7f48aaca928 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 3 Sep 2026 00:27:27 +0200 Subject: [PATCH] perf(router-core): make superseded navigation waiters share one settle chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `awaitCurrent` looped `await current.done` and re-read `router._tx` after each completion. Under back-to-back navigations (anything that starts the next navigation in the same tick as `onRendered`, including the client-nav benchmarks) every superseded waiter woke on every successor, so a burst of N navigations cost N²/2 microtask iterations and kept every superseded transaction alive until the router went idle. Each transaction's `done` promise now follows its current successor, so every waiter on a transaction shares the same chain instead of polling successors independently. A waiter still resolves once the router has settled on a transaction other than its owner. Measured in benchmarks/client-nav (react, navigations per 10s, jsdom): baseline 15.4K -> 43K, route-tree-scale 17.9K -> 98K, search-params 16.5K -> 55K. The instrumented baseline run showed 125.8M loop iterations for 15,867 navigations before the change. Bundle: react-router.minimal -1 B gzip vs main. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C1tX2n8xegVBsZqoJPu7iv --- .changeset/await-current-shared-settle.md | 5 ++ packages/router-core/src/load-client.ts | 14 ++-- .../tests/navigation-burst-settle.test.ts | 66 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 .changeset/await-current-shared-settle.md create mode 100644 packages/router-core/tests/navigation-burst-settle.test.ts diff --git a/.changeset/await-current-shared-settle.md b/.changeset/await-current-shared-settle.md new file mode 100644 index 0000000000..724e04a24a --- /dev/null +++ b/.changeset/await-current-shared-settle.md @@ -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. diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 8a4c85c0aa..2b5a32094a 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -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 { let current = router._tx while (current && current !== owner) { + owner = current await current[5 /* done */] - if (router._tx === current) { - return - } current = router._tx } } @@ -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] @@ -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( diff --git a/packages/router-core/tests/navigation-burst-settle.test.ts b/packages/router-core/tests/navigation-burst-settle.test.ts new file mode 100644 index 0000000000..3ea034aa5a --- /dev/null +++ b/packages/router-core/tests/navigation-burst-settle.test.ts @@ -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() + } + }) +})