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

Properly handle fetcher redirects interrupted by normal navigations #10674

Merged
merged 4 commits into from
Jul 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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

Properly handle fetcher redirects interrupted by normal navigations
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
},
"filesize": {
"packages/router/dist/router.umd.min.js": {
"none": "46.6 kB"
"none": "46.9 kB"
},
"packages/react-router/dist/react-router.production.min.js": {
"none": "13.8 kB"
Expand Down
179 changes: 178 additions & 1 deletion packages/router/__tests__/router-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9726,7 +9726,10 @@ describe("a router", () => {
expect(A.loaders.foo.signal.aborted).toBe(false);
expect(t.router.state.navigation.state).toBe("idle");
expect(t.router.state.location.pathname).toBe("/foo");
expect(t.router.state.loaderData.foo).toBe("B");
expect(t.router.state.loaderData).toEqual({
root: "B root",
foo: "B",
});

await A.loaders.root.resolve("A root");
await A.loaders.foo.resolve("A");
Expand Down Expand Up @@ -10009,6 +10012,180 @@ describe("a router", () => {
expect(t.router.state.fetchers.get(key)?.data).toBeUndefined();
});
});

describe(`
A) fetch POST /foo |--R
B) nav GET /bar |---O
`, () => {
it("ignores submission redirect navigation if preceded by a normal navigation", async () => {
let key = "key";
let t = initializeTmTest();
let A = await t.fetch("/foo", key, {
formMethod: "post",
formData: createFormData({ key: "value" }),
});
let B = await t.navigate("/bar");

// This redirect should be ignored
await A.actions.foo.redirect("/baz");
expect(t.router.state.fetchers.get(key)?.state).toBe("idle");

await B.loaders.root.resolve("ROOT*");
await B.loaders.bar.resolve("BAR");
expect(t.router.state).toMatchObject({
navigation: IDLE_NAVIGATION,
location: { pathname: "/bar" },
loaderData: {
root: "ROOT*",
bar: "BAR",
},
});
expect(t.router.state.fetchers.get(key)?.state).toBe("idle");
expect(t.router.state.fetchers.get(key)?.data).toBeUndefined();
});
});

describe(`
A) fetch GET /foo |--R
B) nav GET /bar |---O
`, () => {
it("ignores loader redirect navigation if preceded by a normal navigation", async () => {
let key = "key";
let t = initializeTmTest();

// Start a fetch load and interrupt with a navigation
let A = await t.fetch("/foo", key);
let B = await t.navigate("/bar", undefined, ["foo"]);

// The fetcher loader redirect should be ignored
await A.loaders.foo.redirect("/baz");
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

// The navigation should trigger the fetcher to revalidate since it's
// not yet "completed". If it returns data this time that should be
// reflected
await B.loaders.bar.resolve("BAR");
await B.loaders.foo.resolve("FOO");

expect(t.router.state).toMatchObject({
navigation: IDLE_NAVIGATION,
location: { pathname: "/bar" },
loaderData: {
root: "ROOT",
bar: "BAR",
},
});
expect(t.router.state.fetchers.get(key)?.state).toBe("idle");
expect(t.router.state.fetchers.get(key)?.data).toBe("FOO");
});

it("processes second fetcher load redirect after interruption by normal navigation", async () => {
let key = "key";
let t = initializeTmTest();

// Start a fetch load and interrupt with a navigation
let A = await t.fetch("/foo", key, "root");
let B = await t.navigate("/bar", undefined, ["foo"]);

// The fetcher loader redirect should be ignored
await A.loaders.foo.redirect("/baz");
expect(t.router.state).toMatchObject({
navigation: { location: { pathname: "/bar" } },
location: { pathname: "/" },
});
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

// The navigation should trigger the fetcher to revalidate since it's
// not yet "completed". If it redirects again we should follow that
await B.loaders.bar.resolve("BAR");
let C = await B.loaders.foo.redirect(
"/foo/bar",
undefined,
undefined,
["foo"]
);
expect(t.router.state).toMatchObject({
navigation: { location: { pathname: "/foo/bar" } },
location: { pathname: "/" },
loaderData: {
root: "ROOT",
},
});
expect(t.router.state.fetchers.get(key)?.state).toBe("loading");

// The fetcher should not revalidate here since it triggered the redirect
await C.loaders.foobar.resolve("FOOBAR");
expect(t.router.state).toMatchObject({
navigation: IDLE_NAVIGATION,
location: { pathname: "/foo/bar" },
loaderData: {
root: "ROOT",
foobar: "FOOBAR",
},
});
expect(t.router.state.fetchers.get(key)?.state).toBe("idle");
expect(t.router.state.fetchers.get(key)?.data).toBe(undefined);
});

it("handle multiple fetcher loader redirects", async () => {
let keyA = "a";
let keyB = "b";
let t = initializeTmTest();

// Start 2 fetch loads
let A = await t.fetch("/foo", keyA, "root");
let B = await t.fetch("/bar", keyB, "root");

// Return a redirect from the second fetcher.load (which will trigger
// a revalidation of the first fetcher)
let C = await B.loaders.bar.redirect("/baz", undefined, undefined, [
"foo",
]);
expect(t.router.state).toMatchObject({
navigation: { location: { pathname: "/baz" } },
location: { pathname: "/" },
});
expect(t.router.state.fetchers.get(keyA)?.state).toBe("loading");
expect(t.router.state.fetchers.get(keyB)?.state).toBe("loading");

// The original fetch load redirect should be ignored
await A.loaders.foo.redirect("/foo/bar");
expect(t.router.state).toMatchObject({
navigation: { location: { pathname: "/baz" } },
location: { pathname: "/" },
});
expect(t.router.state.fetchers.get(keyA)?.state).toBe("loading");
expect(t.router.state.fetchers.get(keyB)?.state).toBe("loading");

// Resolve the navigation loader and the revalidating (first) fetcher
// loader which redirects again
await C.loaders.baz.resolve("BAZ");
let D = await C.loaders.foo.redirect("/foo/bar");
expect(t.router.state).toMatchObject({
navigation: { location: { pathname: "/foo/bar" } },
location: { pathname: "/" },
loaderData: {
root: "ROOT",
},
});
expect(t.router.state.fetchers.get(keyA)?.state).toBe("loading");
expect(t.router.state.fetchers.get(keyB)?.state).toBe("loading");

// Resolve the navigation loader, bringing everything back to idle at
// the final location
await D.loaders.foobar.resolve("FOOBAR");
expect(t.router.state).toMatchObject({
navigation: IDLE_NAVIGATION,
location: { pathname: "/foo/bar" },
loaderData: {
root: "ROOT",
foobar: "FOOBAR",
},
});
expect(t.router.state.fetchers.get(keyA)?.state).toBe("idle");
expect(t.router.state.fetchers.get(keyB)?.state).toBe("idle");
});
});
});

