Skip to content

Commit 99c9a0e

Browse files
committed
Remove hard requirement of mutual exclusivity
1 parent e1c71cb commit 99c9a0e

File tree

4 files changed

+118
-50
lines changed

4 files changed

+118
-50
lines changed

.changeset/raw-payload-submission.md

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,45 @@
22
"react-router-dom": minor
33
---
44

5-
- Support submission of raw payloads through `useSubmit`/`fetcher.submit` by opting out of serialization into `request.formData` using `encType: null`. When opting-out of serialization, your data will be passed to the action in a new `payload` parameter:
5+
- 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`).
6+
7+
The default behavior will still serialize into `FormData`:
8+
9+
```jsx
10+
function Component() {
11+
let submit = useSubmit();
12+
submit({ key: "value" });
13+
// navigation.formEncType => "application/x-www-form-urlencoded"
14+
// navigation.formData => FormData instance
15+
// navigation.payload => { key: "Value" }
16+
}
17+
18+
function action({ request, payload }) {
19+
// request.headers.get("Content-Type") => "application/x-www-form-urlencoded"
20+
// request.formData => FormData instance
21+
// payload => { key: 'value' }
22+
}
23+
```
24+
25+
You may opt out of this default serialization using `encType: null`:
626

727
```jsx
828
function Component() {
929
let submit = useSubmit();
1030
submit({ key: "value" }, { encType: null });
31+
// navigation.formEncType => null
32+
// navigation.formData => undefined
33+
// navigation.payload => { key: "Value" }
1134
}
1235

