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 {