describe("fetcher revalidation", () => {
Expand Down
72 changes: 56 additions & 16 deletions packages/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1591,7 +1591,15 @@ export function createRouter(init: RouterInit): Router {
// If any loaders returned a redirect Response, start a new REPLACE navigation
let redirect = findRedirect(results);
if (redirect) {
await startRedirectNavigation(state, redirect, { replace });
if (redirect.idx >= matchesToLoad.length) {
// If this redirect came from a fetcher make sure we mark it in
// fetchRedirectIds so it doesn't get revalidated on the next set of
// loader executions
let fetcherKey =
revalidatingFetchers[redirect.idx - matchesToLoad.length].key;
fetchRedirectIds.add(fetcherKey);
}
await startRedirectNavigation(state, redirect.result, { replace });
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adding to fetchRedirectIds avoids redirect loops of revalidating fetchers

return { shortCircuited: true };
}

Expand Down Expand Up @@ -1739,6 +1747,7 @@ export function createRouter(init: RouterInit): Router {
);
fetchControllers.set(key, abortController);

let originatingLoadId = incrementingLoadId;
let actionResult = await callLoaderOrAction(
"action",
fetchRequest,
Expand All @@ -1760,15 +1769,26 @@ export function createRouter(init: RouterInit): Router {

if (isRedirectResult(actionResult)) {
fetchControllers.delete(key);
fetchRedirectIds.add(key);
let loadingFetcher = getLoadingFetcher(submission);
state.fetchers.set(key, loadingFetcher);
updateState({ fetchers: new Map(state.fetchers) });

return startRedirectNavigation(state, actionResult, {
submission,
isFetchActionRedirect: true,
});
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
let doneFetcher = getDoneFetcher(undefined);
state.fetchers.set(key, doneFetcher);
updateState({ fetchers: new Map(state.fetchers) });
return;
Comment on lines +1777 to +1780
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Flip submitting fetchers to done if they come back with a redirect after we've already started a separate navigation

} else {
fetchRedirectIds.add(key);
let loadingFetcher = getLoadingFetcher(submission);
state.fetchers.set(key, loadingFetcher);
updateState({ fetchers: new Map(state.fetchers) });

return startRedirectNavigation(state, actionResult, {
submission,
isFetchActionRedirect: true,
});
}
}

// Process any non-redirect errors thrown
Expand Down Expand Up @@ -1875,7 +1895,15 @@ export function createRouter(init: RouterInit): Router {

let redirect = findRedirect(results);
if (redirect) {
return startRedirectNavigation(state, redirect);
if (redirect.idx >= matchesToLoad.length) {
// If this redirect came from a fetcher make sure we mark it in
// fetchRedirectIds so it doesn't get revalidated on the next set of
// loader executions
let fetcherKey =
revalidatingFetchers[redirect.idx - matchesToLoad.length].key;
fetchRedirectIds.add(fetcherKey);
}
return startRedirectNavigation(state, redirect.result);
}

// Process and commit output from loaders
Expand Down Expand Up @@ -1962,6 +1990,7 @@ export function createRouter(init: RouterInit): Router {
);
fetchControllers.set(key, abortController);

let originatingLoadId = incrementingLoadId;
let result: DataResult = await callLoaderOrAction(
"loader",
fetchRequest,
Expand Down Expand Up @@ -1994,9 +2023,18 @@ export function createRouter(init: RouterInit): Router {

// If the loader threw a redirect Response, start a new REPLACE navigation
if (isRedirectResult(result)) {
fetchRedirectIds.add(key);
await startRedirectNavigation(state, result);
return;
if (pendingNavigationLoadId > originatingLoadId) {
// A new navigation was kicked off after our loader started, so that
// should take precedence over this redirect navigation
let doneFetcher = getDoneFetcher(undefined);
state.fetchers.set(key, doneFetcher);
updateState({ fetchers: new Map(state.fetchers) });
return;
Comment on lines +2029 to +2032
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Flip loading fetchers to done if they come back with a redirect after we've already started a separate navigation. Note that they'll be eligible for revalidation so they'll run again and if they redirect that time it will be processed

} else {
fetchRedirectIds.add(key);
await startRedirectNavigation(state, result);
return;
}
}

// Process any non-redirect errors thrown
Expand Down Expand Up @@ -4090,11 +4128,13 @@ function getInternalRouterError(
}

// Find any returned redirect errors, starting from the lowest match
function findRedirect(results: DataResult[]): RedirectResult | undefined {
function findRedirect(
results: DataResult[]
): { result: RedirectResult; idx: number } | undefined {
for (let i = results.length - 1; i >= 0; i--) {
let result = results[i];
if (isRedirectResult(result)) {
return result;
return { result, idx: i };
}
}
}
Expand Down