diff --git a/.changeset/raw-payload-submission-router.md b/.changeset/raw-payload-submission-router.md new file mode 100644 index 0000000000..225337fc46 --- /dev/null +++ b/.changeset/raw-payload-submission-router.md @@ -0,0 +1,20 @@ +--- +"@remix-run/router": minor +--- + +Add support for a new `payload` parameter for `router.navigate`/`router.fetch` submissions. This allows you to submit data to an `action` without requiring serialization into a `FormData` instance. This `payload` value will be passed unaltered to your `action` function. + +```js +router.navigate("/", { payload: { key: "value" } }); + +function action({ request, payload }) { + // payload => { key: 'value' } + // request.body => null +} +``` + +You may also opt-into serialization of this `payload` into your `request` using the `formEncType` parameter: + +- `formEncType: "application/x-ww-form-urlencoded"` => serializes into `request.formData()` +- `formEncType: "application/json"` => serializes into `request.json()` +- `formEncType: "text/plain"` => serializes into `request.text()` diff --git a/.changeset/raw-payload-submission.md b/.changeset/raw-payload-submission.md new file mode 100644 index 0000000000..8795bb0925 --- /dev/null +++ b/.changeset/raw-payload-submission.md @@ -0,0 +1,105 @@ +--- +"react-router-dom": minor +--- + +- Support better submission and control of serialization of raw payloads through `useSubmit`/`fetcher.submit`. The default `encType` will still be `application/x-www-form-urlencoded` as it is today, but actions will now also receive a raw `payload` parameter when you submit a raw value (not an HTML element, `FormData`, or `URLSearchParams`). + +The default behavior will still serialize into `FormData`: + +```jsx +function Component() { + let submit = useSubmit(); + submit({ key: "value" }); + // navigation.formEncType => "application/x-www-form-urlencoded" + // navigation.formData => FormData instance + // navigation.payload => { key: "Value" } +} + +function action({ request, payload }) { + // request.headers.get("Content-Type") => "application/x-www-form-urlencoded" + // request.formData => FormData instance + // payload => { key: 'value' } +} +``` + +You may opt out of this default serialization using `encType: null`: + +```jsx +function Component() { + let submit = useSubmit(); + submit({ key: "value" }, { encType: null }); + // navigation.formEncType => null + // navigation.formData => undefined + // navigation.payload => { key: "Value" } +} + +function action({ request, payload }) { + // request.headers.get("Content-Type") => null + // request.formData => undefined + // payload => { key: 'value' } +} +``` + +_Note: we plan to change the default behavior of `{ encType: undefined }` to match this "no serialization" behavior in React Router v7. In order to better prepare for this change, we encourage developers to add explicit content types to scenarios in which they are submitting raw JSON objects:_ + +```jsx +function Component() { + let submit = useSubmit(); + + // Change this: + submit({ key: "value" }); + + // To this: + submit({ key: "value" }, { encType: "application/x-www-form-urlencoded" }); +} +``` + +- You may now also opt-into different types of serialization of this `payload` into your `request` using the `formEncType` parameter: + +```js +function Component() { + let submit = useSubmit(); + submit({ key: "value" }, { encType: "application/json" }); + // navigation.formEncType => "application/json" + // navigation.formData => undefined + // navigation.payload => { key: "Value" } +} + +function action({ request, payload }) { + // request.headers.get("Content-Type") => "application/json" + // request.json => { key: 'value' } + // payload => { key: 'value' } +} +``` + +```js +function Component() { + let submit = useSubmit(); + submit({ key: "value" }, { encType: "application/x-www-form-urlencoded" }); + // navigation.formEncType => "application/x-www-form-urlencoded" + // navigation.formData => FormData instance + // navigation.payload => { key: "Value" } +} + +function action({ request, payload }) { + // request.headers.get("Content-Type") => "application/x-www-form-urlencoded" + // request.formData => { key: 'value' } + // payload => { key: 'value' } +} +``` + +```js +function Component() { + let submit = useSubmit(); + submit("Plain ol' text", { encType: "text/plain" }); + // navigation.formEncType => "text/plain" + // navigation.formData => undefined + // navigation.payload => "Plain ol' text" +} + +function action({ request, payload }) { + // request.headers.get("Content-Type") => "text/plain" + // request.text => "Plain ol' text" + // payload => "Plain ol' text" +} +``` diff --git a/docs/hooks/use-fetcher.md b/docs/hooks/use-fetcher.md index 9f8b13f5d2..8f2d0f6d89 100644 --- a/docs/hooks/use-fetcher.md +++ b/docs/hooks/use-fetcher.md @@ -38,6 +38,7 @@ function SomeComponent() { // build your UI with these properties fetcher.state; fetcher.formData; + fetcher.payload; fetcher.formMethod; fetcher.formAction; fetcher.data; @@ -132,6 +133,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 +203,8 @@ function TaskCheckbox({ task }) { } ``` +If you opt-out of serialization using `encType: null`, then `fetcher.formData` will be `undefined` and your data will be exposed on `fetcher.payload`. + ## `fetcher.formAction` Tells you the action url the form is being submitted to. @@ -224,6 +229,10 @@ fetcher.formMethod; // "post" The `fetcher.formMethod` field is lowercase without the `future.v7_normalizeFormMethod` [Future Flag][api-development-strategy]. This is being normalized to uppercase to align with the `fetch()` behavior in v7, so please upgrade your React Router v6 applications to adopt the uppercase HTTP methods. +## `fetcher.payload` + +Any POST, PUT, PATCH, or DELETE that started from a `fetcher.submit(payload, { encType: null })` will have your `payload` value represented in `fetcher.payload`. + [loader]: ../route/loader [action]: ../route/action [pickingarouter]: ../routers/picking-a-router @@ -231,3 +240,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..cebf09287c 100644 --- a/docs/hooks/use-navigation.md +++ b/docs/hooks/use-navigation.md @@ -23,6 +23,7 @@ function SomeComponent() { navigation.state; navigation.location; navigation.formData; + navigation.payload; navigation.formAction; navigation.formMethod; } @@ -90,8 +91,14 @@ let isRedirecting = Any POST, PUT, PATCH, or DELETE navigation that started from a `
` or `useSubmit` will have your form's submission data attached to it. This is primarily useful to build "Optimistic UI" with the `submission.formData` [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object. +If you opt-out of serialization using `encType: null`, then `navigation.formData` will be `undefined` and your data will be exposed on `navigation.payload`. + In the case of a GET form submission, `formData` will be empty and the data will be reflected in `navigation.location.search`. +## `navigation.payload` + +Any POST, PUT, PATCH, or DELETE navigation that started from a `useSubmit(payload, { encType: null })` will have your `payload` value represented in `navigation.payload`. + ## `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..ac084c7614 100644 --- a/docs/hooks/use-submit.md +++ b/docs/hooks/use-submit.md @@ -78,6 +78,48 @@ formData.append("cheese", "gouda"); submit(formData); ``` +### Payload Serialization + +You may also submit raw JSON to your `action` and the default behavior will be to encode the key/values into `FormData`: + +```tsx +let obj = { key: "value" }; +submit(obj); // -> request.formData() +``` + +You may also choose which type of serialization you'd like via the `encType` option: + +```tsx +let obj = { key: "value" }; +submit(obj, { encType: 'application/x-www-form-urlencoded' }); // -> request.formData() +``` + +```tsx +let obj = { key: "value" }; +submit(obj, { encType: "application/json" }); // -> request.json() +``` + +```tsx +let text = "Plain ol' text"; +submit(obj, { encType: "text/plain" }); // -> request.text() +``` + +In future versions of React Router, the default behavior will not serialize raw JSON payloads. If you are submitting raw JSON today it's recommended to specify an explicit `encType`. + +### Opting out of serialization + +Sometimes in a client-side application, it's overkill to require serialization into `request.formData` when you have a raw JSON object in your component and want to submit it to your `action` directly. If you'd like to opt out of serialization, you can pass `encType: null` to your second options argument, and your data will be sent to your action function verbatim as a `payload` parameter: + +```tsx +let obj = { key: "value" }; +submit(obj, { encType: null }); + +function action({ request, payload }) { + // payload is `obj` from your component + // request.body === null +} +``` + ## Submit options The second argument is a set of options that map directly to form submission attributes: diff --git a/docs/route/action.md b/docs/route/action.md index 733a79af54..0fbfb31c6c 100644 --- a/docs/route/action.md +++ b/docs/route/action.md @@ -101,6 +101,22 @@ 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()`. + +## `payload` + +A `payload` is provided to your action when you submit imperatively with [`useSubmit`][usesubmit] and provide a raw javascript value. This value might also be serialized into the request depending on the `encType`. + +```jsx +function Component { + let submit = useSubmit(); + submit({ key: "value" }, { encType: null }); + // action payload is { key: 'value' } +} +``` + ## 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]. @@ -144,5 +160,6 @@ For more details and expanded use cases, read the [errorElement][errorelement] d [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 diff --git a/docs/route/should-revalidate.md b/docs/route/should-revalidate.md index 647d1a9cb4..19600bc3ac 100644 --- a/docs/route/should-revalidate.md +++ b/docs/route/should-revalidate.md @@ -66,6 +66,7 @@ interface ShouldRevalidateFunction { formAction?: Submission["formAction"]; formEncType?: Submission["formEncType"]; formData?: Submission["formData"]; + payload?: Submission["payload"]; actionResult?: DataResult; defaultShouldRevalidate: boolean; }): boolean; diff --git a/package.json b/package.json index 8b95a2d15f..5de4acdadc 100644 --- a/package.json +++ b/package.json @@ -114,10 +114,10 @@ "none": "15.6 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "11.8 kB" + "none": "12 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "17.7 kB" + "none": "17.9 kB" } } } 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..15c3a5e7f3 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -3130,31 +3130,237 @@ 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 payload = { a: "1", b: "2" }; + let navigation; let router = createTestRouter( - createRoutesFromElements( - } /> - ), + [ + { + path: "/", + action: actionSpy, + Component() { + let submit = useSubmit(); + let n = useNavigation(); + if (n.state === "submitting") { + navigation = n; + } + return ( + + ); + }, + }, + ], { window: getWindow("/") } ); render(); - function FormPage() { - let submit = useSubmit(); - return ( - - ); - } + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData?.get("a")).toBe("1"); + expect(navigation.formData?.get("b")).toBe("2"); + expect(navigation.payload).toBe(payload); + let { request, payload: actionPayload } = 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"); + expect(actionPayload).toBe(payload); + }); + + it("serializes formData on submit(object)/encType:application/x-www-form-urlencoded submissions", async () => { + let actionSpy = jest.fn(); + let payload = { 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")); - let formData = await actionSpy.mock.calls[0][0].request.formData(); - expect(formData.get("a")).toBe("1"); - expect(formData.get("b")).toBe("2"); + expect(navigation.formData?.get("a")).toBe("1"); + expect(navigation.formData?.get("b")).toBe("2"); + expect(navigation.payload).toBe(payload); + let { request, payload: actionPayload } = 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"); + expect(actionPayload).toBe(payload); + }); + + it("serializes JSON on submit(object)/encType:application/json submissions", async () => { + let actionSpy = jest.fn(); + let payload = { 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).toBe(undefined); + expect(navigation.payload).toBe(payload); + let { request, payload: actionPayload } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toBe("application/json"); + expect(await request.json()).toEqual({ a: "1", b: "2" }); + expect(actionPayload).toBe(payload); + }); + + it("serializes text on submit(object)/encType:text/plain submissions", async () => { + let actionSpy = jest.fn(); + let payload = "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.formData).toBe(undefined); + expect(navigation.payload).toBe(payload); + let { request, payload: actionPayload } = actionSpy.mock.calls[0][0]; + expect(request.headers.get("Content-Type")).toBe("text/plain"); + expect(await request.text()).toEqual(payload); + expect(actionPayload).toBe(payload); + }); + + it("does not serialize formData on submit(object)/encType:null submissions", async () => { + let actionSpy = jest.fn(); + let payload; + 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(); + + payload = "look ma no formData!"; + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData).toBeUndefined(); + expect(navigation.payload).toBe(payload); + expect(actionSpy.mock.calls[0][0].request.body).toBe(null); + expect(actionSpy.mock.calls[0][0].payload).toBe(payload); + actionSpy.mockReset(); + + payload = { a: "1", b: "2" }; + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData).toBeUndefined(); + expect(navigation.payload).toBe(payload); + expect(actionSpy.mock.calls[0][0].request.body).toBe(null); + expect(actionSpy.mock.calls[0][0].payload).toBe(payload); + actionSpy.mockReset(); + + payload = [1, 2, 3, 4, 5]; + fireEvent.click(screen.getByText("Submit")); + expect(navigation.formData).toBeUndefined(); + expect(navigation.payload).toBe(payload); + expect(actionSpy.mock.calls[0][0].request.body).toBe(null); + expect(actionSpy.mock.calls[0][0].payload).toBe(payload); + actionSpy.mockReset(); + + router.dispose(); }); it("includes submit button name/value on form submission", async () => { @@ -3964,6 +4170,42 @@ function testDomRouter( `); }); + it("does not serialize fetcher.submit(object, { encType: null }) calls", async () => { + let actionSpy = jest.fn(); + let payload = { key: "value" }; + let router = createTestRouter( + [ + { + path: "/", + action: actionSpy, + Component() { + let fetcher = useFetcher(); + return ( + + ); + }, + }, + ], + { + window: getWindow("/"), + } + ); + + render(); + fireEvent.click(screen.getByText("Submit")); + expect(actionSpy.mock.calls[0][0].payload).toEqual(payload); + expect(actionSpy.mock.calls[0][0].request.body).toBe(null); + }); + 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/dom.ts b/packages/react-router-dom/dom.ts index 4be6d69c7c..c2b390a6d4 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -109,6 +109,16 @@ export function getSearchParamsForLocation( return searchParams; } +export type SubmitTarget = + | HTMLFormElement + | HTMLButtonElement + | HTMLInputElement + | FormData + | URLSearchParams + | { [name: string]: string } + | NonNullable // Raw payload submissions + | null; + export interface SubmitOptions { /** * The HTTP method used to submit the form. Overrides ``. @@ -124,9 +134,14 @@ export interface SubmitOptions { /** * The action URL used to submit the form. Overrides ``. - * Defaults to "application/x-www-form-urlencoded". + * Defaults to "application/x-www-form-urlencoded". Specifying `null` will + * opt-out of serialization and will submit the data directly to your action + * in the `payload` parameter. + * + * In v7, the default behavior will change from "application/x-www-form-urlencoded" + * to `null` and will make serialization opt-in */ - encType?: FormEncType; + encType?: FormEncType | null; /** * Set `true` to replace the current entry in the browser's history stack @@ -150,26 +165,21 @@ export interface SubmitOptions { } export function getFormSubmissionInfo( - target: - | HTMLFormElement - | HTMLButtonElement - | HTMLInputElement - | FormData - | URLSearchParams - | { [name: string]: string } - | null, + target: SubmitTarget, options: SubmitOptions, basename: string ): { action: string | null; method: string; - encType: string; - formData: FormData; + encType: string | null; + formData: FormData | undefined; + payload: any; } { let method: string; let action: string | null = null; - let encType: string; - let formData: FormData; + let encType: string | null; + let formData: FormData | undefined = undefined; + let payload: unknown = undefined; if (isFormElement(target)) { let submissionTrigger: HTMLButtonElement | HTMLInputElement = ( @@ -243,6 +253,21 @@ export function getFormSubmissionInfo( `Cannot submit element that is not ,