diff --git a/.changeset/pretty-ravens-film.md b/.changeset/pretty-ravens-film.md
new file mode 100644
index 0000000000..06e0d38465
--- /dev/null
+++ b/.changeset/pretty-ravens-film.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/router": patch
+---
+
+fix: properly handle ?index on fetcher get submissions (#9312)
diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx
index 55ba4e6ad5..fb2fcc6457 100644
--- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx
+++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx
@@ -2270,6 +2270,93 @@ function testDomRouter(
`);
});
+ it("handles fetcher ?index params", async () => {
+ let { container } = render(
+
+ }
+ action={() => "PARENT ACTION"}
+ loader={() => "PARENT LOADER"}
+ >
+ }
+ action={() => "INDEX ACTION"}
+ loader={() => "INDEX LOADER"}
+ />
+
+
+ );
+
+ function Index() {
+ let fetcher = useFetcher();
+
+ return (
+ <>
+
{fetcher.data}
+
+
+
+
+
+
+
+ >
+ );
+ }
+
+ async function clickAndAssert(btnText: string, expectedOutput: string) {
+ fireEvent.click(screen.getByText(btnText));
+ await waitFor(() => screen.getByText(new RegExp(expectedOutput)));
+ expect(getHtml(container.querySelector("#output"))).toContain(
+ expectedOutput
+ );
+ }
+
+ await clickAndAssert("Load parent", "PARENT LOADER");
+ await clickAndAssert("Load index", "INDEX LOADER");
+ await clickAndAssert("Submit empty", "INDEX LOADER");
+ await clickAndAssert("Submit parent get", "PARENT LOADER");
+ await clickAndAssert("Submit index get", "INDEX LOADER");
+ await clickAndAssert("Submit parent post", "PARENT ACTION");
+ await clickAndAssert("Submit index post", "INDEX ACTION");
+ });
+
it("handles fetcher.load errors", async () => {
let { container } = render(
{
});
});
});
+
+ describe("fetcher ?index params", () => {
+ it("hits the proper Routes when ?index params are present", async () => {
+ let t = setup({
+ routes: [
+ {
+ id: "parent",
+ path: "parent",
+ action: true,
+ loader: true,
+ // Turn off revalidation after fetcher action submission for this test
+ shouldRevalidate: () => false,
+ children: [
+ {
+ id: "index",
+ index: true,
+ action: true,
+ loader: true,
+ // Turn off revalidation after fetcher action submission for this test
+ shouldRevalidate: () => false,
+ },
+ ],
+ },
+ ],
+ initialEntries: ["/parent"],
+ hydrationData: { loaderData: { parent: "PARENT", index: "INDEX" } },
+ });
+
+ let key = "KEY";
+
+ // fetcher.load()
+ let A = await t.fetch("/parent", key);
+ await A.loaders.parent.resolve("PARENT LOADER");
+ expect(t.router.getFetcher(key).data).toBe("PARENT LOADER");
+
+ let B = await t.fetch("/parent?index", key);
+ await B.loaders.index.resolve("INDEX LOADER");
+ expect(t.router.getFetcher(key).data).toBe("INDEX LOADER");
+
+ // fetcher.submit({}, { method: 'get' })
+ let C = await t.fetch("/parent", key, {
+ formMethod: "get",
+ formData: createFormData({}),
+ });
+ await C.loaders.parent.resolve("PARENT LOADER");
+ expect(t.router.getFetcher(key).data).toBe("PARENT LOADER");
+
+ let D = await t.fetch("/parent?index", key, {
+ formMethod: "get",
+ formData: createFormData({}),
+ });
+ await D.loaders.index.resolve("INDEX LOADER");
+ expect(t.router.getFetcher(key).data).toBe("INDEX LOADER");
+
+ // fetcher.submit({}, { method: 'post' })
+ let E = await t.fetch("/parent", key, {
+ formMethod: "post",
+ formData: createFormData({}),
+ });
+ await E.actions.parent.resolve("PARENT ACTION");
+ expect(t.router.getFetcher(key).data).toBe("PARENT ACTION");
+
+ let F = await t.fetch("/parent?index", key, {
+ formMethod: "post",
+ formData: createFormData({}),
+ });
+ await F.actions.index.resolve("INDEX ACTION");
+ expect(t.router.getFetcher(key).data).toBe("INDEX ACTION");
+ });
+ });
});
describe("deferred data", () => {
diff --git a/packages/router/router.ts b/packages/router/router.ts
index 140ab20d38..6af50bef5b 100644
--- a/packages/router/router.ts
+++ b/packages/router/router.ts
@@ -1165,7 +1165,7 @@ export function createRouter(init: RouterInit): Router {
return;
}
- let { path, submission } = normalizeNavigateOptions(href, opts);
+ let { path, submission } = normalizeNavigateOptions(href, opts, true);
let match = getTargetMatch(matches, path);
if (submission) {
@@ -2098,7 +2098,8 @@ export function getStaticContextFromError(
// URLSearchParams so they behave identically to links with query params
function normalizeNavigateOptions(
to: To,
- opts?: RouterNavigateOptions
+ opts?: RouterNavigateOptions,
+ isFetcher = false
): {
path: string;
submission?: Submission;
@@ -2134,6 +2135,16 @@ function normalizeNavigateOptions(
let parsedPath = parsePath(path);
try {
let searchParams = convertFormDataToSearchParams(opts.formData);
+ // Since fetcher GET submissions only run a single loader (as opposed to
+ // navigation GET submissions which run all loaders), we need to preserve
+ // any incoming ?index params
+ if (
+ isFetcher &&
+ parsedPath.search &&
+ hasNakedIndexQuery(parsedPath.search)
+ ) {
+ searchParams.append("index", "");
+ }
parsedPath.search = `?${searchParams}`;
} catch (e) {
return {