Skip to content

Commit 30aae7d

Browse files
Sherafftannerlinsley
authored andcommitted
refactor(router-core): skip beforeLoad and related store updates if options is not defined (#4928)
Following #4916, this PR keeps reducing the number of store updates (`__store.setState` through `updateMatch`). Early bail out of route before load step if `route.options.beforeLoad` is not defined --- The `store-updates-during-navigation` test tracking the number of executions of a `useRouterState > select` method during a navigation goes from **17 calls** without this PR, to **14 calls** with this PR (or even 13 calls if `beforeLoad` is synchronous). --- Should be a partial improvement of #4359 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a not-found helper and router options to configure a default Not Found component and a default preload strategy. * **Refactor** * Streamlined the before-load lifecycle for more deterministic pending-state updates, earlier pending-timeout readiness, clearer parameter/search error surfacing, and adjusted context composition; loader error timing is more conservative. * **Tests** * Expanded test coverage with new scenarios (sync before-load, not-found flow, hover-preload/navigation) and updated navigation update expectations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 7563165 commit 30aae7d

File tree

2 files changed

+195
-100
lines changed

2 files changed

+195
-100
lines changed

packages/react-router/tests/store-updates-during-navigation.test.tsx

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { afterEach, describe, expect, test, vi } from 'vitest'
2-
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
2+
import {
3+
cleanup,
4+
fireEvent,
5+
render,
6+
screen,
7+
waitFor,
8+
} from '@testing-library/react'
39
import {
410
Link,
511
Outlet,
612
RouterProvider,
713
createRootRoute,
814
createRoute,
915
createRouter,
16+
notFound,
1017
redirect,
1118
useRouterState,
1219
} from '../src'
@@ -74,21 +81,23 @@ function setup({
7481
defaultPendingMs,
7582
defaultPendingMinMs,
7683
defaultPendingComponent: () => <p>Loading...</p>,
84+
defaultNotFoundComponent: () => <h1>Not Found Title</h1>,
85+
defaultPreload: 'intent',
7786
})
7887

7988
render(<RouterProvider router={router} />)
8089

8190
return { select, router }
8291
}
8392

84-
async function run({ select }: ReturnType<typeof setup>, opts?: {}) {
93+
async function run({ select }: ReturnType<typeof setup>) {
8594
// navigate to /posts
8695
const link = await waitFor(() => screen.getByRole('link', { name: 'Posts' }))
8796
const before = select.mock.calls.length
88-
act(() => link.click())
89-
const title = await waitFor(() =>
90-
screen.getByRole('heading', { name: /Title$/ }),
91-
) // matches /posts and /other
97+
fireEvent.click(link)
98+
const title = await waitFor(
99+
() => screen.getByRole('heading', { name: /Title$/ }), // matches /posts and /other and not found
100+
)
92101
expect(title).toBeInTheDocument()
93102
const after = select.mock.calls.length
94103

@@ -110,7 +119,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
110119
// This number should be as small as possible to minimize the amount of work
111120
// that needs to be done during a navigation.
112121
// Any change that increases this number should be investigated.
113-
expect(updates).toBe(17)
122+
expect(updates).toBe(14)
114123
})
115124

116125
test('redirection in preload', async () => {
@@ -125,9 +134,77 @@ describe("Store doesn't update *too many* times during navigation", () => {
125134
const after = select.mock.calls.length
126135
const updates = after - before
127136

137+
// This number should be as small as possible to minimize the amount of work
138+
// that needs to be done during a navigation.
139+
// Any change that increases this number should be investigated.
140+
expect(updates).toBe(6)
141+
})
142+
143+
test('sync beforeLoad', async () => {
144+
const params = setup({
145+
beforeLoad: () => ({ foo: 'bar' }),
146+
loader: () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
147+
defaultPendingMs: 100,
148+
defaultPendingMinMs: 300,
149+
})
150+
151+
const updates = await run(params)
152+
153+
// This number should be as small as possible to minimize the amount of work
154+
// that needs to be done during a navigation.
155+
// Any change that increases this number should be investigated.
156+
expect(updates).toBe(13)
157+
})
158+
159+
test('nothing', async () => {
160+
const params = setup({})
161+
162+
const updates = await run(params)
163+
164+
// This number should be as small as possible to minimize the amount of work
165+
// that needs to be done during a navigation.
166+
// Any change that increases this number should be investigated.
167+
expect(updates).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 11
168+
expect(updates).toBeLessThanOrEqual(11)
169+
})
170+
171+
test('not found in beforeLoad', async () => {
172+
const params = setup({
173+
beforeLoad: () => {
174+
throw notFound()
175+
},
176+
})
177+
178+
const updates = await run(params)
179+
128180
// This number should be as small as possible to minimize the amount of work
129181
// that needs to be done during a navigation.
130182
// Any change that increases this number should be investigated.
131183
expect(updates).toBe(8)
132184
})
185+
186+
test('hover preload, then navigate, w/ async loaders', async () => {
187+
const { select } = setup({
188+
beforeLoad: () => Promise.resolve({ foo: 'bar' }),
189+
loader: () => Promise.resolve({ hello: 'world' }),
190+
})
191+
192+
const link = await waitFor(() =>
193+
screen.getByRole('link', { name: 'Posts' }),
194+
)
195+
const before = select.mock.calls.length
196+
fireEvent.focus(link)
197+
fireEvent.click(link)
198+
const title = await waitFor(() =>
199+
screen.getByRole('heading', { name: /Title$/ }),
200+
)
201+
expect(title).toBeInTheDocument()
202+
const after = select.mock.calls.length
203+
const updates = after - before
204+
205+
// This number should be as small as possible to minimize the amount of work
206+
// that needs to be done during a navigation.
207+
// Any change that increases this number should be investigated.
208+
expect(updates).toBe(19)
209+
})
133210
})

