Skip to content

refactor(router-core): Reduce navigation work by making some properties non-reactive #4916

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Aug 11, 2025
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
1 change: 0 additions & 1 deletion docs/router/framework/react/api/router/RouteMatchType.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ interface RouteMatch {
paramsError: unknown
searchError: unknown
updatedAt: number
loadPromise?: Promise<void>
loaderData?: Route['loaderData']
context: Route['allContext']
search: Route['fullSearchSchema']
Expand Down
66 changes: 32 additions & 34 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
getLocationChangeInfo,
isNotFound,
isRedirect,
pick,
rootRouteId,
} from '@tanstack/router-core'
import { CatchBoundary, ErrorComponent } from './CatchBoundary'
Expand Down Expand Up @@ -37,7 +36,11 @@ export const Match = React.memo(function MatchImpl({
match,
`Could not find match for matchId "${matchId}". Please file an issue!`,
)
return pick(match, ['routeId', 'ssr', '_displayPending'])
return {
routeId: match.routeId,
ssr: match.ssr,
_displayPending: match._displayPending,
}
},
structuralSharing: true as any,
})
Expand Down Expand Up @@ -204,13 +207,13 @@ export const MatchInner = React.memo(function MatchInnerImpl({
return {
key,
routeId,
match: pick(match, [
'id',
'status',
'error',
'_forcePending',
'_displayPending',
]),
match: {
id: match.id,
status: match.status,
error: match.error,
_forcePending: match._forcePending,
_displayPending: match._displayPending,
},
}
},
structuralSharing: true as any,
Expand All @@ -227,43 +230,38 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}, [key, route.options.component, router.options.defaultComponent])

if (match._displayPending) {
throw router.getMatch(match.id)?.displayPendingPromise
throw router.getMatch(match.id)?._nonReactive.displayPendingPromise
}

if (match._forcePending) {
throw router.getMatch(match.id)?.minPendingPromise
throw router.getMatch(match.id)?._nonReactive.minPendingPromise
}

// see also hydrate() in packages/router-core/src/ssr/ssr-client.ts
if (match.status === 'pending') {
// We're pending, and if we have a minPendingMs, we need to wait for it
const pendingMinMs =
route.options.pendingMinMs ?? router.options.defaultPendingMinMs
if (pendingMinMs) {
const routerMatch = router.getMatch(match.id)
if (routerMatch && !routerMatch._nonReactive.minPendingPromise) {
// Create a promise that will resolve after the minPendingMs
if (!router.isServer) {
const minPendingPromise = createControlledPromise<void>()

Promise.resolve().then(() => {
routerMatch._nonReactive.minPendingPromise = minPendingPromise
})

if (pendingMinMs && !router.getMatch(match.id)?.minPendingPromise) {
// Create a promise that will resolve after the minPendingMs
if (!router.isServer) {
const minPendingPromise = createControlledPromise<void>()

Promise.resolve().then(() => {
router.updateMatch(match.id, (prev) => ({
...prev,
minPendingPromise,
}))
})

setTimeout(() => {
minPendingPromise.resolve()

// We've handled the minPendingPromise, so we can delete it
router.updateMatch(match.id, (prev) => ({
...prev,
minPendingPromise: undefined,
}))
}, pendingMinMs)
setTimeout(() => {
minPendingPromise.resolve()
// We've handled the minPendingPromise, so we can delete it
routerMatch._nonReactive.minPendingPromise = undefined
}, pendingMinMs)
}
}
}
throw router.getMatch(match.id)?.loadPromise
throw router.getMatch(match.id)?._nonReactive.loadPromise
}

if (match.status === 'notFound') {
Expand All @@ -280,7 +278,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)?.loadPromise
throw router.getMatch(match.id)?._nonReactive.loadPromise
}

if (match.status === 'error') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import {
Link,
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
useRouterState,
} from '../src'
import type { RouteComponent } from '../src'

afterEach(() => {
window.history.replaceState(null, 'root', '/')
cleanup()
})

function setup({ RootComponent }: { RootComponent: RouteComponent }) {
const rootRoute = createRootRoute({
component: RootComponent,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>IndexTitle</h1>
<Link to="/posts">Posts</Link>
</>
),
})

const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/posts',
beforeLoad: () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
loader: () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
component: () => <h1>PostsTitle</h1>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
defaultPendingMs: 100,
defaultPendingMinMs: 300,
defaultPendingComponent: () => <p>Loading...</p>,
})

return render(<RouterProvider router={router} />)
}

describe('Store updates during navigation', () => {
it("isn't called *too many* times", async () => {
const select = vi.fn()

setup({
RootComponent: () => {
useRouterState({ select })
return <Outlet />
},
})

// navigate to /posts
const link = await waitFor(() =>
screen.getByRole('link', { name: 'Posts' }),
)
const before = select.mock.calls.length
act(() => link.click())
const title = await waitFor(() => screen.getByText('PostsTitle'))
expect(title).toBeInTheDocument()
const after = select.mock.calls.length

// 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(after - before).toBe(19)
})
})
22 changes: 13 additions & 9 deletions packages/router-core/src/Matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,18 @@ export interface RouteMatch<
paramsError: unknown
searchError: unknown
updatedAt: number
loadPromise?: ControlledPromise<void>
/** @internal */
beforeLoadPromise?: ControlledPromise<void>
/** @internal */
loaderPromise?: ControlledPromise<void>
_nonReactive: {
/** @internal */
beforeLoadPromise?: ControlledPromise<void>
/** @internal */
loaderPromise?: ControlledPromise<void>
/** @internal */
pendingTimeout?: ReturnType<typeof setTimeout>
loadPromise?: ControlledPromise<void>
displayPendingPromise?: Promise<void>
minPendingPromise?: ControlledPromise<void>
dehydrated?: boolean
}
loaderData?: TLoaderData
/** @internal */
__routeContext: Record<string, unknown>
Expand All @@ -158,12 +165,9 @@ export interface RouteMatch<
headers?: Record<string, string>
globalNotFound?: boolean
staticData: StaticDataRouteOption
minPendingPromise?: ControlledPromise<void>
pendingTimeout?: ReturnType<typeof setTimeout>
/** This attribute is not reactive */
ssr?: boolean | 'data-only'
_dehydrated?: boolean
_forcePending?: boolean
displayPendingPromise?: Promise<void>
_displayPending?: boolean
}

Expand Down
Loading