Skip to content

Commit 405ef6b

Browse files
authored
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 4dd0cbe commit 405ef6b

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
@@ -2318,11 +2318,9 @@ export class RouterCore<
23182318
const match = this.getMatch(matchId)!
23192319
if (shouldPending && match._nonReactive.pendingTimeout === undefined) {
23202320
const pendingTimeout = setTimeout(() => {
2321-
try {
2322-
// Update the match and prematurely resolve the loadMatches promise so that
2323-
// the pending component can start rendering
2324-
this.triggerOnReady(innerLoadContext)
2325-
} catch {}
2321+
// Update the match and prematurely resolve the loadMatches promise so that
2322+
// the pending component can start rendering
2323+
this.triggerOnReady(innerLoadContext)
23262324
}, pendingMs)
23272325
match._nonReactive.pendingTimeout = pendingTimeout
23282326
}
@@ -2371,131 +2369,150 @@ export class RouterCore<
23712369
index: number,
23722370
route: AnyRoute,
23732371
): void | Promise<void> => {
2374-
const resolve = () => {
2375-
innerLoadContext.updateMatch(matchId, (prev) => {
2376-
prev._nonReactive.beforeLoadPromise?.resolve()
2377-
prev._nonReactive.beforeLoadPromise = undefined
2372+
const match = this.getMatch(matchId)!
23782373

2379-
return {
2380-
...prev,
2381-
isFetching: false,
2382-
}
2383-
})
2384-
}
2374+
match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
2375+
// explicitly capture the previous loadPromise
2376+
const prevLoadPromise = match._nonReactive.loadPromise
2377+
match._nonReactive.loadPromise = createControlledPromise<void>(() => {
2378+
prevLoadPromise?.resolve()
2379+
})
23852380

2386-
try {
2387-
const match = this.getMatch(matchId)!
2388-
match._nonReactive.beforeLoadPromise = createControlledPromise<void>()
2389-
// explicitly capture the previous loadPromise
2390-
const prevLoadPromise = match._nonReactive.loadPromise
2391-
match._nonReactive.loadPromise = createControlledPromise<void>(() => {
2392-
prevLoadPromise?.resolve()
2393-
})
2381+
const { paramsError, searchError } = match
23942382

2395-
const { paramsError, searchError } = this.getMatch(matchId)!
2383+
if (paramsError) {
2384+
this.handleSerialError(
2385+
innerLoadContext,
2386+
index,
2387+
paramsError,
2388+
'PARSE_PARAMS',
2389+
)
2390+
}
23962391

2397-
if (paramsError) {
2398-
this.handleSerialError(
2399-
innerLoadContext,
2400-
index,
2401-
paramsError,
2402-
'PARSE_PARAMS',
2403-
)
2404-
}
2392+
if (searchError) {
2393+
this.handleSerialError(
2394+
innerLoadContext,
2395+
index,
2396+
searchError,
2397+
'VALIDATE_SEARCH',
2398+
)
2399+
}
24052400

2406-
if (searchError) {
2407-
this.handleSerialError(
2408-
innerLoadContext,
2409-
index,
2410-
searchError,
2411-
'VALIDATE_SEARCH',
2412-
)
2413-
}
2401+
this.setupPendingTimeout(innerLoadContext, matchId, route)
24142402

2415-
this.setupPendingTimeout(innerLoadContext, matchId, route)
2403+
const abortController = new AbortController()
24162404

2417-
const abortController = new AbortController()
2405+
const parentMatchId = innerLoadContext.matches[index - 1]?.id
2406+
const parentMatch = parentMatchId
2407+
? this.getMatch(parentMatchId)!
2408+
: undefined
2409+
const parentMatchContext =
2410+
parentMatch?.context ?? this.options.context ?? undefined
24182411

2419-
const parentMatchId = innerLoadContext.matches[index - 1]?.id
2420-
const parentMatch = parentMatchId
2421-
? this.getMatch(parentMatchId)!
2422-
: undefined
2423-
const parentMatchContext =
2424-
parentMatch?.context ?? this.options.context ?? undefined
2412+
const context = { ...parentMatchContext, ...match.__routeContext }
24252413

2414+
let isPending = false
2415+
const pending = () => {
2416+
if (isPending) return
2417+
isPending = true
24262418
innerLoadContext.updateMatch(matchId, (prev) => ({
24272419
...prev,
24282420
isFetching: 'beforeLoad',
24292421
fetchCount: prev.fetchCount + 1,
24302422
abortController,
2431-
context: {
2432-
...parentMatchContext,
2433-
...prev.__routeContext,
2434-
},
2423+
context,
2424+
}))
2425+
}
2426+
2427+
const resolve = () => {
2428+
match._nonReactive.beforeLoadPromise?.resolve()
2429+
match._nonReactive.beforeLoadPromise = undefined
2430+
innerLoadContext.updateMatch(matchId, (prev) => ({
2431+
...prev,
2432+
isFetching: false,
24352433
}))
2434+
}
24362435

2437-
const { search, params, context, cause } = this.getMatch(matchId)!
2436+
// if there is no `beforeLoad` option, skip everything, batch update the store, return early
2437+
if (!route.options.beforeLoad) {
2438+
batch(() => {
2439+
pending()
2440+
resolve()
2441+
})
2442+
return
2443+
}
24382444

2439-
const preload = this.resolvePreload(innerLoadContext, matchId)
2445+
const { search, params, cause } = match
2446+
const preload = this.resolvePreload(innerLoadContext, matchId)
2447+
const beforeLoadFnContext: BeforeLoadContextOptions<
2448+
any,
2449+
any,
2450+
any,
2451+
any,
2452+
any
2453+
> = {
2454+
search,
2455+
abortController,
2456+
params,
2457+
preload,
2458+
context,
2459+
location: innerLoadContext.location,
2460+
navigate: (opts: any) =>
2461+
this.navigate({ ...opts, _fromLocation: innerLoadContext.location }),
2462+
buildLocation: this.buildLocation,
2463+
cause: preload ? 'preload' : cause,
2464+
matches: innerLoadContext.matches,
2465+
}
24402466

2441-
const beforeLoadFnContext: BeforeLoadContextOptions<
2442-
any,
2443-
any,
2444-
any,
2445-
any,
2446-
any
2447-
> = {
2448-
search,
2449-
abortController,
2450-
params,
2451-
preload,
2452-
context,
2453-
location: innerLoadContext.location,
2454-
navigate: (opts: any) =>
2455-
this.navigate({ ...opts, _fromLocation: innerLoadContext.location }),
2456-
buildLocation: this.buildLocation,
2457-
cause: preload ? 'preload' : cause,
2458-
matches: innerLoadContext.matches,
2467+
const updateContext = (beforeLoadContext: any) => {
2468+
if (beforeLoadContext === undefined) {
2469+
batch(() => {
2470+
pending()
2471+
resolve()
2472+
})
2473+
return
2474+
}
2475+
if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
2476+
pending()
2477+
this.handleSerialError(
2478+
innerLoadContext,
2479+
index,
2480+
beforeLoadContext,
2481+
'BEFORE_LOAD',
2482+
)
24592483
}
24602484

2461-
const updateContext = (beforeLoadContext: any) => {
2462-
if (isRedirect(beforeLoadContext) || isNotFound(beforeLoadContext)) {
2463-
this.handleSerialError(
2464-
innerLoadContext,
2465-
index,
2466-
beforeLoadContext,
2467-
'BEFORE_LOAD',
2468-
)
2469-
}
2470-
2485+
batch(() => {
2486+
pending()
24712487
innerLoadContext.updateMatch(matchId, (prev) => ({
24722488
...prev,
24732489
__beforeLoadContext: beforeLoadContext,
24742490
context: {
2475-
...parentMatchContext,
2476-
...prev.__routeContext,
2491+
...prev.context,
24772492
...beforeLoadContext,
24782493
},
2479-
abortController,
24802494
}))
2481-
}
2495+
resolve()
2496+
})
2497+
}
24822498

2483-
const beforeLoadContext = route.options.beforeLoad?.(beforeLoadFnContext)
2499+
let beforeLoadContext
2500+
try {
2501+
beforeLoadContext = route.options.beforeLoad(beforeLoadFnContext)
24842502
if (isPromise(beforeLoadContext)) {
2503+
pending()
24852504
return beforeLoadContext
2486-
.then(updateContext)
24872505
.catch((err) => {
24882506
this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
24892507
})
2490-
.then(resolve)
2491-
} else {
2492-
updateContext(beforeLoadContext)
2508+
.then(updateContext)
24932509
}
24942510
} catch (err) {
2511+
pending()
24952512
this.handleSerialError(innerLoadContext, index, err, 'BEFORE_LOAD')
24962513
}
24972514

2498-
resolve()
2515+
updateContext(beforeLoadContext)
24992516
return
25002517
}
25012518

@@ -2709,7 +2726,8 @@ export class RouterCore<
27092726
} catch (e) {
27102727
let error = e
27112728

2712-
await this.potentialPendingMinPromise(matchId)
2729+
const pendingPromise = this.potentialPendingMinPromise(matchId)
2730+
if (pendingPromise) await pendingPromise
27132731

27142732
this.handleRedirectAndNotFound(
27152733
innerLoadContext,

0 commit comments

Comments
 (0)