Skip to content

Commit 6b124c6

Browse files
refactor(router-core): head, scripts, headers are executed in parallel and can skip store update (#4925)
Following #4916, this PR keeps reducing the number of store updates (`__store.setState` through `updateMatch`). We can skip `route.options.head()`, `route.options.scripts()`, and `route.options.headers()` calls, if they are not defined, and not even call `updateMatch` after. Additionally, `head`, `scripts` and `headers` are now called in parallel. They don't depend on one another, and so there is no reason to delay their execution by calling them serially. --- This PR also fixes 2 issues: - in `cancelMatch`, we were resetting the timeout id to `undefined` **before** calling `clearTimeout()` with it - `handleRedirectAndNotFound` is sometimes called w/ `match` being `undefined` (in case of a redirecting match during preload), which went undetected because of heavy `match!` assertions, and the many `try/catch` blocks --- With this PR, the `redirection in preload` test in `store-updates-during-navigation` goes from **11 updates** during the preload to **8 updates**. --- Should be a partial improvement of #4359 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 3c3497a commit 6b124c6

File tree

2 files changed

+110
-71
lines changed

2 files changed

+110
-71
lines changed

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ afterEach(() => {
1616
cleanup()
1717
})
1818

19-
async function setupAndRun({
19+
function setup({
2020
beforeLoad,
2121
loader,
2222
head,
@@ -78,6 +78,10 @@ async function setupAndRun({
7878

7979
render(<RouterProvider router={router} />)
8080

81+
return { select, router }
82+
}
83+
84+
async function run({ select }: ReturnType<typeof setup>, opts?: {}) {
8185
// navigate to /posts
8286
const link = await waitFor(() => screen.getByRole('link', { name: 'Posts' }))
8387
const before = select.mock.calls.length
@@ -93,30 +97,37 @@ async function setupAndRun({
9397

9498
describe("Store doesn't update *too many* times during navigation", () => {
9599
test('async loader, async beforeLoad, pendingMs', async () => {
96-
const updates = await setupAndRun({
100+
const params = setup({
97101
beforeLoad: () =>
98102
new Promise<void>((resolve) => setTimeout(resolve, 100)),
99103
loader: () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
100104
defaultPendingMs: 100,
101105
defaultPendingMinMs: 300,
102106
})
103107

108+
const updates = await run(params)
109+
104110
// This number should be as small as possible to minimize the amount of work
105111
// that needs to be done during a navigation.
106112
// Any change that increases this number should be investigated.
107113
expect(updates).toBe(19)
108114
})
109115

110-
test('redirection', async () => {
111-
const updates = await setupAndRun({
112-
beforeLoad: () => {
116+
test('redirection in preload', async () => {
117+
const { select, router } = setup({
118+
loader: () => {
113119
throw redirect({ to: '/other' })
114120
},
115121
})
116122

123+
const before = select.mock.calls.length
124+
await router.preloadRoute({ to: '/posts' })
125+
const after = select.mock.calls.length
126+
const updates = after - before
127+
117128
// This number should be as small as possible to minimize the amount of work
118129
// that needs to be done during a navigation.
119130
// Any change that increases this number should be investigated.
120-
expect(updates).toBe(26)
131+
expect(updates).toBe(8)
121132
})
122133
})

packages/router-core/src/router.ts

Lines changed: 93 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,8 +1393,8 @@ export class RouterCore<
13931393
if (!match) return
13941394

13951395
match.abortController.abort()
1396-
match._nonReactive.pendingTimeout = undefined
13971396
clearTimeout(match._nonReactive.pendingTimeout)
1397+
match._nonReactive.pendingTimeout = undefined
13981398
}
13991399

14001400
cancelMatches = () => {
@@ -2121,7 +2121,10 @@ export class RouterCore<
21212121
triggerOnReady()
21222122
}
21232123

2124-
const handleRedirectAndNotFound = (match: AnyRouteMatch, err: any) => {
2124+
const handleRedirectAndNotFound = (
2125+
match: AnyRouteMatch | undefined,
2126+
err: any,
2127+
) => {
21252128
if (isRedirect(err) || isNotFound(err)) {
21262129
if (isRedirect(err)) {
21272130
if (err.redirectHandled) {
@@ -2131,27 +2134,30 @@ export class RouterCore<
21312134
}
21322135
}
21332136

2134-
match._nonReactive.beforeLoadPromise?.resolve()
2135-
match._nonReactive.loaderPromise?.resolve()
2136-
match._nonReactive.beforeLoadPromise = undefined
2137-
match._nonReactive.loaderPromise = undefined
2138-
2139-
updateMatch(match.id, (prev) => ({
2140-
...prev,
2141-
status: isRedirect(err)
2142-
? 'redirected'
2143-
: isNotFound(err)
2144-
? 'notFound'
2145-
: 'error',
2146-
isFetching: false,
2147-
error: err,
2148-
}))
2137+
// in case of a redirecting match during preload, the match does not exist
2138+
if (match) {
2139+
match._nonReactive.beforeLoadPromise?.resolve()
2140+
match._nonReactive.loaderPromise?.resolve()
2141+
match._nonReactive.beforeLoadPromise = undefined
2142+
match._nonReactive.loaderPromise = undefined
2143+
2144+
updateMatch(match.id, (prev) => ({
2145+
...prev,
2146+
status: isRedirect(err)
2147+
? 'redirected'
2148+
: isNotFound(err)
2149+
? 'notFound'
2150+
: 'error',
2151+
isFetching: false,
2152+
error: err,
2153+
}))
21492154

2150-
if (!(err as any).routeId) {
2151-
;(err as any).routeId = match.routeId
2152-
}
2155+
if (!(err as any).routeId) {
2156+
;(err as any).routeId = match.routeId
2157+
}
21532158

2154-
match._nonReactive.loadPromise?.resolve()
2159+
match._nonReactive.loadPromise?.resolve()
2160+
}
21552161

21562162
if (isRedirect(err)) {
21572163
rendered = true
@@ -2204,13 +2210,13 @@ export class RouterCore<
22042210

22052211
err.routerCode = routerCode
22062212
firstBadMatchIndex = firstBadMatchIndex ?? index
2207-
handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2213+
handleRedirectAndNotFound(this.getMatch(matchId), err)
22082214

22092215
try {
22102216
route.options.onError?.(err)
22112217
} catch (errorHandlerErr) {
22122218
err = errorHandlerErr
2213-
handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2219+
handleRedirectAndNotFound(this.getMatch(matchId), err)
22142220
}
22152221

22162222
updateMatch(matchId, (prev) => {
@@ -2465,35 +2471,45 @@ export class RouterCore<
24652471
let loaderIsRunningAsync = false
24662472
const route = this.looseRoutesById[routeId]!
24672473

2468-
const executeHead = async () => {
2474+
const executeHead = () => {
24692475
const match = this.getMatch(matchId)
24702476
// in case of a redirecting match during preload, the match does not exist
24712477
if (!match) {
24722478
return
24732479
}
2480+
if (
2481+
!route.options.head &&
2482+
!route.options.scripts &&
2483+
!route.options.headers
2484+
) {
2485+
return
2486+
}
24742487
const assetContext = {
24752488
matches,
24762489
match,
24772490
params: match.params,
24782491
loaderData: match.loaderData,
24792492
}
2480-
const headFnContent =
2481-
await route.options.head?.(assetContext)
2482-
const meta = headFnContent?.meta
2483-
const links = headFnContent?.links
2484-
const headScripts = headFnContent?.scripts
2485-
const styles = headFnContent?.styles
2486-
2487-
const scripts = await route.options.scripts?.(assetContext)
2488-
const headers = await route.options.headers?.(assetContext)
2489-
return {
2490-
meta,
2491-
links,
2492-
headScripts,
2493-
headers,
2494-
scripts,
2495-
styles,
2496-
}
2493+
2494+
return Promise.all([
2495+
route.options.head?.(assetContext),
2496+
route.options.scripts?.(assetContext),
2497+
route.options.headers?.(assetContext),
2498+
]).then(([headFnContent, scripts, headers]) => {
2499+
const meta = headFnContent?.meta
2500+
const links = headFnContent?.links
2501+
const headScripts = headFnContent?.scripts
2502+
const styles = headFnContent?.styles
2503+
2504+
return {
2505+
meta,
2506+
links,
2507+
headScripts,
2508+
headers,
2509+
scripts,
2510+
styles,
2511+
}
2512+
})
24972513
}
24982514

24992515
const potentialPendingMinPromise = async () => {
@@ -2506,11 +2522,14 @@ export class RouterCore<
25062522
const prevMatch = this.getMatch(matchId)!
25072523
if (shouldSkipLoader(matchId)) {
25082524
if (this.isServer) {
2509-
const head = await executeHead()
2510-
updateMatch(matchId, (prev) => ({
2511-
...prev,
2512-
...head,
2513-
}))
2525+
const headResult = executeHead()
2526+
if (headResult) {
2527+
const head = await headResult
2528+
updateMatch(matchId, (prev) => ({
2529+
...prev,
2530+
...head,
2531+
}))
2532+
}
25142533
return this.getMatch(matchId)!
25152534
}
25162535
}
@@ -2622,7 +2641,7 @@ export class RouterCore<
26222641
await route.options.loader?.(getLoaderContext())
26232642

26242643
handleRedirectAndNotFound(
2625-
this.getMatch(matchId)!,
2644+
this.getMatch(matchId),
26262645
loaderData,
26272646
)
26282647
updateMatch(matchId, (prev) => ({
@@ -2634,7 +2653,8 @@ export class RouterCore<
26342653
// so we need to wait for it to resolve before
26352654
// we can use the options
26362655
await route._lazyPromise
2637-
const head = await executeHead()
2656+
const headResult = executeHead()
2657+
const head = headResult ? await headResult : undefined
26382658
await potentialPendingMinPromise()
26392659

26402660
// Last but not least, wait for the the components
@@ -2653,18 +2673,19 @@ export class RouterCore<
26532673

26542674
await potentialPendingMinPromise()
26552675

2656-
handleRedirectAndNotFound(this.getMatch(matchId)!, e)
2676+
handleRedirectAndNotFound(this.getMatch(matchId), e)
26572677

26582678
try {
26592679
route.options.onError?.(e)
26602680
} catch (onErrorError) {
26612681
error = onErrorError
26622682
handleRedirectAndNotFound(
2663-
this.getMatch(matchId)!,
2683+
this.getMatch(matchId),
26642684
onErrorError,
26652685
)
26662686
}
2667-
const head = await executeHead()
2687+
const headResult = executeHead()
2688+
const head = headResult ? await headResult : undefined
26682689
updateMatch(matchId, (prev) => ({
26692690
...prev,
26702691
error,
@@ -2674,16 +2695,20 @@ export class RouterCore<
26742695
}))
26752696
}
26762697
} catch (err) {
2677-
const head = await executeHead()
2678-
2679-
updateMatch(matchId, (prev) => {
2680-
prev._nonReactive.loaderPromise = undefined
2681-
return {
2682-
...prev,
2683-
...head,
2698+
const match = this.getMatch(matchId)
2699+
// in case of a redirecting match during preload, the match does not exist
2700+
if (match) {
2701+
const headResult = executeHead()
2702+
if (headResult) {
2703+
const head = await headResult
2704+
updateMatch(matchId, (prev) => ({
2705+
...prev,
2706+
...head,
2707+
}))
26842708
}
2685-
})
2686-
handleRedirectAndNotFound(this.getMatch(matchId)!, err)
2709+
match._nonReactive.loaderPromise = undefined
2710+
}
2711+
handleRedirectAndNotFound(match, err)
26872712
}
26882713
}
26892714

@@ -2718,11 +2743,14 @@ export class RouterCore<
27182743
// if the loader did not run, still update head.
27192744
// reason: parent's beforeLoad may have changed the route context
27202745
// and only now do we know the route context (and that the loader would not run)
2721-
const head = await executeHead()
2722-
updateMatch(matchId, (prev) => ({
2723-
...prev,
2724-
...head,
2725-
}))
2746+
const headResult = executeHead()
2747+
if (headResult) {
2748+
const head = await headResult
2749+
updateMatch(matchId, (prev) => ({
2750+
...prev,
2751+
...head,
2752+
}))
2753+
}
27262754
}
27272755
}
27282756
if (!loaderIsRunningAsync) {

0 commit comments

Comments
 (0)