Skip to content

Commit 54ec39e

Browse files
authored
Allow persisted fetchers unmounted during submit to revalidate (#11102)
* Allow persisted fetchers unmounted during submit to revalidate * Bump bundle
1 parent a76ee41 commit 54ec39e

File tree

5 files changed

+171
-27
lines changed

5 files changed

+171
-27
lines changed

.changeset/silver-teachers-doubt.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+
Fix bug preventing revalidation from occuring for persisted fetchers unmounted during the `submitting` phase

packages/react-router-dom/__tests__/data-browser-router-test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5712,10 +5712,10 @@ function testDomRouter(
57125712
await waitFor(() => screen.getByText("Page (1)"));
57135713
expect(getHtml(container)).toMatch("Num fetchers: 1");
57145714

5715-
// Resolve after the navigation and revalidation
5715+
// Resolve action after the navigation and trigger revalidation
57165716
dfd.resolve("FETCH");
57175717
await waitFor(() => screen.getByText("Num fetchers: 0"));
5718-
expect(getHtml(container)).toMatch("Page (1)");
5718+
expect(getHtml(container)).toMatch("Page (2)");
57195719
});
57205720

57215721
it("submitting fetchers w/revalidations are cleaned up on completion (remounted)", async () => {

packages/router/__tests__/fetchers-test.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable jest/valid-title */
2-
import type { HydrationState } from "../index";
2+
import type { FutureConfig, HydrationState } from "../index";
33
import {
44
createMemoryHistory,
55
createRouter,
@@ -21,6 +21,7 @@ import { createFormData, tick } from "./utils/utils";
2121
function initializeTest(init?: {
2222
url?: string;
2323
hydrationData?: HydrationState;
24+
future?: Partial<FutureConfig>;
2425
}) {
2526
return setup({
2627
routes: [
@@ -66,6 +67,7 @@ function initializeTest(init?: {
6667
hydrationData: init?.hydrationData || {
6768
loaderData: { root: "ROOT", index: "INDEX" },
6869
},
70+
future: init?.future,
6971
...(init?.url ? { initialEntries: [init.url] } : {}),
7072
});
7173
}
@@ -82,6 +84,7 @@ describe("fetchers", () => {
8284
// @ts-ignore
8385
console.warn.mockReset();
8486
});
87+
8588
describe("fetcher states", () => {
8689
it("unabstracted loader fetch", async () => {
8790
let dfd = createDeferred();
@@ -338,6 +341,134 @@ describe("fetchers", () => {
338341
});
339342
});
340343

344+
describe("fetcher removal (w/v7_fetcherPersist)", () => {
345+
it("loading fetchers persist until completion", async () => {
346+
let t = initializeTest({ future: { v7_fetcherPersist: true } });
347+
348+
let key = "key";
349+
t.router.getFetcher(key); // mount
350+
expect(t.router.state.fetchers.size).toBe(0);
351+
352+
let A = await t.fetch("/foo", key);
353+
expect(t.router.state.fetchers.size).toBe(1);
354+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
355+
356+
t.router.deleteFetcher(key); // unmount
357+
expect(t.router.state.fetchers.size).toBe(1);
358+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
359+
360+
// Cleaned up on completion
361+
await A.loaders.foo.resolve("FOO");
362+
expect(t.router.state.fetchers.size).toBe(0);
363+
});
364+
365+
it("submitting fetchers persist until completion when removed during submitting phase", async () => {
366+
let t = initializeTest({ future: { v7_fetcherPersist: true } });
367+
368+
let key = "key";
369+
expect(t.router.state.fetchers.size).toBe(0);
370+
371+
t.router.getFetcher(key); // mount
372+
let A = await t.fetch("/foo", key, {
373+
formMethod: "post",
374+
formData: createFormData({}),
375+
});
376+
expect(t.router.state.fetchers.size).toBe(1);
377+
expect(t.router.state.fetchers.get(key)?.state).toBe("submitting");
378+
379+
t.router.deleteFetcher(key); // unmount
380+
expect(t.router.state.fetchers.size).toBe(1);
381+
expect(t.router.state.fetchers.get(key)?.state).toBe("submitting");
382+
383+
await A.actions.foo.resolve("FOO");
384+
expect(t.router.state.fetchers.size).toBe(1);
385+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
386+
387+
// Cleaned up on completion
388+
await A.loaders.root.resolve("ROOT*");
389+
expect(t.router.state.fetchers.size).toBe(1);
390+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
391+
392+
await A.loaders.index.resolve("INDEX*");
393+
expect(t.router.state.fetchers.size).toBe(0);
394+
});
395+
396+
it("submitting fetchers persist until completion when removed during loading phase", async () => {
397+
let t = initializeTest({ future: { v7_fetcherPersist: true } });
398+
399+
let key = "key";
400+
t.router.getFetcher(key); // mount
401+
expect(t.router.state.fetchers.size).toBe(0);
402+
403+
let A = await t.fetch("/foo", key, {
404+
formMethod: "post",
405+
formData: createFormData({}),
406+
});
407+
expect(t.router.state.fetchers.size).toBe(1);
408+
expect(t.router.state.fetchers.get(key)?.state).toBe("submitting");
409+
410+
await A.actions.foo.resolve("FOO");
411+
expect(t.router.state.fetchers.size).toBe(1);
412+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
413+
414+
t.router.deleteFetcher(key); // unmount
415+
expect(t.router.state.fetchers.size).toBe(1);
416+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
417+
418+
// Cleaned up on completion
419+
await A.loaders.root.resolve("ROOT*");
420+
expect(t.router.state.fetchers.size).toBe(1);
421+
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");
422+
423+
await A.loaders.index.resolve("INDEX*");
424+
expect(t.router.state.fetchers.size).toBe(0);
425+
});
426+
427+
it("unmounted fetcher.load errors/redirects should not be processed", async () => {
428+
let t = initializeTest({ future: { v7_fetcherPersist: true } });
429+
430+
t.router.getFetcher("a"); // mount
431+
let A = await t.fetch("/foo", "a");
432+
t.router.deleteFetcher("a"); //unmount
433+
await A.loaders.foo.reject("ERROR");
434+
expect(t.router.state.fetchers.size).toBe(0);
435+
expect(t.router.state.errors).toBe(null);
436+
437+
t.router.getFetcher("b"); // mount
438+
let B = await t.fetch("/bar", "b");
439+
t.router.deleteFetcher("b"); //unmount
440+
await B.loaders.bar.redirect("/baz");
441+
expect(t.router.state.fetchers.size).toBe(0);
442+
expect(t.router.state.navigation).toBe(IDLE_NAVIGATION);
443+
expect(t.router.state.location.pathname).toBe("/");
444+
});
445+
446+
it("unmounted fetcher.submit errors/redirects should not be processed", async () => {
447+
let t = initializeTest({ future: { v7_fetcherPersist: true } });
448+
449+
t.router.getFetcher("a"); // mount
450+
let A = await t.fetch("/foo", "a", {
451+
formMethod: "post",
452+
formData: createFormData({}),
453+
});
454+
t.router.deleteFetcher("a"); //unmount
455+
await A.actions.foo.reject("ERROR");
456+
expect(t.router.state.fetchers.size).toBe(0);
457+
expect(t.router.state.errors).toBe(null);
458+
459+
t.router.getFetcher("b"); // mount
460+
let B = await t.fetch("/bar", "b", {
461+
formMethod: "post",
462+
formData: createFormData({}),
463+
});
464+
t.router.deleteFetcher("b"); //unmount
465+
await B.actions.bar.redirect("/baz");
466+
expect(t.router.state.fetchers.size).toBe(0);
467+
expect(t.router.state.navigation).toBe(IDLE_NAVIGATION);
468+
expect(t.router.state.location.pathname).toBe("/");
469+
});
470+
});
471+
341472
describe("fetcher error states (4xx Response)", () => {
342473
it("loader fetch", async () => {
343474
let t = initializeTest();

packages/router/__tests__/utils/data-router-setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ type SetupOpts = {
142142
initialEntries?: InitialEntry[];
143143
initialIndex?: number;
144144
hydrationData?: HydrationState;
145-
future?: FutureConfig;
145+
future?: Partial<FutureConfig>;
146146
};
147147

148148
// We use a slightly modified version of createDeferred here that includes the

packages/router/router.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,33 +1981,39 @@ export function createRouter(init: RouterInit): Router {
19811981
return;
19821982
}
19831983

1984-
if (deletedFetchers.has(key)) {
1985-
updateFetcherState(key, getDoneFetcher(undefined));
1986-
return;
1987-
}
1988-
1989-
if (isRedirectResult(actionResult)) {
1990-
fetchControllers.delete(key);
1991-
if (pendingNavigationLoadId > originatingLoadId) {
1992-
// A new navigation was kicked off after our action started, so that
1993-
// should take precedence over this redirect navigation. We already
1994-
// set isRevalidationRequired so all loaders for the new route should
1995-
// fire unless opted out via shouldRevalidate
1984+
// When using v7_fetcherPersist, we don't want errors bubbling up to the UI
1985+
// or redirects processed for unmounted fetchers so we just revert them to
1986+
// idle
1987+
if (future.v7_fetcherPersist && deletedFetchers.has(key)) {
1988+
if (isRedirectResult(actionResult) || isErrorResult(actionResult)) {
19961989
updateFetcherState(key, getDoneFetcher(undefined));
19971990
return;
1998-
} else {
1999-
fetchRedirectIds.add(key);
2000-
updateFetcherState(key, getLoadingFetcher(submission));
2001-
return startRedirectNavigation(state, actionResult, {
2002-
fetcherSubmission: submission,
2003-
});
20041991
}
2005-
}
1992+
// Let SuccessResult's fall through for revalidation
1993+
} else {
1994+
if (isRedirectResult(actionResult)) {
1995+
fetchControllers.delete(key);
1996+
if (pendingNavigationLoadId > originatingLoadId) {
1997+
// A new navigation was kicked off after our action started, so that
1998+
// should take precedence over this redirect navigation. We already
1999+
// set isRevalidationRequired so all loaders for the new route should
2000+
// fire unless opted out via shouldRevalidate
2001+
updateFetcherState(key, getDoneFetcher(undefined));
2002+
return;
2003+
} else {
2004+
fetchRedirectIds.add(key);
2005+
updateFetcherState(key, getLoadingFetcher(submission));
2006+
return startRedirectNavigation(state, actionResult, {
2007+
fetcherSubmission: submission,
2008+
});
2009+
}
2010+
}
20062011

2007-
// Process any non-redirect errors thrown
2008-
if (isErrorResult(actionResult)) {
2009-
setFetcherError(key, routeId, actionResult.error);
2010-
return;
2012+
// Process any non-redirect errors thrown
2013+
if (isErrorResult(actionResult)) {
2014+
setFetcherError(key, routeId, actionResult.error);
2015+
return;
2016+
}
20112017
}
20122018

20132019
if (isDeferredResult(actionResult)) {
@@ -2237,6 +2243,8 @@ export function createRouter(init: RouterInit): Router {
22372243
return;
22382244
}
22392245

2246+
// We don't want errors bubbling up or redirects followed for unmounted
2247+
// fetchers, so short circuit here if it was removed from the UI
22402248
if (deletedFetchers.has(key)) {
22412249
updateFetcherState(key, getDoneFetcher(undefined));
22422250
return;

0 commit comments

Comments
 (0)