From 15b96e547c406e77664bd4bedaa7370b3fd96978 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 14 Jul 2022 16:31:42 -0400 Subject: [PATCH 1/5] fix: preserve search params on unspecified form action --- .../__tests__/DataBrowserRouter-test.tsx | 126 ++++++++++++++++++ packages/react-router-dom/index.tsx | 15 ++- packages/router/__tests__/router-test.ts | 24 +++- packages/router/router.ts | 18 +-- 4 files changed, 162 insertions(+), 21 deletions(-) diff --git a/packages/react-router-dom/__tests__/DataBrowserRouter-test.tsx b/packages/react-router-dom/__tests__/DataBrowserRouter-test.tsx index 41c1758acc..67bc811f9e 100644 --- a/packages/react-router-dom/__tests__/DataBrowserRouter-test.tsx +++ b/packages/react-router-dom/__tests__/DataBrowserRouter-test.tsx @@ -1117,6 +1117,132 @@ function testDomRouter( `); }); + it("handles action for
correctly", async () => { + let { container } = render( + + } /> + + ); + + function Home() { + return ( + + + +
+ ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/?a=1#hash" + ); + }); + + it("handles action for
correctly", async () => { + let { container } = render( + + } /> + + ); + + function Home() { + return ( + + + +
+ ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe("/"); + }); + + it("handles action for
correctly", async () => { + let { container } = render( + + } /> + + ); + + function Home() { + return ( + + + +
+ ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/?a=1#hash" + ); + }); + + it("handles action for
correctly", async () => { + let { container } = render( + + } /> + + ); + + function Home() { + return ( + + + +
+ ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe("/"); + }); + + it("handles ?index param for action
", async () => { + let { container } = render( + + + } /> + + + ); + + function Home() { + return ( + + + +
+ ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/?index" + ); + }); + + it("handles ?index param for action
", async () => { + let { container } = render( + + + } /> + + + ); + + function Home() { + return ( + + + +
+ ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/?index" + ); + }); + describe("useFetcher(s)", () => { it("handles fetcher.load and fetcher.submit", async () => { let count = 0; diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 5a45117932..761e06df12 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -566,7 +566,7 @@ const FormImpl = React.forwardRef( { replace, method = defaultMethod, - action = ".", + action, onSubmit, fetcherKey, routeId, @@ -805,18 +805,21 @@ function useSubmitImpl(fetcherKey?: string, routeId?: string): SubmitFunction { ); } -export function useFormAction(action = "."): string { +export function useFormAction(action?: string): string { let routeContext = React.useContext(UNSAFE_RouteContext); invariant(routeContext, "useFormAction must be used inside a RouteContext"); + let location = useLocation(); let [match] = routeContext.matches.slice(-1); - let { pathname, search } = useResolvedPath(action); + let path = useResolvedPath(action || location); - if (action === "." && match.route.index) { - search = search ? search.replace(/^\?/, "?index&") : "?index"; + if ((!action || action === ".") && match.route.index) { + path.search = path.search + ? path.search.replace(/^\?/, "?index&") + : "?index"; } - return pathname + search; + return createPath(path); } function createFetcherForm(fetcherKey: string, routeId: string) { diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 3ff909b4d8..434ede8117 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -3320,7 +3320,7 @@ describe("a router", () => { expect(t.router.state.navigation.formData).toBeUndefined(); }); - it("merges URLSearchParams for formMethod=get", async () => { + it("does not preserve existing 'action' URLSearchParams for formMethod='get'", async () => { let t = setup({ routes: TASK_ROUTES, initialEntries: ["/"], @@ -3332,12 +3332,32 @@ describe("a router", () => { expect(t.router.state.navigation.state).toBe("loading"); expect(t.router.state.navigation.location).toMatchObject({ pathname: "/tasks", - search: "?key=1&key=2", + search: "?key=2", }); expect(t.router.state.navigation.formMethod).toBeUndefined(); expect(t.router.state.navigation.formData).toBeUndefined(); }); + it("preserves existing 'action' URLSearchParams for formMethod='post'", async () => { + let t = setup({ + routes: TASK_ROUTES, + initialEntries: ["/"], + }); + await t.navigate("/tasks?key=1", { + formMethod: "post", + formData: createFormData({ key: "2" }), + }); + expect(t.router.state.navigation.state).toBe("submitting"); + expect(t.router.state.navigation.location).toMatchObject({ + pathname: "/tasks", + search: "?key=1", + }); + expect(t.router.state.navigation.formMethod).toBe("post"); + expect(t.router.state.navigation.formData).toEqual( + createFormData({ key: "2" }) + ); + }); + it("returns a 400 error if binary data is attempted to be submitted using formMethod=GET", async () => { let t = setup({ routes: TASK_ROUTES, diff --git a/packages/router/router.ts b/packages/router/router.ts index 485fdc2d41..b5f578cdee 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -2013,12 +2013,9 @@ function normalizeNavigateOptions( // Flatten submission onto URLSearchParams for GET submissions let parsedPath = parsePath(path); - let searchParams; try { - searchParams = convertFormDataToSearchParams( - opts.formData, - parsedPath.search - ); + let searchParams = convertFormDataToSearchParams(opts.formData); + parsedPath.search = `?${searchParams}`; } catch (e) { return { path, @@ -2030,9 +2027,7 @@ function normalizeNavigateOptions( }; } - return { - path: createPath({ ...parsedPath, search: `?${searchParams}` }), - }; + return { path: createPath(parsedPath) }; } function getLoaderRedirect( @@ -2307,11 +2302,8 @@ function createRequest( return new Request(url, init); } -function convertFormDataToSearchParams( - formData: FormData, - initialSearchParams?: string -): URLSearchParams { - let searchParams = new URLSearchParams(initialSearchParams); +function convertFormDataToSearchParams(formData: FormData): URLSearchParams { + let searchParams = new URLSearchParams(); for (let [key, value] of formData.entries()) { invariant( From b6e2c7f16ea1c49f4e9bb91b1e9a7e1e791a1126 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 3 Aug 2022 14:28:16 -0400 Subject: [PATCH 2/5] stengthen index tests --- .../react-router-dom/__tests__/data-browser-router-test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 535e87cc0a..2c5a842fbe 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -1281,7 +1281,7 @@ function testDomRouter( it("handles ?index param for action
", async () => { let { container } = render( - + } /> @@ -1298,13 +1298,13 @@ function testDomRouter( } expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/?index" + "/?index&a=1" ); }); it("handles ?index param for action ", async () => { let { container } = render( - + } /> From 6f4bc0d11f7d97f5d05071e6b507a2efb0a09e9a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 8 Aug 2022 10:15:55 -0400 Subject: [PATCH 3/5] Add useFormAction tests for nested routes --- .../__tests__/data-browser-router-test.tsx | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) 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 2c5a842fbe..8c719beec9 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -1220,6 +1220,34 @@ function testDomRouter( ); }); + it("handles action for correctly in nested routes", async () => { + let { container } = render( + + + + } /> + + + + ); + + function Component() { + return ( + + + + + ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?a=1#hash" + ); + }); + it("handles action for
correctly", async () => { let { container } = render( @@ -1239,6 +1267,34 @@ function testDomRouter( expect(container.querySelector("form")?.getAttribute("action")).toBe("/"); }); + it("handles action for correctly in nested routes", async () => { + let { container } = render( + + + + } /> + + + + ); + + function Component() { + return ( + + + + + ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar" + ); + }); + it("handles action for
correctly", async () => { let { container } = render( @@ -1260,6 +1316,34 @@ function testDomRouter( ); }); + it("handles action for correctly in nested routes", async () => { + let { container } = render( + + + + } /> + + + + ); + + function Component() { + return ( + + + + + ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?a=1#hash" + ); + }); + it("handles action for
correctly", async () => { let { container } = render( @@ -1279,6 +1363,34 @@ function testDomRouter( expect(container.querySelector("form")?.getAttribute("action")).toBe("/"); }); + it("handles action for correctly in nested routes", async () => { + let { container } = render( + + + + } /> + + + + ); + + function Component() { + return ( + + + + + ); + } + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar" + ); + }); + it("handles ?index param for action
", async () => { let { container } = render( From 3b6fc1101e48bcbb53bf13883a58ba96e36c66e6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Mon, 8 Aug 2022 13:30:09 -0400 Subject: [PATCH 4/5] account for splat routes and empty action --- .../__tests__/data-browser-router-test.tsx | 486 +++++++++++------- packages/react-router-dom/index.tsx | 2 +- 2 files changed, 293 insertions(+), 195 deletions(-) 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 8c719beec9..075db4a469 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -1199,242 +1199,340 @@ function testDomRouter( `); }); - it("handles action for correctly", async () => { - let { container } = render( - - } /> - - ); - - function Home() { + describe("", () => { + function NoActionComponent() { return ( - + ); } - expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/?a=1#hash" - ); - }); - - it("handles action for
correctly in nested routes", async () => { - let { container } = render( - - - - } /> - - - - ); - - function Component() { + function ActionDotComponent() { return ( - +
); } - expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/foo/bar?a=1#hash" - ); - }); - - it("handles action for
correctly", async () => { - let { container } = render( - - } /> - - ); - - function Home() { + function ActionEmptyComponent() { return ( - +
); } - expect(container.querySelector("form")?.getAttribute("action")).toBe("/"); - }); + describe("static routes", () => { + it("uses full URL when no action is specified", async () => { + let { container } = render( + + + + } /> + + + + ); - it("handles action for
correctly in nested routes", async () => { - let { container } = render( - - - - } /> - - - - ); + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?a=1#hash" + ); + }); - function Component() { - return ( - - - -
- ); - } + it("uses current route path when action='.' is specified", async () => { + let { container } = render( + + + + } /> + + + + ); - expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/foo/bar" - ); - }); + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar" + ); + }); - it("handles action for
correctly", async () => { - let { container } = render( - - } /> - - ); + it("uses current route path when action='' is specified", async () => { + let { container } = render( + + + + } /> + + + + ); - function Home() { - return ( - - - -
- ); - } + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar" + ); + }); + }); - expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/?a=1#hash" - ); - }); + describe("layout routes", () => { + it("uses full URL when no action is specified", async () => { + let { container } = render( + + + + }> + Index} /> + + + + + ); - it("handles action for
correctly in nested routes", async () => { - let { container } = render( - - - - } /> - - - - ); + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?a=1#hash" + ); + }); - function Component() { - return ( - - - -
- ); - } + it("uses current route path when action='.' is specified", async () => { + let { container } = render( + + + + }> + Index} /> + + + + + ); - expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/foo/bar?a=1#hash" - ); - }); + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar" + ); + }); - it("handles action for
correctly", async () => { - let { container } = render( - - } /> - - ); + it("uses current route path when action='' is specified", async () => { + let { container } = render( + + + + }> + Index} /> + + + + + ); - function Home() { - return ( - - - -
- ); - } + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar" + ); + }); + }); - expect(container.querySelector("form")?.getAttribute("action")).toBe("/"); - }); + describe("index routes", () => { + it("uses full URL when no action is specified", async () => { + let { container } = render( + + + + + } /> + + + + + ); - it("handles action for
correctly in nested routes", async () => { - let { container } = render( - - - - } /> - - - - ); + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?index&a=1#hash" + ); + }); - function Component() { - return ( - - - -
- ); - } + it("uses current route path when action='.' is specified", async () => { + let { container } = render( + + + + + } /> + + + + + ); - expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/foo/bar" - ); - }); + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?index" + ); + }); - it("handles ?index param for action
", async () => { - let { container } = render( - - - } /> - - - ); + it("uses current route path when action='' is specified", async () => { + let { container } = render( + + + + + } /> + + + + + ); - function Home() { - return ( - - - -
- ); - } + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?index" + ); + }); + }); - expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/?index&a=1" - ); - }); + describe("dynamic routes", () => { + it("uses full URL when no action is specified", async () => { + let { container } = render( + + + + } /> + + + + ); - it("handles ?index param for action
", async () => { - let { container } = render( - - - } /> - - - ); + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?a=1#hash" + ); + }); - function Home() { - return ( - - - -
- ); - } + it("uses current route path when action='.' is specified", async () => { + let { container } = render( + + + + } /> + + + + ); - expect(container.querySelector("form")?.getAttribute("action")).toBe( - "/?index" - ); + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar" + ); + }); + + it("uses current route path when action='' is specified", async () => { + let { container } = render( + + + + } /> + + + + ); + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar" + ); + }); + }); + + describe("splat routes", () => { + it("uses full URL when no action is specified", async () => { + let { container } = render( + + + + } /> + + + + ); + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo/bar?a=1#hash" + ); + }); + + it("uses current route path (excluding splat) when action='.' is specified", async () => { + let { container } = render( + + + + } /> + + + + ); + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo" + ); + }); + + it("uses current route path (excluding splat) when action='' is specified", async () => { + let { container } = render( + + + + } /> + + + + ); + + expect(container.querySelector("form")?.getAttribute("action")).toBe( + "/foo" + ); + }); + }); }); describe("useFetcher(s)", () => { diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 6dfd06e6f6..411ed5bb61 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -857,7 +857,7 @@ export function useFormAction(action?: string): string { let location = useLocation(); let [match] = routeContext.matches.slice(-1); - let path = useResolvedPath(action || location); + let path = useResolvedPath(action ?? location); if ((!action || action === ".") && match.route.index) { path.search = path.search From 4e5925f2afeb87f47761dc77bbf7ffd97515a918 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 10 Aug 2022 09:32:54 -0400 Subject: [PATCH 5/5] add changeset --- .changeset/metal-hounds-remain.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/metal-hounds-remain.md diff --git a/.changeset/metal-hounds-remain.md b/.changeset/metal-hounds-remain.md new file mode 100644 index 0000000000..aa644c2136 --- /dev/null +++ b/.changeset/metal-hounds-remain.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": patch +--- + +fix: unspecified
action should preserve search params (#9060)