Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow persisted fetchers unmounted during submit to revalidate #11102

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silver-teachers-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/router": patch
---

Fix bug preventing revalidation from occuring for persisted fetchers unmounted during the `submitting` phase
Original file line number Diff line number Diff line change
Expand Up @@ -5712,10 +5712,10 @@ function testDomRouter(
await waitFor(() => screen.getByText("Page (1)"));
expect(getHtml(container)).toMatch("Num fetchers: 1");

// Resolve after the navigation and revalidation
// Resolve action after the navigation and trigger revalidation
dfd.resolve("FETCH");
await waitFor(() => screen.getByText("Num fetchers: 0"));
expect(getHtml(container)).toMatch("Page (1)");
expect(getHtml(container)).toMatch("Page (2)");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test was incorrect before due to the bug. The revalidation would cause the count to increment again after the navigation completed

});

it("submitting fetchers w/revalidations are cleaned up on completion (remounted)", async () => {
Expand Down
133 changes: 132 additions & 1 deletion packages/router/__tests__/fetchers-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable jest/valid-title */
import type { HydrationState } from "../index";
import type { FutureConfig, HydrationState } from "../index";
import {
createMemoryHistory,
createRouter,
Expand All @@ -21,6 +21,7 @@ import { createFormData, tick } from "./utils/utils";
function initializeTest(init?: {
url?: string;
hydrationData?: HydrationState;
future?: Partial<FutureConfig>;
}) {
return setup({
routes: [
Expand Down Expand Up @@ -66,6 +67,7 @@ function initializeTest(init?: {
hydrationData: init?.hydrationData || {
loaderData: { root: "ROOT", index: "INDEX" },
},
future: init?.future,
...(init?.url ? { initialEntries: [init.url] } : {}),
});
}
Expand All @@ -82,6 +84,7 @@ describe("fetchers", () => {
// @ts-ignore
console.warn.mockReset();
});

describe("fetcher states", () => {
it("unabstracted loader fetch", async () => {
let dfd = createDeferred();
Expand Down Expand Up @@ -338,6 +341,134 @@ describe("fetchers", () => {
});
});

describe("fetcher removal (w/v7_fetcherPersist)", () => {
it("loading fetchers persist until completion", async () => {
let t = initializeTest({ future: { v7_fetcherPersist: true } });

let key = "key";
t.router.getFetcher(key); // mount
expect(t.router.state.fetchers.size).toBe(0);

let A = await t.fetch("/foo", key);
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

t.router.deleteFetcher(key); // unmount
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

// Cleaned up on completion
await A.loaders.foo.resolve("FOO");
expect(t.router.state.fetchers.size).toBe(0);
});

it("submitting fetchers persist until completion when removed during submitting phase", async () => {
let t = initializeTest({ future: { v7_fetcherPersist: true } });

let key = "key";
expect(t.router.state.fetchers.size).toBe(0);

t.router.getFetcher(key); // mount
let A = await t.fetch("/foo", key, {
formMethod: "post",
formData: createFormData({}),
});
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("submitting");

t.router.deleteFetcher(key); // unmount
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("submitting");

await A.actions.foo.resolve("FOO");
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

// Cleaned up on completion
await A.loaders.root.resolve("ROOT*");
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

await A.loaders.index.resolve("INDEX*");
expect(t.router.state.fetchers.size).toBe(0);
});

it("submitting fetchers persist until completion when removed during loading phase", async () => {
let t = initializeTest({ future: { v7_fetcherPersist: true } });

let key = "key";
t.router.getFetcher(key); // mount
expect(t.router.state.fetchers.size).toBe(0);

let A = await t.fetch("/foo", key, {
formMethod: "post",
formData: createFormData({}),
});
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("submitting");

await A.actions.foo.resolve("FOO");
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

t.router.deleteFetcher(key); // unmount
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

// Cleaned up on completion
await A.loaders.root.resolve("ROOT*");
expect(t.router.state.fetchers.size).toBe(1);
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

await A.loaders.index.resolve("INDEX*");
expect(t.router.state.fetchers.size).toBe(0);
});

it("unmounted fetcher.load errors/redirects should not be processed", async () => {
let t = initializeTest({ future: { v7_fetcherPersist: true } });

t.router.getFetcher("a"); // mount
let A = await t.fetch("/foo", "a");
t.router.deleteFetcher("a"); //unmount
await A.loaders.foo.reject("ERROR");
expect(t.router.state.fetchers.size).toBe(0);
expect(t.router.state.errors).toBe(null);

t.router.getFetcher("b"); // mount
let B = await t.fetch("/bar", "b");
t.router.deleteFetcher("b"); //unmount
await B.loaders.bar.redirect("/baz");
expect(t.router.state.fetchers.size).toBe(0);
expect(t.router.state.navigation).toBe(IDLE_NAVIGATION);
expect(t.router.state.location.pathname).toBe("/");
});

it("unmounted fetcher.submit errors/redirects should not be processed", async () => {
let t = initializeTest({ future: { v7_fetcherPersist: true } });

t.router.getFetcher("a"); // mount
let A = await t.fetch("/foo", "a", {
formMethod: "post",
formData: createFormData({}),
});
t.router.deleteFetcher("a"); //unmount
await A.actions.foo.reject("ERROR");
expect(t.router.state.fetchers.size).toBe(0);
expect(t.router.state.errors).toBe(null);

t.router.getFetcher("b"); // mount
let B = await t.fetch("/bar", "b", {
formMethod: "post",
formData: createFormData({}),
});
t.router.deleteFetcher("b"); //unmount
await B.actions.bar.redirect("/baz");
expect(t.router.state.fetchers.size).toBe(0);
expect(t.router.state.navigation).toBe(IDLE_NAVIGATION);
expect(t.router.state.location.pathname).toBe("/");
});
});

describe("fetcher error states (4xx Response)", () => {
it("loader fetch", async () => {
let t = initializeTest();
Expand Down
2 changes: 1 addition & 1 deletion packages/router/__tests__/utils/data-router-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ type SetupOpts = {
initialEntries?: InitialEntry[];
initialIndex?: number;
hydrationData?: HydrationState;
future?: FutureConfig;
future?: Partial<FutureConfig>;
};

// We use a slightly modified version of createDeferred here that includes the
Expand Down
54 changes: 31 additions & 23 deletions packages/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1981,33 +1981,39 @@ export function createRouter(init: RouterInit): Router {
return;
}

if (deletedFetchers.has(key)) {
updateFetcherState(key, getDoneFetcher(undefined));
return;
}
Comment on lines -1984 to -1987
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was too aggressive. It works for handleFetcherLoader because there's nothing left to do for an unmounted fetcher. For submissions we only want to short circuit for error/redirects - we need to let success results flow through so revalidation happens.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Easiest to see the changes with whitespace ignored


if (isRedirectResult(actionResult)) {
fetchControllers.delete(key);
if (pendingNavigationLoadId > originatingLoadId) {
// A new navigation was kicked off after our action started, so that
// should take precedence over this redirect navigation. We already
// set isRevalidationRequired so all loaders for the new route should
// fire unless opted out via shouldRevalidate
// When using v7_fetcherPersist, we don't want errors bubbling up to the UI
// or redirects processed for unmounted fetchers so we just revert them to
// idle
if (future.v7_fetcherPersist && deletedFetchers.has(key)) {
if (isRedirectResult(actionResult) || isErrorResult(actionResult)) {
updateFetcherState(key, getDoneFetcher(undefined));
return;
} else {
fetchRedirectIds.add(key);
updateFetcherState(key, getLoadingFetcher(submission));
return startRedirectNavigation(state, actionResult, {
fetcherSubmission: submission,
});
}
}
// Let SuccessResult's fall through for revalidation
} else {
if (isRedirectResult(actionResult)) {
fetchControllers.delete(key);
if (pendingNavigationLoadId > originatingLoadId) {
// A new navigation was kicked off after our action started, so that
// should take precedence over this redirect navigation. We already
// set isRevalidationRequired so all loaders for the new route should
// fire unless opted out via shouldRevalidate
updateFetcherState(key, getDoneFetcher(undefined));
return;
} else {
fetchRedirectIds.add(key);
updateFetcherState(key, getLoadingFetcher(submission));
return startRedirectNavigation(state, actionResult, {
fetcherSubmission: submission,
});
}
}

// Process any non-redirect errors thrown
if (isErrorResult(actionResult)) {
setFetcherError(key, routeId, actionResult.error);
return;
// Process any non-redirect errors thrown
if (isErrorResult(actionResult)) {
setFetcherError(key, routeId, actionResult.error);
return;
}
}

if (isDeferredResult(actionResult)) {
Expand Down Expand Up @@ -2237,6 +2243,8 @@ export function createRouter(init: RouterInit): Router {
return;
}

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