Skip to content

Commit ecdbbde

Browse files
authored
Fix fetcher interruption cleanup logic after fetcher submissions (#11839)
1 parent 653d1a8 commit ecdbbde

File tree

3 files changed

+120
-6
lines changed

3 files changed

+120
-6
lines changed

.changeset/hip-poets-provide.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
Fix internal cleanup of interrupted fetchers to avoid invalid revalidations on navigations
6+
7+
- When a `fetcher.load` is interrupted by an `action` submission, we track it internally and force revalidation once the `action` completes
8+
- We previously only cleared out this internal tracking info on a successful _navigation_ submission
9+
- Therefore, if the `fetcher.load` was interrupted by a `fetcher.submit`, then we wouldn't remove it from this internal tracking info on successful load (incorrectly)
10+
- And then on the next navigation it's presence in the internal tracking would automatically trigger execution of the `fetcher.load` again, ignoring any `shouldRevalidate` logic
11+
- This fix cleans up the internal tracking so it applies to both navigation submission and fetcher submissions

packages/router/__tests__/fetchers-test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2405,6 +2405,108 @@ describe("fetchers", () => {
24052405
});
24062406
});
24072407

2408+
it("cancels in-flight fetcher.loads on fetcher.action submission and forces reload (one time)", async () => {
2409+
let t = setup({
2410+
routes: [
2411+
{
2412+
id: "root",
2413+
path: "/",
2414+
children: [
2415+
{
2416+
id: "index",
2417+
index: true,
2418+
},
2419+
{
2420+
id: "page",
2421+
path: "page",
2422+
action: true,
2423+
},
2424+
{
2425+
id: "action",
2426+
path: "action",
2427+
action: true,
2428+
},
2429+
{
2430+
id: "fetch",
2431+
path: "fetch",
2432+
loader: true,
2433+
shouldRevalidate: () => false,
2434+
},
2435+
],
2436+
},
2437+
],
2438+
});
2439+
expect(t.router.state.navigation).toBe(IDLE_NAVIGATION);
2440+
2441+
// Kick off a fetch from the root route
2442+
let keyA = "a";
2443+
let A = await t.fetch("/fetch", keyA, "root");
2444+
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
2445+
state: "loading",
2446+
data: undefined,
2447+
});
2448+
2449+
// Interrupt with a fetcher submission which will trigger a forced
2450+
// revalidation, ignoring shouldRevalidate=false since the prior load
2451+
// was interrupted
2452+
let keyB = "b";
2453+
let B = await t.fetch("/action", keyB, {
2454+
formMethod: "post",
2455+
formData: createFormData({}),
2456+
});
2457+
t.shimHelper(B.loaders, "fetch", "loader", "fetch");
2458+
expect(t.router.state.fetchers.get(keyB)).toMatchObject({
2459+
state: "submitting",
2460+
data: undefined,
2461+
});
2462+
2463+
// Resolving the first load is a no-op since it would have been aborted
2464+
await A.loaders.fetch.resolve("A LOADER");
2465+
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
2466+
state: "loading",
2467+
data: undefined,
2468+
});
2469+
2470+
// Resolve the action, kicking off revalidations which will force the
2471+
// fetcher "a" load to run again even through shouldRevalidate=false
2472+
await B.actions.action.resolve("B ACTION");
2473+
expect(t.router.state.fetchers.get(keyB)).toMatchObject({
2474+
state: "loading",
2475+
data: "B ACTION",
2476+
});
2477+
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
2478+
state: "loading",
2479+
data: undefined,
2480+
});
2481+
2482+
// Resolve the second loader kicked off after the action
2483+
await B.loaders.fetch.resolve("B LOADER");
2484+
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
2485+
state: "idle",
2486+
data: "B LOADER",
2487+
});
2488+
2489+
// submitting to another route
2490+
let C = await t.navigate("/page", {
2491+
formMethod: "post",
2492+
formData: createFormData({}),
2493+
});
2494+
t.shimHelper(C.loaders, "navigation", "loader", "fetch");
2495+
expect(t.router.state.navigation.location?.pathname).toBe("/page");
2496+
2497+
// should not trigger a fetcher reload due to shouldRevalidate=false
2498+
// This fixes a prior bug where we didn't remove the fetcher from
2499+
// cancelledFetcherLoaders upon the forced revalidation
2500+
await C.actions.page.resolve("PAGE ACTION");
2501+
expect(t.router.state.location.pathname).toBe("/page");
2502+
expect(t.router.state.navigation).toBe(IDLE_NAVIGATION);
2503+
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
2504+
state: "idle",
2505+
data: "B LOADER",
2506+
});
2507+
expect(C.loaders.fetch.stub).not.toHaveBeenCalled();
2508+
});
2509+
24082510
it("does not cancel pending action navigation on deletion of revalidating fetcher", async () => {
24092511
let t = setup({
24102512
routes: TASK_ROUTES,

packages/router/router.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,7 @@ export function createRouter(init: RouterInit): Router {
981981

982982
// Use this internal array to capture fetcher loads that were cancelled by an
983983
// action navigation and require revalidation
984-
let cancelledFetcherLoads: string[] = [];
984+
let cancelledFetcherLoads: Set<string> = new Set();
985985

986986
// AbortControllers for any in-flight fetchers
987987
let fetchControllers = new Map<string, AbortController>();
@@ -1334,7 +1334,6 @@ export function createRouter(init: RouterInit): Router {
13341334
isUninterruptedRevalidation = false;
13351335
isRevalidationRequired = false;
13361336
cancelledDeferredRoutes = [];
1337-
cancelledFetcherLoads = [];
13381337
}
13391338

13401339
// Trigger a navigation event, which can either be a numerical POP or a PUSH
@@ -2867,7 +2866,7 @@ export function createRouter(init: RouterInit): Router {
28672866
// Abort in-flight fetcher loads
28682867
fetchLoadMatches.forEach((_, key) => {
28692868
if (fetchControllers.has(key)) {
2870-
cancelledFetcherLoads.push(key);
2869+
cancelledFetcherLoads.add(key);
28712870
abortFetcher(key);
28722871
}
28732872
});
@@ -2931,6 +2930,7 @@ export function createRouter(init: RouterInit): Router {
29312930
fetchReloadIds.delete(key);
29322931
fetchRedirectIds.delete(key);
29332932
deletedFetchers.delete(key);
2933+
cancelledFetcherLoads.delete(key);
29342934
state.fetchers.delete(key);
29352935
}
29362936

@@ -4324,7 +4324,7 @@ function getMatchesToLoad(
43244324
skipActionErrorRevalidation: boolean,
43254325
isRevalidationRequired: boolean,
43264326
cancelledDeferredRoutes: string[],
4327-
cancelledFetcherLoads: string[],
4327+
cancelledFetcherLoads: Set<string>,
43284328
deletedFetchers: Set<string>,
43294329
fetchLoadMatches: Map<string, FetchLoadMatch>,
43304330
fetchRedirectIds: Set<string>,
@@ -4459,8 +4459,9 @@ function getMatchesToLoad(
44594459
if (fetchRedirectIds.has(key)) {
44604460
// Never trigger a revalidation of an actively redirecting fetcher
44614461
shouldRevalidate = false;
4462-
} else if (cancelledFetcherLoads.includes(key)) {
4463-
// Always revalidate if the fetcher was cancelled
4462+
} else if (cancelledFetcherLoads.has(key)) {
4463+
// Always mark for revalidation if the fetcher was cancelled
4464+
cancelledFetcherLoads.delete(key);
44644465
shouldRevalidate = true;
44654466
} else if (
44664467
fetcher &&

0 commit comments

Comments
 (0)