Skip to content

Commit 916253a

Browse files
refactor(router-core): Reduce navigation work by making some properties non-reactive (#4916)
Some of the properties of a route match in the store do not need to be reactive (i.e. reactive = trigger the execution of all subscribers when they change). This PR proposes we add a `_nonReactive` key for storing these properties. Changes to properties in `_nonReactive` can be made like on a regular object, without the need to call `__store.setState`, and without making a shallow copy of the object. The following properties were moved to `_nonReactive` (but more would be welcome if we can find which ones): - `beforeLoadPromise` - `loaderPromise` - `pendingTimeout` - `loadPromise` - `displayPendingPromise` - `minPendingPromise` - `dehydrated` This results in the removal of 4 `updateMatch` calls in `router-core`. At least 1 of which is in the hot-path of a navigation (`loadMatches` > `validResolvedMatches.forEach`). And the removal of 2 `updateMatch` calls in `packages/react-router/src/Match.tsx` (and solid-router too). The `ssr` property was also updated to not be reactive, but kept outside of `_nonReactive` because it is part of the public API. This removes another call to `updateMatch`. This key is an exception, and if in the future we find a need to have more "public but non-reactive" properties, we could consider - hiding internal props in a separate type, or - adding a dev proxy to ensure non-reactive props aren't accessed inside `select` functions --- This PR adds a unit-test in `react-router` that counts how many times the `select` inside a `useRouterState` hook is called during a navigation. Without this PR, the result of this test is **26 calls**. With this PR, we're down to **19 calls**. --- Should be a partial improvement of #4359 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent fe43f1b commit 916253a

File tree

7 files changed

+247
-181
lines changed

7 files changed

+247
-181
lines changed

docs/router/framework/react/api/router/RouteMatchType.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ interface RouteMatch {
1818
paramsError: unknown
1919
searchError: unknown
2020
updatedAt: number
21-
loadPromise?: Promise<void>
2221
loaderData?: Route['loaderData']
2322
context: Route['allContext']
2423
search: Route['fullSearchSchema']

packages/react-router/src/Match.tsx

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
getLocationChangeInfo,
77
isNotFound,
88
isRedirect,
9-
pick,
109
rootRouteId,
1110
} from '@tanstack/router-core'
1211
import { CatchBoundary, ErrorComponent } from './CatchBoundary'
@@ -37,7 +36,11 @@ export const Match = React.memo(function MatchImpl({
3736
match,
3837
`Could not find match for matchId "${matchId}". Please file an issue!`,
3938
)
40-
return pick(match, ['routeId', 'ssr', '_displayPending'])
39+
return {
40+
routeId: match.routeId,
41+
ssr: match.ssr,
42+
_displayPending: match._displayPending,
43+
}
4144
},
4245
structuralSharing: true as any,
4346
})
@@ -204,13 +207,13 @@ export const MatchInner = React.memo(function MatchInnerImpl({
204207
return {
205208
key,
206209
routeId,
207-
match: pick(match, [
208-
'id',
209-
'status',
210-
'error',
211-
'_forcePending',
212-
'_displayPending',
213-
]),
210+
match: {
211+
id: match.id,
212+
status: match.status,
213+
error: match.error,
214+
_forcePending: match._forcePending,
215+
_displayPending: match._displayPending,
216+
},
214217
}
215218
},
216219
structuralSharing: true as any,
@@ -227,43 +230,38 @@ export const MatchInner = React.memo(function MatchInnerImpl({
227230
}, [key, route.options.component, router.options.defaultComponent])
228231

229232
if (match._displayPending) {
230-
throw router.getMatch(match.id)?.displayPendingPromise
233+
throw router.getMatch(match.id)?._nonReactive.displayPendingPromise
231234
}
232235

233236
if (match._forcePending) {
234-
throw router.getMatch(match.id)?.minPendingPromise
237+
throw router.getMatch(match.id)?._nonReactive.minPendingPromise
235238
}
236239

237240
// see also hydrate() in packages/router-core/src/ssr/ssr-client.ts
238241
if (match.status === 'pending') {
239242
// We're pending, and if we have a minPendingMs, we need to wait for it
240243
const pendingMinMs =
241244
route.options.pendingMinMs ?? router.options.defaultPendingMinMs
245+
if (pendingMinMs) {
246+
const routerMatch = router.getMatch(match.id)
247+
if (routerMatch && !routerMatch._nonReactive.minPendingPromise) {
248+
// Create a promise that will resolve after the minPendingMs
249+
if (!router.isServer) {
250+
const minPendingPromise = createControlledPromise<void>()
251+
252+
Promise.resolve().then(() => {
253+
routerMatch._nonReactive.minPendingPromise = minPendingPromise
254+
})
242255

243-
if (pendingMinMs && !router.getMatch(match.id)?.minPendingPromise) {
244-
// Create a promise that will resolve after the minPendingMs
245-
if (!router.isServer) {
246-
const minPendingPromise = createControlledPromise<void>()
247-
248-
Promise.resolve().then(() => {
249-
router.updateMatch(match.id, (prev) => ({
250-
...prev,
251-
minPendingPromise,
252-
}))
253-
})
254-
255-
setTimeout(() => {
256-
minPendingPromise.resolve()
257-
258-
// We've handled the minPendingPromise, so we can delete it
259-
router.updateMatch(match.id, (prev) => ({
260-
...prev,
261-
minPendingPromise: undefined,
262-
}))
263-
}, pendingMinMs)
256+
setTimeout(() => {
257+
minPendingPromise.resolve()
258+
// We've handled the minPendingPromise, so we can delete it
259+
routerMatch._nonReactive.minPendingPromise = undefined
260+
}, pendingMinMs)
261+
}
264262
}
265263
}
266-
throw router.getMatch(match.id)?.loadPromise
264+
throw router.getMatch(match.id)?._nonReactive.loadPromise
267265
}
268266

269267
if (match.status === 'notFound') {
@@ -280,7 +278,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
280278
// false,
281279
// 'Tried to render a redirected route match! This is a weird circumstance, please file an issue!',
282280
// )
283-
throw router.getMatch(match.id)?.loadPromise
281+
throw router.getMatch(match.id)?._nonReactive.loadPromise
284282
}
285283

286284
if (match.status === 'error') {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
3+
import {
4+
Link,
5+
Outlet,
6+
RouterProvider,
7+
createRootRoute,
8+
createRoute,
9+
createRouter,
10+
useRouterState,
11+
} from '../src'
12+
import type { RouteComponent } from '../src'
13+
14+
afterEach(() => {
15+
window.history.replaceState(null, 'root', '/')
16+
cleanup()
17+
})
18+
19+
function setup({ RootComponent }: { RootComponent: RouteComponent }) {
20+
const rootRoute = createRootRoute({
21+
component: RootComponent,
22+
})
23+
const indexRoute = createRoute({
24+
getParentRoute: () => rootRoute,
25+
path: '/',
26+
component: () => (
27+
<>
28+
<h1>IndexTitle</h1>
29+
<Link to="/posts">Posts</Link>
30+
</>
31+
),
32+
})
33+
34+
const postsRoute = createRoute({
35+
getParentRoute: () => rootRoute,
36+
path: '/posts',
37+
beforeLoad: () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
38+
loader: () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
39+
component: () => <h1>PostsTitle</h1>,
40+
})
41+
42+
const router = createRouter({
43+
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
44+
defaultPendingMs: 100,
45+
defaultPendingMinMs: 300,
46+
defaultPendingComponent: () => <p>Loading...</p>,
47+
})
48+
49+
return render(<RouterProvider router={router} />)
50+
}
51+
52+
describe('Store updates during navigation', () => {
53+
it("isn't called *too many* times", async () => {
54+
const select = vi.fn()
55+
56+
setup({
57+
RootComponent: () => {
58+
useRouterState({ select })
59+
return <Outlet />
60+
},
61+
})
62+
63+
// navigate to /posts
64+
const link = await waitFor(() =>
65+
screen.getByRole('link', { name: 'Posts' }),
66+
)
67+
const before = select.mock.calls.length
68+
act(() => link.click())
69+
const title = await waitFor(() => screen.getByText('PostsTitle'))
70+
expect(title).toBeInTheDocument()
71+
const after = select.mock.calls.length
72+
73+
// This number should be as small as possible to minimize the amount of work
74+
// that needs to be done during a navigation.
75+
// Any change that increases this number should be investigated.
76+
expect(after - before).toBe(19)
77+
})
78+
})

packages/router-core/src/Matches.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,18 @@ export interface RouteMatch<
136136
paramsError: unknown
137137
searchError: unknown
138138
updatedAt: number
139-
loadPromise?: ControlledPromise<void>
140-
/** @internal */
141-
beforeLoadPromise?: ControlledPromise<void>
142-
/** @internal */
143-
loaderPromise?: ControlledPromise<void>
139+
_nonReactive: {
140+
/** @internal */
141+
beforeLoadPromise?: ControlledPromise<void>
142+
/** @internal */
143+
loaderPromise?: ControlledPromise<void>
144+
/** @internal */
145+
pendingTimeout?: ReturnType<typeof setTimeout>
146+
loadPromise?: ControlledPromise<void>
147+
displayPendingPromise?: Promise<void>
148+
minPendingPromise?: ControlledPromise<void>
149+
dehydrated?: boolean
150+
}
144151
loaderData?: TLoaderData
145152
/** @internal */
146153
__routeContext: Record<string, unknown>
@@ -158,12 +165,9 @@ export interface RouteMatch<
158165
headers?: Record<string, string>
159166
globalNotFound?: boolean
160167
staticData: StaticDataRouteOption
161-
minPendingPromise?: ControlledPromise<void>
162-
pendingTimeout?: ReturnType<typeof setTimeout>
168+
/** This attribute is not reactive */
163169
ssr?: boolean | 'data-only'
164-
_dehydrated?: boolean
165170
_forcePending?: boolean
166-
displayPendingPromise?: Promise<void>
167171
_displayPending?: boolean
168172
}
169173

0 commit comments

Comments
 (0)