Skip to content

Commit f70688f

Browse files
authored
refactor(router-core): Verify changes are necessary before updating store (#4964)
Following #4916, #4925, #4926, and #4928, this PR keeps reducing the number of store updates (`__store.setState` through `updateMatch`). We check the values we want to set in the store are *actually different* from what is already in the store before calling `updateMatch` --- In the `store-updates-during-navigation` test tracking the number of executions of a `useRouterState > select` method during a navigation, our main test case goes from **14 calls** without this PR, to **10 calls** with this PR. Most test cases show significant improvements as well. --- Should be a partial improvement of #4359 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Prevented loss of previously loaded data during navigation when a loader yields no value. * Stabilized preloading to avoid redundant updates and unnecessary state churn. * Improved loading-state cleanup after navigation, reducing flicker and inconsistent “fetching” indicators. * **Performance** * Reduced unnecessary state updates and re-renders during and after preloads/loads for smoother navigation. * **Tests** * Updated async navigation tests to reflect refined timing and data-return behavior and added a helper to simulate delayed async results. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 1ccf7c8 commit f70688f

File tree

2 files changed

+39
-31
lines changed

2 files changed

+39
-31
lines changed

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,15 @@ async function run({ select }: ReturnType<typeof setup>) {
104104
return after - before
105105
}
106106

107+
function resolveAfter(ms: number, value: any) {
108+
return new Promise<void>((resolve) => setTimeout(() => resolve(value), ms))
109+
}
110+
107111
describe("Store doesn't update *too many* times during navigation", () => {
108112
test('async loader, async beforeLoad, pendingMs', async () => {
109113
const params = setup({
110-
beforeLoad: () =>
111-
new Promise<void>((resolve) => setTimeout(resolve, 100)),
112-
loader: () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
114+
beforeLoad: () => resolveAfter(100, { foo: 'bar' }),
115+
loader: () => resolveAfter(100, { hello: 'world' }),
113116
defaultPendingMs: 100,
114117
defaultPendingMinMs: 300,
115118
})
@@ -119,7 +122,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
119122
// This number should be as small as possible to minimize the amount of work
120123
// that needs to be done during a navigation.
121124
// Any change that increases this number should be investigated.
122-
expect(updates).toBe(14)
125+
expect(updates).toBe(10)
123126
})
124127

125128
test('redirection in preload', async () => {
@@ -137,13 +140,13 @@ describe("Store doesn't update *too many* times during navigation", () => {
137140
// This number should be as small as possible to minimize the amount of work
138141
// that needs to be done during a navigation.
139142
// Any change that increases this number should be investigated.
140-
expect(updates).toBe(6)
143+
expect(updates).toBe(5)
141144
})
142145

143146
test('sync beforeLoad', async () => {
144147
const params = setup({
145148
beforeLoad: () => ({ foo: 'bar' }),
146-
loader: () => new Promise<void>((resolve) => setTimeout(resolve, 100)),
149+
loader: () => resolveAfter(100, { hello: 'world' }),
147150
defaultPendingMs: 100,
148151
defaultPendingMinMs: 300,
149152
})
@@ -153,7 +156,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
153156
// This number should be as small as possible to minimize the amount of work
154157
// that needs to be done during a navigation.
155158
// Any change that increases this number should be investigated.
156-
expect(updates).toBe(13)
159+
expect(updates).toBe(9)
157160
})
158161

159162
test('nothing', async () => {
@@ -164,8 +167,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
164167
// This number should be as small as possible to minimize the amount of work
165168
// that needs to be done during a navigation.
166169
// 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)
170+
expect(updates).toBeGreaterThanOrEqual(6) // WARN: this is flaky, and sometimes (rarely) is 7
171+
expect(updates).toBeLessThanOrEqual(7)
169172
})
170173

171174
test('not found in beforeLoad', async () => {
@@ -205,6 +208,6 @@ describe("Store doesn't update *too many* times during navigation", () => {
205208
// This number should be as small as possible to minimize the amount of work
206209
// that needs to be done during a navigation.
207210
// Any change that increases this number should be investigated.
208-
expect(updates).toBe(19)
211+
expect(updates).toBe(15)
209212
})
210213
})

packages/router-core/src/router.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2697,10 +2697,12 @@ export class RouterCore<
26972697
this.getMatch(matchId),
26982698
loaderData,
26992699
)
2700-
innerLoadContext.updateMatch(matchId, (prev) => ({
2701-
...prev,
2702-
loaderData,
2703-
}))
2700+
if (loaderData !== undefined) {
2701+
innerLoadContext.updateMatch(matchId, (prev) => ({
2702+
...prev,
2703+
loaderData,
2704+
}))
2705+
}
27042706
}
27052707

27062708
// Lazy option can modify the route options,
@@ -2837,14 +2839,16 @@ export class RouterCore<
28372839
)
28382840
: shouldReloadOption
28392841

2840-
innerLoadContext.updateMatch(matchId, (prev) => {
2841-
prev._nonReactive.loaderPromise = createControlledPromise<void>()
2842-
return {
2842+
const nextPreload =
2843+
!!preload && !this.state.matches.some((d) => d.id === matchId)
2844+
const match = this.getMatch(matchId)!
2845+
match._nonReactive.loaderPromise = createControlledPromise<void>()
2846+
if (nextPreload !== match.preload) {
2847+
innerLoadContext.updateMatch(matchId, (prev) => ({
28432848
...prev,
2844-
preload:
2845-
!!preload && !this.state.matches.some((d) => d.id === matchId),
2846-
}
2847-
})
2849+
preload: nextPreload,
2850+
}))
2851+
}
28482852

28492853
// If the route is successful and still fresh, just resolve
28502854
const { status, invalid } = this.getMatch(matchId)!
@@ -2886,23 +2890,24 @@ export class RouterCore<
28862890
}
28872891
}
28882892
}
2893+
const match = this.getMatch(matchId)!
28892894
if (!loaderIsRunningAsync) {
2890-
const match = this.getMatch(matchId)!
28912895
match._nonReactive.loaderPromise?.resolve()
28922896
match._nonReactive.loadPromise?.resolve()
28932897
}
28942898

2895-
innerLoadContext.updateMatch(matchId, (prev) => {
2896-
clearTimeout(prev._nonReactive.pendingTimeout)
2897-
prev._nonReactive.pendingTimeout = undefined
2898-
if (!loaderIsRunningAsync) prev._nonReactive.loaderPromise = undefined
2899-
prev._nonReactive.dehydrated = undefined
2900-
return {
2899+
clearTimeout(match._nonReactive.pendingTimeout)
2900+
match._nonReactive.pendingTimeout = undefined
2901+
if (!loaderIsRunningAsync) match._nonReactive.loaderPromise = undefined
2902+
match._nonReactive.dehydrated = undefined
2903+
const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false
2904+
if (nextIsFetching !== match.isFetching || match.invalid !== false) {
2905+
innerLoadContext.updateMatch(matchId, (prev) => ({
29012906
...prev,
2902-
isFetching: loaderIsRunningAsync ? prev.isFetching : false,
2907+
isFetching: nextIsFetching,
29032908
invalid: false,
2904-
}
2905-
})
2909+
}))
2910+
}
29062911
return this.getMatch(matchId)!
29072912
}
29082913

0 commit comments

Comments
 (0)