Skip to content

Commit ff9efe2

Browse files
Sherafftannerlinsley
authored andcommitted
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 40275ab commit ff9efe2

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
@@ -2690,10 +2690,12 @@ export class RouterCore<
26902690
this.getMatch(matchId),
26912691
loaderData,
26922692
)
2693-
innerLoadContext.updateMatch(matchId, (prev) => ({
2694-
...prev,
2695-
loaderData,
2696-
}))
2693+
if (loaderData !== undefined) {
2694+
innerLoadContext.updateMatch(matchId, (prev) => ({
2695+
...prev,
2696+
loaderData,
2697+
}))
2698+
}
26972699
}
26982700

26992701
// Lazy option can modify the route options,
@@ -2830,14 +2832,16 @@ export class RouterCore<
28302832
)
28312833
: shouldReloadOption
28322834

2833-
innerLoadContext.updateMatch(matchId, (prev) => {
2834-
prev._nonReactive.loaderPromise = createControlledPromise<void>()
2835-
return {
2835+
const nextPreload =
2836+
!!preload && !this.state.matches.some((d) => d.id === matchId)
2837+
const match = this.getMatch(matchId)!
2838+
match._nonReactive.loaderPromise = createControlledPromise<void>()
2839+
if (nextPreload !== match.preload) {
2840+
innerLoadContext.updateMatch(matchId, (prev) => ({
28362841
...prev,
2837-
preload:
2838-
!!preload && !this.state.matches.some((d) => d.id === matchId),
2839-
}
2840-
})
2842+
preload: nextPreload,
2843+
}))
2844+
}
28412845

28422846
// If the route is successful and still fresh, just resolve
28432847
const { status, invalid } = this.getMatch(matchId)!
@@ -2879,23 +2883,24 @@ export class RouterCore<
28792883
}
28802884
}
28812885
}
2886+
const match = this.getMatch(matchId)!
28822887
if (!loaderIsRunningAsync) {
2883-
const match = this.getMatch(matchId)!
28842888
match._nonReactive.loaderPromise?.resolve()
28852889
match._nonReactive.loadPromise?.resolve()
28862890
}
28872891

2888-
innerLoadContext.updateMatch(matchId, (prev) => {
2889-
clearTimeout(prev._nonReactive.pendingTimeout)
2890-
prev._nonReactive.pendingTimeout = undefined
2891-
if (!loaderIsRunningAsync) prev._nonReactive.loaderPromise = undefined
2892-
prev._nonReactive.dehydrated = undefined
2893-
return {
2892+
clearTimeout(match._nonReactive.pendingTimeout)
2893+
match._nonReactive.pendingTimeout = undefined
2894+
if (!loaderIsRunningAsync) match._nonReactive.loaderPromise = undefined
2895+
match._nonReactive.dehydrated = undefined
2896+
const nextIsFetching = loaderIsRunningAsync ? match.isFetching : false
2897+
if (nextIsFetching !== match.isFetching || match.invalid !== false) {
2898+
innerLoadContext.updateMatch(matchId, (prev) => ({
28942899
...prev,
2895-
isFetching: loaderIsRunningAsync ? prev.isFetching : false,
2900+
isFetching: nextIsFetching,
28962901
invalid: false,
2897-
}
2898-
})
2902+
}))
2903+
}
28992904
return this.getMatch(matchId)!
29002905
}
29012906

0 commit comments

Comments
 (0)