Skip to content

Commit 718ec1f

Browse files
authored
Avoid calling shouldRevalidate on interrupted initial load fetchers (#10623)
* Avoid calling shouldRevalidate on interrupted initial load fetchers * Bump bundle
1 parent 96e1fc1 commit 718ec1f

File tree

4 files changed

+100
-26
lines changed

4 files changed

+100
-26
lines changed

.changeset/skip-fetcher-revalidate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
Avoid calling `shouldRevalidate` for fetchers that have not yet completed a data load

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
},
110110
"filesize": {
111111
"packages/router/dist/router.umd.min.js": {
112-
"none": "46.4 kB"
112+
"none": "46.5 kB"
113113
},
114114
"packages/react-router/dist/react-router.production.min.js": {
115115
"none": "13.8 kB"

packages/router/__tests__/router-test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10845,6 +10845,62 @@ describe("a router", () => {
1084510845
});
1084610846
expect(t.router.state.fetchers.get(actionKey)).toBeUndefined();
1084710847
});
10848+
10849+
it("does not call shouldRevalidate if fetcher has no data (called 2x rapidly)", async () => {
10850+
// This is specifically for a Remix use case where the initial fetcher.load
10851+
// call hasn't completed (and hasn't even loaded the route module yet), so
10852+
// there isn't even a shouldRevalidate implementation to access yet. If
10853+
// there's no data it should just interrupt the existing load and load again,
10854+
// it's not a "revalidation"
10855+
let spy = jest.fn(() => true);
10856+
let t = setup({
10857+
routes: [
10858+
{
10859+
id: "root",
10860+
path: "/",
10861+
children: [
10862+
{
10863+
index: true,
10864+
},
10865+
{
10866+
path: "page",
10867+
},
10868+
],
10869+
},
10870+
{
10871+
id: "fetch",
10872+
path: "/fetch",
10873+
loader: true,
10874+
shouldRevalidate: spy,
10875+
},
10876+
],
10877+
});
10878+
10879+
let key = "key";
10880+
let A = await t.fetch("/fetch", key, "root");
10881+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
10882+
expect(A.loaders.fetch.signal.aborted).toBe(false);
10883+
10884+
// This should trigger an automatic revalidation of the fetcher since it
10885+
// hasn't loaded yet
10886+
let B = await t.navigate("/page", undefined, ["fetch"]);
10887+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
10888+
expect(A.loaders.fetch.signal.aborted).toBe(true);
10889+
expect(B.loaders.fetch.signal.aborted).toBe(false);
10890+
10891+
// No-op since the original call was aborted
10892+
await A.loaders.fetch.resolve("A");
10893+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
10894+
10895+
// Complete the navigation
10896+
await B.loaders.fetch.resolve("B");
10897+
expect(t.router.state.navigation.state).toBe("idle");
10898+
expect(t.router.state.fetchers.get(key)).toMatchObject({
10899+
state: "idle",
10900+
data: "B",
10901+
});
10902+
expect(spy).not.toHaveBeenCalled();
10903+
});
1084810904
});
1084910905

