From 41100b9ed4ab862cf94a12d3e0ca9da281c88b41 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 16 Sep 2024 14:10:25 -0400 Subject: [PATCH] Fix issues with contextual routing and index params --- .changeset/calm-wombats-design.md | 8 + .../__tests__/data-browser-router-test.tsx | 280 +++++++++++++++++- packages/react-router-dom/index.tsx | 8 +- packages/router/router.ts | 27 +- 4 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 .changeset/calm-wombats-design.md diff --git a/.changeset/calm-wombats-design.md b/.changeset/calm-wombats-design.md new file mode 100644 index 0000000000..5b74838f7a --- /dev/null +++ b/.changeset/calm-wombats-design.md @@ -0,0 +1,8 @@ +--- +"react-router-dom": patch +"react-router": patch +"@remix-run/router": patch +--- + +- Fix bug when submitting to the current contextual route (parent route with an index child) when an `?index` param already exists from a prior submission +- Fix `useFormAction` bug - when removing `?index` param it would not keep other non-Remix `index` params 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 8d6e1f95d4..a1b251c8cb 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -3000,6 +3000,280 @@ function testDomRouter( }); }); + describe("submitting to self from parent/index when ?index param exists", () => { + it("useSubmit", async () => { + let router = createTestRouter( + createRoutesFromElements( + } + action={({ request }) => "PARENT ACTION: " + request.url} + > + } + action={({ request }) => "INDEX ACTION: " + request.url} + /> + + ), + { + window: getWindow("/parent?index&index=keep"), + } + ); + let { container } = render(); + + function Parent() { + let actionData = useActionData(); + let submit = useSubmit(); + return ( + <> +

{actionData}

+ + + + ); + } + + function Index() { + let actionData = useActionData(); + let submit = useSubmit(); + return ( + <> +

{actionData}

+ + + ); + } + + fireEvent.click(screen.getByText("Submit from parent")); + await tick(); + await waitFor(() => screen.getByText(new RegExp("PARENT ACTION"))); + expect(getHtml(container.querySelector("#parent")!)).toContain( + "PARENT ACTION: http://localhost/parent?index=keep" + ); + + fireEvent.click(screen.getByText("Submit from index")); + await tick(); + await waitFor(() => screen.getByText(new RegExp("INDEX ACTION"))); + expect(getHtml(container.querySelector("#index")!)).toContain( + "INDEX ACTION: http://localhost/parent?index&index=keep" + ); + }); + + it("Form", async () => { + let router = createTestRouter( + createRoutesFromElements( + } + action={({ request }) => "PARENT ACTION: " + request.url} + > + } + action={({ request }) => "INDEX ACTION: " + request.url} + /> + + ), + { + window: getWindow("/parent?index&index=keep"), + } + ); + let { container } = render(); + + function Parent() { + let actionData = useActionData(); + return ( + <> +

{actionData}

+
+ +
+ + + ); + } + + function Index() { + let actionData = useActionData(); + return ( + <> +

{actionData}

+
+ +
+ + ); + } + + expect( + container.querySelector("#parent-form")?.getAttribute("action") + ).toBe("/parent?index=keep"); + expect( + container.querySelector("#index-form")?.getAttribute("action") + ).toBe("/parent?index&index=keep"); + + fireEvent.click(screen.getByText("Submit from parent")); + await tick(); + await waitFor(() => screen.getByText(new RegExp("PARENT ACTION"))); + expect(getHtml(container.querySelector("#parent")!)).toContain( + "PARENT ACTION: http://localhost/parent?index=keep" + ); + + fireEvent.click(screen.getByText("Submit from index")); + await tick(); + await waitFor(() => screen.getByText(new RegExp("INDEX ACTION"))); + expect(getHtml(container.querySelector("#index")!)).toContain( + "INDEX ACTION: http://localhost/parent?index&index=keep" + ); + }); + + it("fetcher.submit", async () => { + let router = createTestRouter( + createRoutesFromElements( + } + action={({ request }) => "PARENT ACTION: " + request.url} + > + } + action={({ request }) => "INDEX ACTION: " + request.url} + /> + + ), + { + window: getWindow("/parent?index&index=keep"), + } + ); + let { container } = render(); + + function Parent() { + let fetcher = useFetcher(); + + return ( + <> +

{fetcher.data}

+ + + + ); + } + + function Index() { + let fetcher = useFetcher(); + + return ( + <> +

{fetcher.data}

+ + + ); + } + + fireEvent.click(screen.getByText("Submit from parent")); + await tick(); + await waitFor(() => screen.getByText(new RegExp("PARENT ACTION"))); + expect(getHtml(container.querySelector("#parent")!)).toContain( + "PARENT ACTION: http://localhost/parent?index=keep" + ); + + fireEvent.click(screen.getByText("Submit from index")); + await tick(); + await waitFor(() => screen.getByText(new RegExp("INDEX ACTION"))); + expect(getHtml(container.querySelector("#index")!)).toContain( + "INDEX ACTION: http://localhost/parent?index&index=keep" + ); + }); + + it("fetcher.Form", async () => { + let router = createTestRouter( + createRoutesFromElements( + } + action={({ request }) => "PARENT ACTION: " + request.url} + > + } + action={({ request }) => "INDEX ACTION: " + request.url} + /> + + ), + { + window: getWindow("/parent?index&index=keep"), + } + ); + let { container } = render(); + + function Parent() { + let fetcher = useFetcher(); + + return ( + <> +

{fetcher.data}

+ + + + + + ); + } + + function Index() { + let fetcher = useFetcher(); + + return ( + <> +

{fetcher.data}

+ + + + + ); + } + + expect( + container.querySelector("#parent-form")?.getAttribute("action") + ).toBe("/parent?index=keep"); + expect( + container.querySelector("#index-form")?.getAttribute("action") + ).toBe("/parent?index&index=keep"); + + fireEvent.click(screen.getByText("Submit from parent")); + await tick(); + await waitFor(() => screen.getByText(new RegExp("PARENT ACTION"))); + expect(getHtml(container.querySelector("#parent")!)).toContain( + "PARENT ACTION: http://localhost/parent?index=keep" + ); + + fireEvent.click(screen.getByText("Submit from index")); + await tick(); + await waitFor(() => screen.getByText(new RegExp("INDEX ACTION"))); + expect(getHtml(container.querySelector("#index")!)).toContain( + "INDEX ACTION: http://localhost/parent?index&index=keep" + ); + }); + }); + it("allows user to specify search params and hash", async () => { let router = createTestRouter( createRoutesFromElements( @@ -5370,9 +5644,9 @@ function testDomRouter( let { container } = render(); expect(container.innerHTML).not.toMatch(/my-key/); await waitFor(() => - // React `useId()` results in either `:r2a:` or `:rp:` depending on - // `DataBrowserRouter`/`DataHashRouter` - expect(container.innerHTML).toMatch(/(:r2a:|:rp:),my-key/) + // React `useId()` results in something such as `:r2a:`, `:r2i:`, + // `:rt:`, or `:rp:` depending on `DataBrowserRouter`/`DataHashRouter` + expect(container.innerHTML).toMatch(/(:r[0-9]?[a-z]:),my-key/) ); }); }); diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 0643a8ba82..668487539e 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -1635,9 +1635,13 @@ export function useFormAction( // since it might not apply to our contextual route. We add it back based // on match.route.index below let params = new URLSearchParams(path.search); - if (params.has("index") && params.get("index") === "") { + let indexValues = params.getAll("index"); + let hasNakedIndexParam = indexValues.some((v) => v === ""); + if (hasNakedIndexParam) { params.delete("index"); - path.search = params.toString() ? `?${params.toString()}` : ""; + indexValues.filter((v) => v).forEach((v) => params.append("index", v)); + let qs = params.toString(); + path.search = qs ? `?${qs}` : ""; } } diff --git a/packages/router/router.ts b/packages/router/router.ts index 4a835c5ca9..bfda0837b7 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -4169,16 +4169,23 @@ function normalizeTo( path.hash = location.hash; } - // Add an ?index param for matched index routes if we don't already have one - if ( - (to == null || to === "" || to === ".") && - activeRouteMatch && - activeRouteMatch.route.index && - !hasNakedIndexQuery(path.search) - ) { - path.search = path.search - ? path.search.replace(/^\?/, "?index&") - : "?index"; + // Account for `?index` params when routing to the current location + if ((to == null || to === "" || to === ".") && activeRouteMatch) { + let nakedIndex = hasNakedIndexQuery(path.search); + if (activeRouteMatch.route.index && !nakedIndex) { + // Add one when we're targeting an index route + path.search = path.search + ? path.search.replace(/^\?/, "?index&") + : "?index"; + } else if (!activeRouteMatch.route.index && nakedIndex) { + // Remove existing ones when we're not + let params = new URLSearchParams(path.search); + let indexValues = params.getAll("index"); + params.delete("index"); + indexValues.filter((v) => v).forEach((v) => params.append("index", v)); + let qs = params.toString(); + path.search = qs ? `?${qs}` : ""; + } } // If we're operating within a basename, prepend it to the pathname. If