packages/router-core/src/router.ts

Lines changed: 111 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -2311,11 +2311,9 @@ export class RouterCore<
23112311
const match = this.getMatch(matchId)!
23122312
if (shouldPending && match._nonReactive.pendingTimeout === undefined) {
23132313
const pendingTimeout = setTimeout(() => {
2314-
try {
2315-
// Update the match and prematurely resolve the loadMatches promise so that
2316-
// the pending component can start rendering
2317-
this.triggerOnReady(innerLoadContext)
2318-
} catch {}
2314+
// Update the match and prematurely resolve the loadMatches promise so that
2315+
// the pending component can start rendering
2316+
this.triggerOnReady(innerLoadContext)
23192317
}, pendingMs)
23202318
match._nonReactive.pendingTimeout = pendingTimeout
23212319
}
@@ -2364,131 +2362,150 @@ export class RouterCore<
23642362
index: number,
23652363
route: AnyRoute,
23662364
): void | Promise<void> => {
2367-
const resolve = () => {
2368-
innerLoadContext.updateMatch(matchId, (prev) => {
2369-
prev._nonReactive.beforeLoadPromise?.resolve()
2370-
prev._nonReactive.beforeLoadPromise = undefined
2365+
const match = this.getMatch(matchId)!
23712366

2372-
return {
2373-
...prev,
2374-
isFetching: false,
2375-
}
2376-
})
2377-
}
2367+
match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
2368+
// explicitly capture the previous loadPromise
2369+
const prevLoadPromise = match._nonReactive.loadPromise
2370+
match._nonReactive.loadPromise = createControlledPromise<void>(() => {
2371+
prevLoadPromise?.resolve()
2372+
})
23782373

2379-
try {
2380-
const match = this.getMatch(matchId)!
2381-
match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
2382-
// explicitly capture the previous loadPromise
2383-
const prevLoadPromise = match._nonReactive.loadPromise
2384-
match._nonReactive.loadPromise = createControlledPromise<void>(() => {
2385-
prevLoadPromise?.resolve()
2386-
})
2374+
const { paramsError, searchError } = match
23872375

2388-
const { paramsError, searchError } = this.getMatch(matchId)!
2376+
if (paramsError) {
2377+
this.handleSerialError(
2378+
innerLoadContext,
2379+
index,
2380+
paramsError,
2381+
'PARSE_PARAMS',
2382+
)
2383+
}
23892384

2390-
if (paramsError) {
2391-
this.handleSerialError(
2392-
innerLoadContext,
2393-
index,
2394-
paramsError,
2395-
'PARSE_PARAMS',
2396-
)
2397-
}
2385+
if (searchError) {
2386+
this.handleSerialError(
2387+
innerLoadContext,
2388+
index,
2389+
searchError,
2390+
'VALIDATE_SEARCH',
2391+
)
2392+
}
23982393

2399-
if (searchError) {
2400-
this.handleSerialError(
2401-
innerLoadContext,
2402-
index,
2403-
searchError,
2404-
'VALIDATE_SEARCH',
2405-
)
2406-
}
2394+
this.setupPendingTimeout(innerLoadContext, matchId, route)
24072395

2408-
this.setupPendingTimeout(innerLoadContext, matchId, route)
2396+
const abortController = new AbortController()
24092397

2410-
const abortController = new AbortController()
2398+
const parentMatchId = innerLoadContext.matches[index - 1]?.id
2399+
const parentMatch = parentMatchId
2400+
? this.getMatch(parentMatchId)!
2401+
: undefined
2402+
const parentMatchContext =
2403+
parentMatch?.context ?? this.options.context ?? undefined
24112404

2412-
const parentMatchId = innerLoadContext.matches[index - 1]?.id
2413-
const parentMatch = parentMatchId
2414-
? this.getMatch(parentMatchId)!
2415-
: undefined
2416-
const parentMatchContext =
2417-
parentMatch?.context ?? this.options.context ?? undefined
2405+
const context = { ...parentMatchContext, ...match.__routeContext }
24182406

2407+
let isPending = false
2408+
const pending = () => {
2409+
if (isPending) return
2410+
isPending = true
24192411
innerLoadContext.updateMatch(matchId, (prev) => ({
24202412
...prev,
24212413
isFetching: 'beforeLoad',
24222414
fetchCount: prev.fetchCount + 1,
24232415
abortController,
2424-
context: {
2425-
...parentMatchContext,
2426-
...prev.__routeContext,
2427-
},
2416+
context,
2417+
}))
2418+
}
2419+
2420+
const resolve = () => {
2421+
match._nonReactive.beforeLoadPromise?.resolve()
2422+
match._nonReactive.beforeLoadPromise = undefined
2423+
innerLoadContext.updateMatch(matchId, (prev) => ({
2424+
...prev,
2425+
isFetching: false,
24282426
}))
2427+
}
24292428

2430-
const { search, params, context, cause } = this.getMatch(matchId)!
2429+
// if there is no `beforeLoad` option, skip everything, batch update the store, return early
2430+
if (!route.options.beforeLoad) {
2431+
batch(() => {
2432+
pending()
2433+
resolve()
2434+
})
2435+
return
2436+
}
24312437

2432-
const preload = this.resolvePreload(innerLoadContext, matchId)
2438+
const { search, params, cause } = match
2439+
const preload = this.resolvePreload(innerLoadContext, matchId)
2440+
const beforeLoadFnContext: BeforeLoadContextOptions<
2441+
any,
2442+
any,
2443+
any,
2444+
any,
2445+
any
2446+
> = {
2447+
search,
2448+
abortController,
2449+
params,
2450+
preload,
2451+
context,
2452+
location: innerLoadContext.location,
2453+
navigate: (opts: any) =>
2454+
this.navigate({ ...opts, _fromLocation: innerLoadContext.location }),
2455+
buildLocation: this.buildLocation,
2456+
cause: preload ? 'preload' : cause,
2457+
matches: innerLoadContext.matches,
2458+
}
24332459

2434-
const beforeLoadFnContext: BeforeLoadContextOptions<
2435-
any,
2436-
any,
2437-
any,
2438-
any,
2439-
any
2440-
> = {
2441-
search,
2442-
abortController,
2443-
params,
2444-
preload,
2445-
context,
2446-
location: innerLoadContext.location,
2447-
navigate: (opts: any) =>
2448-
this.navigate({ ...opts, _fromLocation: innerLoadContext.location }),
2449-
buildLocation: this.buildLocation,
2450-
cause: preload ? 'preload' : cause,
2451-
matches: innerLoadContext.matches,
2460+
const updateContext = (beforeLoadContext: any) => {
2461+
if (beforeLoadContext === undefined) {
2462+
batch(() => {
2463+
pending()
2464+
resolve()
2465+
})
2466+
return
2467+
}
2468+
if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
2469+
pending()
2470+
this.handleSerialError(
2471+
innerLoadContext,
2472+
index,
2473+
beforeLoadContext,
2474+
'BEFORE_LOAD',
2475+
)
24522476
}
24532477

2454-
const updateContext = (beforeLoadContext: any) => {
2455-
if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
2456-
this.handleSerialError(
2457-
innerLoadContext,
2458-
index,
2459-
beforeLoadContext,
2460-
'BEFORE_LOAD',
2461-
)
2462-
}
2463-
2478+
batch(() => {
2479+
pending()
24642480
innerLoadContext.updateMatch(matchId, (prev) => ({
24652481
...prev,
24662482
__beforeLoadContext: beforeLoadContext,
24672483
context: {
2468-
...parentMatchContext,
2469-
...prev.__routeContext,
2484+
...prev.context,
24702485
...beforeLoadContext,
24712486
},
2472-
abortController,
24732487
}))
2474-
}
2488+
resolve()
2489+
})
2490+
}
24752491

2476-
const beforeLoadContext = route.options.beforeLoad?.(beforeLoadFnContext)
2492+
let beforeLoadContext
2493+
try {
2494+
beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext)
24772495
if (isPromise(beforeLoadContext)) {
2496+
pending()
24782497
return beforeLoadContext
2479-
.then(updateContext)
24802498
.catch((err) => {
24812499
this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
24822500
})
2483-
.then(resolve)
2484-
} else {
2485-
updateContext(beforeLoadContext)
2501+
.then(updateContext)
24862502
}
24872503
} catch (err) {
2504+
pending()
24882505
this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
24892506
}
24902507

2491-
resolve()
2508+
updateContext(beforeLoadContext)
24922509
return
24932510
}
24942511

@@ -2702,7 +2719,8 @@ export class RouterCore<
27022719
} catch (e) {
27032720
let error = e
27042721

2705-
await this.potentialPendingMinPromise(matchId)
2722+
const pendingPromise = this.potentialPendingMinPromise(matchId)
2723+
if (pendingPromise) await pendingPromise
27062724

27072725
this.handleRedirectAndNotFound(
27082726
innerLoadContext,

0 commit comments

Comments
 (0)