diff --git a/.changeset/raw-payload-submission-router.md b/.changeset/raw-payload-submission-router.md new file mode 100644 index 0000000000..06634bcd9b --- /dev/null +++ b/.changeset/raw-payload-submission-router.md @@ -0,0 +1,44 @@ +--- +"@remix-run/router": minor +--- + +Add support for `application/json` and `text/plain` encodings for `router.navigate`/`router.fetch` submissions. To leverage these encodings, pass your data in a `body` parameter and specify the desired `formEncType`: + +```js +// By default, the encoding is "application/x-www-form-urlencoded" +router.navigate("/", { + formMethod: "post", + body: { key: "value" }, +}); + +function action({ request }) { + // request.formData => FormData instance with entry [key=value] + // request.text => "key=value" +} +``` + +```js +// Pass `formEncType` to opt-into a different encoding +router.navigate("/", { + formMethod: "post", + formEncType: "application/json", + body: { key: "value" }, +}); + +function action({ request }) { + // request.json => { key: "value" } + // request.text => '{ "key":"value" }' +} +``` + +```js +router.navigate("/", { + formMethod: "post", + formEncType: "text/plain", + body: "Text submission", +}); + +function action({ request }) { + // request.text => "Text submission" +} +``` diff --git a/.changeset/raw-payload-submission.md b/.changeset/raw-payload-submission.md new file mode 100644 index 0000000000..ab804c3f02 --- /dev/null +++ b/.changeset/raw-payload-submission.md @@ -0,0 +1,55 @@ +--- +"react-router-dom": minor +--- + +Add support for `application/json` and `text/plain` encodings for `useSubmit`/`fetcher.submit`. To reflect these additional types, `useNavigation`/`useFetcher` now also contain `navigation.json`/`navigation.text` and `fetcher.json`/`fetcher.text` which are getter functions mimicking `request.json` and `request.text`. Just as a `Request` does, if you access one of these methods for the incorrect encoding type, it will throw an Error (i.e. accessing `navigation.formData` when `navigation.formEncType` is `application/json`). + +```jsx +// The default behavior will still serialize as FormData +function Component() { + let navigation = useNavigation(); + let submit = useSubmit(); + submit({ key: "value" }); + // navigation.formEncType => "application/x-www-form-urlencoded" + // navigation.formData => FormData instance + // navigation.text => "key=value" +} + +function action({ request }) { + // request.headers.get("Content-Type") => "application/x-www-form-urlencoded" + // request.formData => FormData instance + // request.text => "key=value" +} +``` + +```js +// Opt-into JSON encoding with `encType: "application/json"` +function Component() { + let submit = useSubmit(); + submit({ key: "value" }, { encType: "application/json" }); + // navigation.formEncType => "application/json" + // navigation.json => { key: "value" } + // navigation.text => '{"key":"value"}' +} + +function action({ request }) { + // request.headers.get("Content-Type") => "application/json" + // request.json => { key: "value" } + // request.text => '{"key":"value"}' +} +``` + +```js +// Opt-into JSON encoding with `encType: "application/json"` +function Component() { + let submit = useSubmit(); + submit("Text submission", { encType: "text/plain" }); + // navigation.formEncType => "text/plain" + // navigation.text => "Text submission" +} + +function action({ request }) { + // request.headers.get("Content-Type") => "text/plain" + // request.text => "Text submission" +} +``` diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 902966fd49..e6794c2ac8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,14 @@ concurrency: jobs: test: - name: 🧪 Test + name: "🧪 Test: (Node: ${{ matrix.node }})" + strategy: + fail-fast: false + matrix: + node: + - 16 + - 18 + runs-on: ubuntu-latest steps: @@ -33,7 +40,7 @@ jobs: with: cache: yarn check-latest: true - node-version-file: ".nvmrc" + node-version: ${{ matrix.node }} - name: Disable GitHub Actions Annotations run: | diff --git a/docs/hooks/use-fetcher.md b/docs/hooks/use-fetcher.md index e4c5567943..5408c5ed19 100644 --- a/docs/hooks/use-fetcher.md +++ b/docs/hooks/use-fetcher.md @@ -38,6 +38,8 @@ function SomeComponent() { // build your UI with these properties fetcher.state; fetcher.formData; + fetcher.json; + fetcher.text; fetcher.formMethod; fetcher.formAction; fetcher.data; @@ -132,6 +134,8 @@ export function useIdleLogout() { } ``` +`fetcher.submit` is a wrapper around a [`useSubmit`][use-submit] call for the fetcher instance, so it also accepts the same options as `useSubmit`. + If you want to submit to an index route, use the [`?index` param][indexsearchparam]. If you find yourself calling this function inside of click handlers, you can probably simplify your code by using `` instead. @@ -200,6 +204,14 @@ function TaskCheckbox({ task }) { } ``` +## `fetcher.json` + +When using `fetcher.submit(data, { formEncType: "application/json" })`, the submitted JSON is available via `fetcher.json`. + +## `fetcher.text` + +When using `fetcher.submit(data, { formEncType: "text/plain" })`, the submitted text is available via `fetcher.text`. + ## `fetcher.formAction` Tells you the action url the form is being submitted to. @@ -231,3 +243,4 @@ fetcher.formMethod; // "post" [link]: ../components/link [form]: ../components/form [api-development-strategy]: ../guides/api-development-strategy +[use-submit]: ./use-submit.md diff --git a/docs/hooks/use-navigation.md b/docs/hooks/use-navigation.md index c3c0009f8f..80e0c1422d 100644 --- a/docs/hooks/use-navigation.md +++ b/docs/hooks/use-navigation.md @@ -23,6 +23,8 @@ function SomeComponent() { navigation.state; navigation.location; navigation.formData; + navigation.json; + navigation.text; navigation.formAction; navigation.formMethod; } @@ -92,6 +94,14 @@ Any POST, PUT, PATCH, or DELETE navigation that started from a `
` or `useS In the case of a GET form submission, `formData` will be empty and the data will be reflected in `navigation.location.search`. +## `navigation.json` + +Any POST, PUT, PATCH, or DELETE navigation that started from a `useSubmit(payload, { encType: "application/json" })` will have your JSON value available in `navigation.json`. + +## `navigation.text` + +Any POST, PUT, PATCH, or DELETE navigation that started from a `useSubmit(payload, { encType: "text/plain" })` will have your text value available in `navigation.text`. + ## `navigation.location` This tells you what the next [location][location] is going to be. diff --git a/docs/hooks/use-submit.md b/docs/hooks/use-submit.md index 99514bcf6f..77838c1ec1 100644 --- a/docs/hooks/use-submit.md +++ b/docs/hooks/use-submit.md @@ -78,9 +78,51 @@ formData.append("cheese", "gouda"); submit(formData); ``` +Or you can submit `URLSearchParams`: + +```tsx +let searchParams = new URLSearchParams(); +searchParams.append("cheese", "gouda"); +submit(searchParams); +``` + +Or anything that the `URLSearchParams` constructor accepts: + +```tsx +submit("cheese=gouda&toasted=yes"); +submit([ + ["cheese", "gouda"], + ["toasted", "yes"], +]); +``` + +The default behavior if you submit a JSON object is to encode the data into `FormData`: + +```tsx +submit({ key: "value" }); +// will serialize into request.formData() in your action +``` + +Or you can opt-into JSON encoding: + +```tsx +submit({ key: "value" }, { encType: "application/json" }); +// will serialize into request.json() in your action + +submit('{"key":"value"}', { encType: "application/json" }); +// will encode into request.json() in your action +``` + +Or plain text: + +```tsx +submit("value", { encType: "text/plain" }); +// will serialize into request.text() in your action +``` + ## Submit options -The second argument is a set of options that map directly to form submission attributes: +The second argument is a set of options that map (mostly) directly to form submission attributes: ```tsx submit(null, { diff --git a/docs/route/action.md b/docs/route/action.md index eaf893456a..8204dcd2af 100644 --- a/docs/route/action.md +++ b/docs/route/action.md @@ -101,6 +101,10 @@ formData.get("lyrics"); For more information on `formData` see [Working with FormData][workingwithformdata]. +### Opt-in serialization types + +Note that when using [`useSubmit`][usesubmit] you may also pass `encType: "application/json"` or `encType: "text/plain"` to instead serialize your payload into `request.json()` or `request.text()`. + ## Returning Responses While you can return anything you want from an action and get access to it from [`useActionData`][useactiondata], you can also return a web [Response][response]. @@ -200,6 +204,7 @@ If a button name/value isn't right for your use case, you could also use a hidde [form]: ../components/form [workingwithformdata]: ../guides/form-data [useactiondata]: ../hooks/use-action-data +[usesubmit]: ../hooks/use-submit [returningresponses]: ./loader#returning-responses [createbrowserrouter]: ../routers/create-browser-router [button]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button diff --git a/docs/route/should-revalidate.md b/docs/route/should-revalidate.md index 647d1a9cb4..461e77e26b 100644 --- a/docs/route/should-revalidate.md +++ b/docs/route/should-revalidate.md @@ -66,6 +66,8 @@ interface ShouldRevalidateFunction { formAction?: Submission["formAction"]; formEncType?: Submission["formEncType"]; formData?: Submission["formData"]; + json?: Submission["json"]; + text?: Submission["text"]; actionResult?: DataResult; defaultShouldRevalidate: boolean; }): boolean; diff --git a/package.json b/package.json index 6fee45572c..60efc1eb52 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,7 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "45 kB" + "none": "46.4 kB" }, "packages/react-router/dist/react-router.production.min.js": { "none": "13.4 kB" @@ -118,10 +118,10 @@ "none": "15.8 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "12.1 kB" + "none": "12.3 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "18.1 kB" + "none": "18.3 kB" } } } diff --git a/packages/react-router-dom/__tests__/concurrent-mode-navigations-test.tsx b/packages/react-router-dom/__tests__/concurrent-mode-navigations-test.tsx index 6e7839a012..d23bb5e295 100644 --- a/packages/react-router-dom/__tests__/concurrent-mode-navigations-test.tsx +++ b/packages/react-router-dom/__tests__/concurrent-mode-navigations-test.tsx @@ -19,7 +19,6 @@ import { waitFor, } from "@testing-library/react"; import { JSDOM } from "jsdom"; -import LazyComponent from "./components//LazyComponent"; describe("Handles concurrent mode features during navigations", () => { function getComponents() { 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 b1a7837f33..ea7b693665 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -9,7 +9,7 @@ import { prettyDOM, } from "@testing-library/react"; import "@testing-library/jest-dom"; -import type { ErrorResponse } from "@remix-run/router"; +import type { ErrorResponse, Fetcher } from "@remix-run/router"; import type { RouteObject } from "react-router-dom"; import { Form, @@ -3130,7 +3130,184 @@ function testDomRouter( expect(formData.get("b")).toBe("2"); }); - it("gathers form data on submit(object) submissions", async () => { + it("serializes formData on submit(object) submissions", async () => { + let actionSpy = jest.fn(); + let body = { a: "1", b: "2" }; + let navigation; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ], + { window: getWindow("/") } + ); + render(); + + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData?.get("a")).toBe("1"); + expect(navigation.formData?.get("b")).toBe("2"); + expect(navigation.text).toBeUndefined(); + expect(navigation.json).toBeUndefined(); + let { request } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toMatchInlineSnapshot( + `"application/x-www-form-urlencoded;charset=UTF-8"` + ); + let actionFormData = await request.formData(); + expect(actionFormData.get("a")).toBe("1"); + expect(actionFormData.get("b")).toBe("2"); + }); + + it("serializes formData on submit(object)/encType:application/x-www-form-urlencoded submissions", async () => { + let actionSpy = jest.fn(); + let body = { a: "1", b: "2" }; + let navigation; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ], + { window: getWindow("/") } + ); + render(); + + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData?.get("a")).toBe("1"); + expect(navigation.formData?.get("b")).toBe("2"); + expect(navigation.text).toBeUndefined(); + expect(navigation.json).toBeUndefined(); + let { request } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toMatchInlineSnapshot( + `"application/x-www-form-urlencoded;charset=UTF-8"` + ); + let actionFormData = await request.formData(); + expect(actionFormData.get("a")).toBe("1"); + expect(actionFormData.get("b")).toBe("2"); + }); + + it("serializes JSON on submit(object)/encType:application/json submissions", async () => { + let actionSpy = jest.fn(); + let body = { a: "1", b: "2" }; + let navigation; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ], + { window: getWindow("/") } + ); + render(); + + fireEvent.click(screen.getByText("Submit")); + expect(navigation.json).toBe(body); + expect(navigation.text).toBeUndefined(); + expect(navigation.formData).toBeUndefined(); + let { request } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toBe("application/json"); + expect(await request.json()).toEqual({ a: "1", b: "2" }); + }); + + it("serializes text on submit(object)/encType:text/plain submissions", async () => { + let actionSpy = jest.fn(); + let body = "look ma, no formData!"; + let navigation; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ], + { window: getWindow("/") } + ); + render(); + + fireEvent.click(screen.getByText("Submit")); + expect(navigation.text).toBe(body); + expect(navigation.formData).toBeUndefined(); + expect(navigation.json).toBeUndefined(); + let { request } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toBe( + "text/plain;charset=UTF-8" + ); + expect(await request.text()).toEqual(body); + }); + + it('serializes into text on { let actionSpy = jest.fn(); let router = createTestRouter( createRoutesFromElements( @@ -3141,20 +3318,22 @@ function testDomRouter( render(); function FormPage() { - let submit = useSubmit(); return ( - + + + + + ); } fireEvent.click(screen.getByText("Submit")); - let formData = await actionSpy.mock.calls[0][0].request.formData(); - expect(formData.get("a")).toBe("1"); - expect(formData.get("b")).toBe("2"); + expect(await actionSpy.mock.calls[0][0].request.text()) + .toMatchInlineSnapshot(` + "a=1 + b=2 + " + `); }); it("includes submit button name/value on form submission", async () => { @@ -3964,6 +4143,175 @@ function testDomRouter( `); }); + it("serializes fetcher.submit(object) as FormData", async () => { + let actionSpy = jest.fn(); + let body = { key: "value" }; + let fetcher: Fetcher; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let f = useFetcher(); + fetcher = f; + return ( + + ); + }, + }, + ], + { + window: getWindow("/"), + } + ); + + render(); + fireEvent.click(screen.getByText("Submit")); + // @ts-expect-error + expect(fetcher.formData?.get("key")).toBe("value"); + // @ts-expect-error + expect(fetcher.text).toBeUndefined(); + // @ts-expect-error + expect(fetcher.json).toBeUndefined(); + let formData = await actionSpy.mock.calls[0][0].request.formData(); + expect(formData.get("key")).toBe("value"); + }); + + it("serializes fetcher.submit(object, { encType:application/x-www-form-urlencoded }) as FormData", async () => { + let actionSpy = jest.fn(); + let body = { key: "value" }; + let fetcher: Fetcher; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let f = useFetcher(); + fetcher = f; + return ( + + ); + }, + }, + ], + { + window: getWindow("/"), + } + ); + + render(); + fireEvent.click(screen.getByText("Submit")); + // @ts-expect-error + expect(fetcher.formData?.get("key")).toBe("value"); + // @ts-expect-error + expect(fetcher.text).toBeUndefined(); + // @ts-expect-error + expect(fetcher.json).toBeUndefined(); + let formData = await actionSpy.mock.calls[0][0].request.formData(); + expect(formData.get("key")).toBe("value"); + }); + + it("serializes fetcher.submit(object, { encType:application/json }) as FormData", async () => { + let actionSpy = jest.fn(); + let body = { key: "value" }; + let fetcher: Fetcher; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let f = useFetcher(); + fetcher = f; + return ( + + ); + }, + }, + ], + { + window: getWindow("/"), + } + ); + + render(); + fireEvent.click(screen.getByText("Submit")); + // @ts-expect-error + expect(fetcher.json).toBe(body); + // @ts-expect-error + expect(fetcher.text).toBeUndefined(); + // @ts-expect-error + expect(fetcher.formData).toBeUndefined(); + let json = await actionSpy.mock.calls[0][0].request.json(); + expect(json).toEqual(body); + }); + + it("serializes fetcher.submit(object, { encType:text/plain }) as text", async () => { + let actionSpy = jest.fn(); + let body = "Look ma, no FormData!"; + let fetcher: Fetcher; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let f = useFetcher(); + fetcher = f; + return ( + + ); + }, + }, + ], + { + window: getWindow("/"), + } + ); + + render(); + fireEvent.click(screen.getByText("Submit")); + // @ts-expect-error + expect(fetcher.text).toBe(body); + // @ts-expect-error + expect(fetcher.formData).toBeUndefined(); + // @ts-expect-error + expect(fetcher.json).toBeUndefined(); + let text = await actionSpy.mock.calls[0][0].request.text(); + expect(text).toEqual(body); + }); + it("show all fetchers via useFetchers and cleans up fetchers on unmount", async () => { let dfd1 = createDeferred(); let dfd2 = createDeferred(); diff --git a/packages/react-router-dom/__tests__/setup.ts b/packages/react-router-dom/__tests__/setup.ts index 077f4fc80b..19483be9ef 100644 --- a/packages/react-router-dom/__tests__/setup.ts +++ b/packages/react-router-dom/__tests__/setup.ts @@ -21,6 +21,7 @@ if (!globalThis.fetch) { // @ts-expect-error globalThis.Response = Response; globalThis.Headers = Headers; + globalThis.Headers = Headers; } if (!globalThis.AbortController) { diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index 4be6d69c7c..37bfdeb214 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -3,7 +3,7 @@ import type { HTMLFormMethod, RelativeRoutingType, } from "@remix-run/router"; -import { stripBasename } from "@remix-run/router"; +import { stripBasename, UNSAFE_warning as warning } from "@remix-run/router"; export const defaultMethod: HTMLFormMethod = "get"; const defaultEncType: FormEncType = "application/x-www-form-urlencoded"; @@ -109,6 +109,23 @@ export function getSearchParamsForLocation( return searchParams; } +// Thanks https://github.com/sindresorhus/type-fest! +type JsonObject = { [Key in string]: JsonValue } & { + [Key in string]?: JsonValue | undefined; +}; +type JsonArray = JsonValue[] | readonly JsonValue[]; +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +export type SubmitTarget = + | HTMLFormElement + | HTMLButtonElement + | HTMLInputElement + | FormData + | URLSearchParams + | JsonValue + | null; + export interface SubmitOptions { /** * The HTTP method used to submit the form. Overrides `
`. @@ -123,7 +140,7 @@ export interface SubmitOptions { action?: string; /** - * The action URL used to submit the form. Overrides ``. + * The encoding used to submit the form. Overrides ``. * Defaults to "application/x-www-form-urlencoded". */ encType?: FormEncType; @@ -149,51 +166,51 @@ export interface SubmitOptions { preventScrollReset?: boolean; } +const supportedFormEncTypes: Set = new Set([ + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", +]); + +function getFormEncType(encType: string | null) { + if (encType != null && !supportedFormEncTypes.has(encType as FormEncType)) { + warning( + false, + `"${encType}" is not a valid \`encType\` for \`\`/\`\` ` + + `and will default to "${defaultEncType}"` + ); + + return null; + } + return encType; +} + export function getFormSubmissionInfo( - target: - | HTMLFormElement - | HTMLButtonElement - | HTMLInputElement - | FormData - | URLSearchParams - | { [name: string]: string } - | null, - options: SubmitOptions, + target: SubmitTarget, basename: string ): { action: string | null; method: string; encType: string; - formData: FormData; + formData: FormData | undefined; + body: any; } { let method: string; - let action: string | null = null; + let action: string | null; let encType: string; - let formData: FormData; + let formData: FormData | undefined; + let body: any; if (isFormElement(target)) { - let submissionTrigger: HTMLButtonElement | HTMLInputElement = ( - options as any - ).submissionTrigger; - - if (options.action) { - action = options.action; - } else { - // When grabbing the action from the element, it will have had the basename - // prefixed to ensure non-JS scenarios work, so strip it since we'll - // re-prefix in the router - let attr = target.getAttribute("action"); - action = attr ? stripBasename(attr, basename) : null; - } - method = options.method || target.getAttribute("method") || defaultMethod; - encType = - options.encType || target.getAttribute("enctype") || defaultEncType; + // When grabbing the action from the element, it will have had the basename + // prefixed to ensure non-JS scenarios work, so strip it since we'll + // re-prefix in the router + let attr = target.getAttribute("action"); + action = attr ? stripBasename(attr, basename) : null; + method = target.getAttribute("method") || defaultMethod; + encType = getFormEncType(target.getAttribute("enctype")) || defaultEncType; formData = new FormData(target); - - if (submissionTrigger && submissionTrigger.name) { - formData.append(submissionTrigger.name, submissionTrigger.value); - } } else if ( isButtonElement(target) || (isInputElement(target) && @@ -209,26 +226,19 @@ export function getFormSubmissionInfo( //