Skip to content

Commit

Permalink
Fix fetcher interruption cleanup logic after fetcher submissions (#11839
Browse files Browse the repository at this point in the history
)
  • Loading branch information
brophdawg11 authored Jul 25, 2024
1 parent 653d1a8 commit ecdbbde
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 6 deletions.
11 changes: 11 additions & 0 deletions .changeset/hip-poets-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@remix-run/router": patch
---

Fix internal cleanup of interrupted fetchers to avoid invalid revalidations on navigations

- When a `fetcher.load` is interrupted by an `action` submission, we track it internally and force revalidation once the `action` completes
- We previously only cleared out this internal tracking info on a successful _navigation_ submission
- 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)
- 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
- This fix cleans up the internal tracking so it applies to both navigation submission and fetcher submissions
102 changes: 102 additions & 0 deletions packages/router/__tests__/fetchers-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,108 @@ describe("fetchers", () => {
});
});

it("cancels in-flight fetcher.loads on fetcher.action submission and forces reload (one time)", async () => {
let t = setup({
routes: [
{
id: "root",
path: "/",
children: [
{
id: "index",
index: true,
},
{
id: "page",
path: "page",
action: true,
},
{
id: "action",
path: "action",
action: true,
},
{
id: "fetch",
path: "fetch",
loader: true,
shouldRevalidate: () => false,
},
],
},
],
});
expect(t.router.state.navigation).toBe(IDLE_NAVIGATION);

// Kick off a fetch from the root route
let keyA = "a";
let A = await t.fetch("/fetch", keyA, "root");
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
state: "loading",
data: undefined,
});

// Interrupt with a fetcher submission which will trigger a forced
// revalidation, ignoring shouldRevalidate=false since the prior load
// was interrupted
let keyB = "b";
let B = await t.fetch("/action", keyB, {
formMethod: "post",
formData: createFormData({}),
});
t.shimHelper(B.loaders, "fetch", "loader", "fetch");
expect(t.router.state.fetchers.get(keyB)).toMatchObject({
state: "submitting",
data: undefined,
});

// Resolving the first load is a no-op since it would have been aborted
await A.loaders.fetch.resolve("A LOADER");
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
state: "loading",
data: undefined,
});

// Resolve the action, kicking off revalidations which will force the
// fetcher "a" load to run again even through shouldRevalidate=false
await B.actions.action.resolve("B ACTION");
expect(t.router.state.fetchers.get(keyB)).toMatchObject({
state: "loading",
data: "B ACTION",
});
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
state: "loading",
data: undefined,
});

// Resolve the second loader kicked off after the action
await B.loaders.fetch.resolve("B LOADER");
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
state: "idle",
data: "B LOADER",
});

// submitting to another route
let C = await t.navigate("/page", {
formMethod: "post",
formData: createFormData({}),
});
t.shimHelper(C.loaders, "navigation", "loader", "fetch");
expect(t.router.state.navigation.location?.pathname).toBe("/page");

// should not trigger a fetcher reload due to shouldRevalidate=false
// This fixes a prior bug where we didn't remove the fetcher from
// cancelledFetcherLoaders upon the forced revalidation
await C.actions.page.resolve("PAGE ACTION");
expect(t.router.state.location.pathname).toBe("/page");
expect(t.router.state.navigation).toBe(IDLE_NAVIGATION);
expect(t.router.state.fetchers.get(keyA)).toMatchObject({
state: "idle",
data: "B LOADER",
});
expect(C.loaders.fetch.stub).not.toHaveBeenCalled();
});

it("does not cancel pending action navigation on deletion of revalidating fetcher", async () => {
let t = setup({
routes: TASK_ROUTES,
Expand Down
13 changes: 7 additions & 6 deletions packages/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,7 +981,7 @@ export function createRouter(init: RouterInit): Router {

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

// AbortControllers for any in-flight fetchers
let fetchControllers = new Map<string, AbortController>();
Expand Down Expand Up @@ -1334,7 +1334,6 @@ export function createRouter(init: RouterInit): Router {
isUninterruptedRevalidation = false;
isRevalidationRequired = false;
cancelledDeferredRoutes = [];
cancelledFetcherLoads = [];
}

// Trigger a navigation event, which can either be a numerical POP or a PUSH
Expand Down Expand Up @@ -2867,7 +2866,7 @@ export function createRouter(init: RouterInit): Router {
// Abort in-flight fetcher loads
fetchLoadMatches.forEach((_, key) => {
if (fetchControllers.has(key)) {
cancelledFetcherLoads.push(key);
cancelledFetcherLoads.add(key);
abortFetcher(key);
}
});
Expand Down Expand Up @@ -2931,6 +2930,7 @@ export function createRouter(init: RouterInit): Router {
fetchReloadIds.delete(key);
fetchRedirectIds.delete(key);
deletedFetchers.delete(key);
cancelledFetcherLoads.delete(key);
state.fetchers.delete(key);
}

Expand Down Expand Up @@ -4324,7 +4324,7 @@ function getMatchesToLoad(
skipActionErrorRevalidation: boolean,
isRevalidationRequired: boolean,
cancelledDeferredRoutes: string[],
cancelledFetcherLoads: string[],
cancelledFetcherLoads: Set<string>,
deletedFetchers: Set<string>,
fetchLoadMatches: Map<string, FetchLoadMatch>,
fetchRedirectIds: Set<string>,
Expand Down Expand Up @@ -4459,8 +4459,9 @@ function getMatchesToLoad(
if (fetchRedirectIds.has(key)) {
// Never trigger a revalidation of an actively redirecting fetcher
shouldRevalidate = false;
} else if (cancelledFetcherLoads.includes(key)) {
// Always revalidate if the fetcher was cancelled
} else if (cancelledFetcherLoads.has(key)) {
// Always mark for revalidation if the fetcher was cancelled
cancelledFetcherLoads.delete(key);
shouldRevalidate = true;
} else if (
fetcher &&
Expand Down

0 comments on commit ecdbbde

Please sign in to comment.