1336
function action({ request, payload }) {
14-
// payload => { key: 'value' }
15-
// request.body => null
37+
// request.headers.get("Content-Type") => null
38+
// request.formData => undefined
39+
// payload => { key: 'value' }
1640
}
1741
```
1842

19-
Since the default behavior in `useSubmit` today is to serialize to `application/x-www-form-urlencoded`, that will remain the behavior for `encType:undefined` in v6. But in v7, we plan to change the default behavior for `undefined` to skip serialization. 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:
43+
_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:_
2044

2145
```jsx
2246
function Component() {
@@ -36,34 +60,46 @@ function Component() {
3660
function Component() {
3761
let submit = useSubmit();
3862
submit({ key: "value" }, { encType: "application/json" });
63+
// navigation.formEncType => "application/json"
64+
// navigation.formData => undefined
65+
// navigation.payload => { key: "Value" }
3966
}
4067

4168
function action({ request, payload }) {
42-
// payload => { key: 'value' }
43-
// await request.json() => {"key":"value"}
69+
// request.headers.get("Content-Type") => "application/json"
70+
// request.json => { key: 'value' }
71+
// payload => { key: 'value' }
4472
}
4573
```
4674

4775
```js
4876
function Component() {
4977
let submit = useSubmit();
5078
submit({ key: "value" }, { encType: "application/x-www-form-urlencoded" });
79+
// navigation.formEncType => "application/x-www-form-urlencoded"
80+
// navigation.formData => FormData instance
81+
// navigation.payload => { key: "Value" }
5182
}
5283

5384
function action({ request, payload }) {
54-
// payload => { key: 'value' }
55-
// await request.formData() => FormData instance with a single entry of key=value
85+
// request.headers.get("Content-Type") => "application/x-www-form-urlencoded"
86+
// request.formData => { key: 'value' }
87+
// payload => { key: 'value' }
5688
}
5789
```
5890

5991
```js
6092
function Component() {
6193
let submit = useSubmit();
6294
submit("Plain ol' text", { encType: "text/plain" });
95+
// navigation.formEncType => "text/plain"
96+
// navigation.formData => undefined
97+
// navigation.payload => "Plain ol' text"
6398
}
6499

65100
function action({ request, payload }) {
66-
// payload => "Plain ol' text"
67-
// await request.text() => "Plain ol' text"
101+
// request.headers.get("Content-Type") => "text/plain"
102+
// request.text => "Plain ol' text"
103+
// payload => "Plain ol' text"
68104
}
69105
```

packages/react-router-dom/__tests__/data-browser-router-test.tsx

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
prettyDOM,
1010
} from "@testing-library/react";
1111
import "@testing-library/jest-dom";
12-
import type { ErrorResponse } from "@remix-run/router";
12+
import { ErrorResponse, IDLE_NAVIGATION, Navigation } from "@remix-run/router";
1313
import type { RouteObject } from "react-router-dom";
1414
import {
1515
Form,
@@ -3133,13 +3133,18 @@ function testDomRouter(
31333133
it("serializes formData on submit(object) submissions", async () => {
31343134
let actionSpy = jest.fn();
31353135
let payload = { a: "1", b: "2" };
3136+
let navigation;
31363137
let router = createTestRouter(
31373138
[
31383139
{
31393140
path: "/",
31403141
action: actionSpy,
31413142
Component() {
31423143
let submit = useSubmit();
3144+
let n = useNavigation();
3145+
if (n.state === "submitting") {
3146+
navigation = n;
3147+
}
31433148
return (
31443149
<button onClick={() => submit(payload, { method: "post" })}>
31453150
Submit
@@ -3153,22 +3158,34 @@ function testDomRouter(
31533158
render(<RouterProvider router={router} />);
31543159

31553160
fireEvent.click(screen.getByText("Submit"));
3156-
let formData = await actionSpy.mock.calls[0][0].request.formData();
3157-
expect(formData.get("a")).toBe("1");
3158-
expect(formData.get("b")).toBe("2");
3159-
expect(actionSpy.mock.calls[0][0].payload).toBe(undefined);
3161+
expect(navigation.formData?.get("a")).toBe("1");
3162+
expect(navigation.formData?.get("b")).toBe("2");
3163+
expect(navigation.payload).toBe(payload);
3164+
let { request, payload: actionPayload } = actionSpy.mock.calls[0][0];
3165+
expect(request.headers.get("Content-Type")).toMatchInlineSnapshot(
3166+
`"application/x-www-form-urlencoded;charset=UTF-8"`
3167+
);
3168+
let actionFormData = await request.formData();
3169+
expect(actionFormData.get("a")).toBe("1");
3170+
expect(actionFormData.get("b")).toBe("2");
3171+
expect(actionPayload).toBe(payload);
31603172
});
31613173

31623174
it("serializes formData on submit(object)/encType:application/x-www-form-urlencoded submissions", async () => {
31633175
let actionSpy = jest.fn();
31643176
let payload = { a: "1", b: "2" };
3177+
let navigation;
31653178
let router = createTestRouter(
31663179
[
31673180
{
31683181
path: "/",
31693182
action: actionSpy,
31703183
Component() {
31713184
let submit = useSubmit();
3185+
let n = useNavigation();
3186+
if (n.state === "submitting") {
3187+
navigation = n;
3188+
}
31723189
return (
31733190
<button
31743191
onClick={() =>
@@ -3189,26 +3206,34 @@ function testDomRouter(
31893206
render(<RouterProvider router={router} />);
31903207

31913208
fireEvent.click(screen.getByText("Submit"));
3192-
let request = actionSpy.mock.calls[0][0].request;
3193-
expect(request.headers.get("Content-Type")).toBe(
3194-
"application/x-www-form-urlencoded;charset=UTF-8"
3209+
expect(navigation.formData?.get("a")).toBe("1");
3210+
expect(navigation.formData?.get("b")).toBe("2");
3211+
expect(navigation.payload).toBe(payload);
3212+
let { request, payload: actionPayload } = actionSpy.mock.calls[0][0];
3213+
expect(request.headers.get("Content-Type")).toMatchInlineSnapshot(
3214+
`"application/x-www-form-urlencoded;charset=UTF-8"`
31953215
);
3196-
let formData = await request.formData();
3197-
expect(formData.get("a")).toBe("1");
3198-
expect(formData.get("b")).toBe("2");
3199-
expect(actionSpy.mock.calls[0][0].payload).toBe(undefined);
3216+
let actionFormData = await request.formData();
3217+
expect(actionFormData.get("a")).toBe("1");
3218+
expect(actionFormData.get("b")).toBe("2");
3219+
expect(actionPayload).toBe(payload);
32003220
});
32013221

32023222
it("serializes JSON on submit(object)/encType:application/json submissions", async () => {
32033223
let actionSpy = jest.fn();
32043224
let payload = { a: "1", b: "2" };
3225+
let navigation;
32053226
let router = createTestRouter(
32063227
[
32073228
{
32083229
path: "/",
32093230
action: actionSpy,
32103231
Component() {
32113232
let submit = useSubmit();
3233+
let n = useNavigation();
3234+
if (n.state === "submitting") {
3235+
navigation = n;
3236+
}
32123237
return (
32133238
<button
32143239
onClick={() =>
@@ -3229,22 +3254,29 @@ function testDomRouter(
32293254
render(<RouterProvider router={router} />);
32303255

32313256
fireEvent.click(screen.getByText("Submit"));
3232-
let request = actionSpy.mock.calls[0][0].request;
3257+
expect(navigation.formData).toBe(undefined);
3258+
expect(navigation.payload).toBe(payload);
3259+
let { request, payload: actionPayload } = actionSpy.mock.calls[0][0];
32333260
expect(request.headers.get("Content-Type")).toBe("application/json");
32343261
expect(await request.json()).toEqual({ a: "1", b: "2" });
3235-
expect(actionSpy.mock.calls[0][0].payload).toBe(payload);
3262+
expect(actionPayload).toBe(payload);
32363263
});
32373264

32383265
it("serializes text on submit(object)/encType:text/plain submissions", async () => {
32393266
let actionSpy = jest.fn();
32403267
let payload = "look ma, no formData!";
3268+
let navigation;
32413269
let router = createTestRouter(
32423270
[
32433271
{
32443272
path: "/",
32453273
action: actionSpy,
32463274
Component() {
32473275
let submit = useSubmit();
3276+
let n = useNavigation();
3277+
if (n.state === "submitting") {
3278+
navigation = n;
3279+
}
32483280
return (
32493281
<button
32503282
onClick={() =>
@@ -3265,22 +3297,29 @@ function testDomRouter(
32653297
render(<RouterProvider router={router} />);
32663298

32673299
fireEvent.click(screen.getByText("Submit"));
3268-
let request = actionSpy.mock.calls[0][0].request;
3300+
expect(navigation.formData).toBe(undefined);
3301+
expect(navigation.payload).toBe(payload);
3302+
let { request, payload: actionPayload } = actionSpy.mock.calls[0][0];
32693303
expect(request.headers.get("Content-Type")).toBe("text/plain");
32703304
expect(await request.text()).toEqual(payload);
3271-
expect(actionSpy.mock.calls[0][0].payload).toBe(payload);
3305+
expect(actionPayload).toBe(payload);
32723306
});
32733307

32743308
it("does not serialize formData on submit(object)/encType:null submissions", async () => {
32753309
let actionSpy = jest.fn();
32763310
let payload;
3311+
let navigation;
32773312
let router = createTestRouter(
32783313
[
32793314
{
32803315
path: "/",
32813316
action: actionSpy,
32823317
Component() {
32833318
let submit = useSubmit();
3319+
let n = useNavigation();
3320+
if (n.state === "submitting") {
3321+
navigation = n;
3322+
}
32843323
return (
32853324
<button
32863325
onClick={() =>
@@ -3299,18 +3338,24 @@ function testDomRouter(
32993338

33003339
payload = "look ma no formData!";
33013340
fireEvent.click(screen.getByText("Submit"));
3341+
expect(navigation.formData).toBeUndefined();
3342+
expect(navigation.payload).toBe(payload);
33023343
expect(actionSpy.mock.calls[0][0].request.body).toBe(null);
33033344
expect(actionSpy.mock.calls[0][0].payload).toBe(payload);
33043345
actionSpy.mockReset();
33053346

33063347
payload = { a: "1", b: "2" };
33073348
fireEvent.click(screen.getByText("Submit"));
3349+
expect(navigation.formData).toBeUndefined();
3350+
expect(navigation.payload).toBe(payload);
33083351
expect(actionSpy.mock.calls[0][0].request.body).toBe(null);
33093352
expect(actionSpy.mock.calls[0][0].payload).toBe(payload);
33103353
actionSpy.mockReset();
33113354

33123355
payload = [1, 2, 3, 4, 5];
33133356
fireEvent.click(screen.getByText("Submit"));
3357+
expect(navigation.formData).toBeUndefined();
3358+
expect(navigation.payload).toBe(payload);
33143359
expect(actionSpy.mock.calls[0][0].request.body).toBe(null);
33153360
expect(actionSpy.mock.calls[0][0].payload).toBe(payload);
33163361
actionSpy.mockReset();

packages/react-router-dom/dom.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,10 @@ export function getFormSubmissionInfo(
283283
formData.append(name, value);
284284
}
285285
} else if (target != null) {
286+
// When a raw object is sent - even though we encode it into formData,
287+
// we still expose it as payload so it aligns with the behavior if
288+
// encType were application/json or text/plain
289+
payload = target;
286290
// To be deprecated in v7 so the default behavior of undefined matches
287291
// the null behavior of no-serialization
288292
for (let name of Object.keys(target)) {

packages/router/router.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3178,28 +3178,16 @@ function normalizeNavigateOptions(
31783178
};
31793179
}
31803180

3181-
// formData/payload are mutually exclusive
3182-
if (
3183-
(opts.formData == null && opts.payload === undefined) ||
3184-
(opts.formData != null && opts.payload !== undefined)
3185-
) {
3186-
return {
3187-
path,
3188-
error: getInternalRouterError(400, { type: "formData-payload" }),
3189-
};
3190-
}
3191-
31923181
// Create a Submission on non-GET navigations
31933182
let submission: Submission;
31943183
let rawFormMethod = opts.formMethod || "get";
31953184
let formMethod = normalizeFormMethod
31963185
? (rawFormMethod.toUpperCase() as V7_FormMethod)
31973186
: (rawFormMethod.toLowerCase() as FormMethod);
31983187
let formAction = stripHashFromPath(path);
3199-
let formData: FormData;
32003188

32013189
// User opted-out of serialization, so just pass along the payload directly
3202-
if (opts.payload !== undefined) {
3190+
if (opts.payload !== undefined && opts.formData == null) {
32033191
// Any payload encType's besides these pass through as a normal payload and
32043192
// get serialized in createClientSideRequest since they are not represented
32053193
// via formData
@@ -3212,18 +3200,17 @@ function normalizeNavigateOptions(
32123200
};
32133201

32143202
return { path, submission };
3215-
} else {
3216-
// We know this exists due to the mutually exclusive check above
3217-
formData = opts.formData!;
32183203
}
32193204

3205+
invariant(opts.formData, "Expected a FormData instance");
3206+
32203207
submission = {
32213208
formMethod,
32223209
formAction,
32233210
formEncType:
32243211
(opts && opts.formEncType) || "application/x-www-form-urlencoded",
3225-
formData,
3226-
payload: undefined,
3212+
formData: opts.formData,
3213+
payload: opts.payload,
32273214
};
32283215

32293216
if (isMutationMethod(submission.formMethod)) {
@@ -3232,7 +3219,7 @@ function normalizeNavigateOptions(
32323219

32333220
// Flatten submission onto URLSearchParams for GET submissions
32343221
let parsedPath = parsePath(path);
3235-
let searchParams = convertFormDataToSearchParams(formData);
3222+
let searchParams = convertFormDataToSearchParams(opts.formData);
32363223
// On GET navigation submissions we can drop the ?index param from the
32373224
// resulting location since all loaders will run. But fetcher GET submissions
32383225
// only run a single loader so we need to preserve any incoming ?index params
@@ -4037,7 +4024,7 @@ function getInternalRouterError(
40374024
pathname?: string;
40384025
routeId?: string;
40394026
method?: string;
4040-
type?: "defer-action" | "formData-payload";
4027+
type?: "defer-action";
40414028
} = {}
40424029
) {
40434030
let statusText = "Unknown Server Error";
@@ -4052,10 +4039,6 @@ function getInternalRouterError(
40524039
`so there is no way to handle the request.`;
40534040
} else if (type === "defer-action") {
40544041
errorMessage = "defer() is not supported in actions";
4055-
} else if (type === "formData-payload") {
4056-
errorMessage =
4057-
"You must include either `formData` or `payload` on a mutation " +
4058-
"submission navigation, but not both";
40594042
}
40604043
} else if (status === 403) {
40614044
statusText = "Forbidden";

0 commit comments

Comments
 (0)