1085010906
describe("fetcher ?index params", () => {

packages/router/router.ts

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,7 @@ export function createRouter(init: RouterInit): Router {
14811481
cancelledDeferredRoutes,
14821482
cancelledFetcherLoads,
14831483
fetchLoadMatches,
1484+
fetchRedirectIds,
14841485
routesToUse,
14851486
basename,
14861487
pendingActionData,
@@ -1539,6 +1540,9 @@ export function createRouter(init: RouterInit): Router {
15391540

15401541
pendingNavigationLoadId = ++incrementingLoadId;
15411542
revalidatingFetchers.forEach((rf) => {
1543+
if (fetchControllers.has(rf.key)) {
1544+
abortFetcher(rf.key);
1545+
}
15421546
if (rf.controller) {
15431547
// Fetchers use an independent AbortController so that aborting a fetcher
15441548
// (via deleteFetcher) does not abort the triggering navigation that
@@ -1806,6 +1810,7 @@ export function createRouter(init: RouterInit): Router {
18061810
cancelledDeferredRoutes,
18071811
cancelledFetcherLoads,
18081812
fetchLoadMatches,
1813+
fetchRedirectIds,
18091814
routesToUse,
18101815
basename,
18111816
{ [match.route.id]: actionResult.data },
@@ -1825,6 +1830,9 @@ export function createRouter(init: RouterInit): Router {
18251830
existingFetcher ? existingFetcher.data : undefined
18261831
);
18271832
state.fetchers.set(staleKey, revalidatingFetcher);
1833+
if (fetchControllers.has(staleKey)) {
1834+
abortFetcher(staleKey);
1835+
}
18281836
if (rf.controller) {
18291837
fetchControllers.set(staleKey, rf.controller);
18301838
}
@@ -3276,6 +3284,7 @@ function getMatchesToLoad(
32763284
cancelledDeferredRoutes: string[],
32773285
cancelledFetcherLoads: string[],
32783286
fetchLoadMatches: Map<string, FetchLoadMatch>,
3287+
fetchRedirectIds: Set<string>,
32793288
routesToUse: AgnosticDataRouteObject[],
32803289
basename: string | undefined,
32813290
pendingActionData?: RouteData,
@@ -3361,34 +3370,38 @@ function getMatchesToLoad(
33613370
return;
33623371
}
33633372

3373+
// Revalidating fetchers are decoupled from the route matches since they
3374+
// load from a static href. They only set `defaultShouldRevalidate` on
3375+
// explicit revalidation due to submission, useRevalidator, or X-Remix-Revalidate
3376+
//
3377+
// They automatically revalidate without even calling shouldRevalidate if:
3378+
// - They were cancelled
3379+
// - They're in the middle of their first load and therefore this is still
3380+
// an initial load and not a revalidation
3381+
//
3382+
// If neither of those is true, then they _always_ check shouldRevalidate
3383+
let fetcher = state.fetchers.get(key);
3384+
let isPerformingInitialLoad =
3385+
fetcher &&
3386+
fetcher.state !== "idle" &&
3387+
fetcher.data === undefined &&
3388+
// If a fetcher.load redirected then it'll be "loading" without any data
3389+
// so ensure we're not processing the redirect from this fetcher
3390+
!fetchRedirectIds.has(key);
33643391
let fetcherMatch = getTargetMatch(fetcherMatches, f.path);
3365-
3366-
if (cancelledFetcherLoads.includes(key)) {
3367-
revalidatingFetchers.push({
3368-
key,
3369-
routeId: f.routeId,
3370-
path: f.path,
3371-
matches: fetcherMatches,
3372-
match: fetcherMatch,
3373-
controller: new AbortController(),
3392+
let shouldRevalidate =
3393+
cancelledFetcherLoads.includes(key) ||
3394+
isPerformingInitialLoad ||
3395+
shouldRevalidateLoader(fetcherMatch, {
3396+
currentUrl,
3397+
currentParams: state.matches[state.matches.length - 1].params,
3398+
nextUrl,
3399+
nextParams: matches[matches.length - 1].params,
3400+
...submission,
3401+
actionResult,
3402+
defaultShouldRevalidate: isRevalidationRequired,
33743403
});
3375-
return;
3376-
}
33773404

3378-
// Revalidating fetchers are decoupled from the route matches since they
3379-
// hit a static href, so they _always_ check shouldRevalidate and the
3380-
// default is strictly if a revalidation is explicitly required (action
3381-
// submissions, useRevalidator, X-Remix-Revalidate).
3382-
let shouldRevalidate = shouldRevalidateLoader(fetcherMatch, {
3383-
currentUrl,
3384-
currentParams: state.matches[state.matches.length - 1].params,
3385-
nextUrl,
3386-
nextParams: matches[matches.length - 1].params,
3387-
...submission,
3388-
actionResult,
3389-
// Forced revalidation due to submission, useRevalidator, or X-Remix-Revalidate
3390-
defaultShouldRevalidate: isRevalidationRequired,
3391-
});
33923405
if (shouldRevalidate) {
33933406
revalidatingFetchers.push({
33943407
key,

0 commit comments

Comments
 (0)