Skip to content

Commit 253cca7

Browse files
committed
Add support for <Form encType=text/plain>
1 parent 8a7df27 commit 253cca7

File tree

5 files changed

+113
-31
lines changed

5 files changed

+113
-31
lines changed

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3307,6 +3307,35 @@ function testDomRouter(
33073307
expect(await request.text()).toEqual(body);
33083308
});
33093309

3310+
it('serializes into text on <Form encType="text/plain" submissions', async () => {
3311+
let actionSpy = jest.fn();
3312+
let router = createTestRouter(
3313+
createRoutesFromElements(
3314+
<Route path="/" action={actionSpy} element={<FormPage />} />
3315+
),
3316+
{ window: getWindow("/") }
3317+
);
3318+
render(<RouterProvider router={router} />);
3319+
3320+
function FormPage() {
3321+
return (
3322+
<Form method="post" encType="text/plain">
3323+
<input name="a" defaultValue="1" />
3324+
<input name="b" defaultValue="2" />
3325+
<button type="submit">Submit</button>
3326+
</Form>
3327+
);
3328+
}
3329+
3330+
fireEvent.click(screen.getByText("Submit"));
3331+
expect(await actionSpy.mock.calls[0][0].request.text())
3332+
.toMatchInlineSnapshot(`
3333+
"a=1
3334+
b=2
3335+
"
3336+
`);
3337+
});
3338+
33103339
it("includes submit button name/value on form submission", async () => {
33113340
let actionSpy = jest.fn();
33123341
let router = createTestRouter(

packages/react-router-dom/dom.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export interface SubmitOptions {
169169
const supportedFormEncTypes: Set<FormEncType> = new Set([
170170
"application/x-www-form-urlencoded",
171171
"multipart/form-data",
172+
"text/plain",
172173
]);
173174

174175
function getFormEncType(encType: string | null) {
@@ -259,5 +260,11 @@ export function getFormSubmissionInfo(
259260
body = target;
260261
}
261262

263+
// Send body for <Form encType="text/plain" so we encode it into text
264+
if (formData && encType === "text/plain") {
265+
body = formData;
266+
formData = undefined;
267+
}
268+
262269
return { action, method: method.toLowerCase(), encType, formData, body };
263270
}

packages/react-router-dom/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -666,10 +666,10 @@ export interface FormProps extends React.FormHTMLAttributes<HTMLFormElement> {
666666
* `<form encType>` - enhancing beyond the normal string type and limiting
667667
* to the built-in browser supported values
668668
*/
669-
encType?: Extract<
670-
FormEncType,
671-
"application/x-www-form-urlencoded" | "multipart/form-data"
672-
>;
669+
encType?:
670+
| "application/x-www-form-urlencoded"
671+
| "multipart/form-data"
672+
| "text/plain";
673673

674674
/**
675675
* Normal `<form action>` but supports React Router's relative paths.

packages/router/__tests__/router-test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6486,6 +6486,47 @@ describe("a router", () => {
64866486
expect(await request.text()).toEqual(body);
64876487
});
64886488

6489+
it("serializes body as text/plain (FormData)", async () => {
6490+
let t = setup({
6491+
routes: [{ id: "root", path: "/", action: true }],
6492+
});
6493+
6494+
let body = new FormData();
6495+
body.append("a", "1");
6496+
body.append("b", "2");
6497+
let nav = await t.navigate("/", {
6498+
formMethod: "post",
6499+
formEncType: "text/plain",
6500+
body,
6501+
});
6502+
expect(t.router.state.navigation.text).toMatchInlineSnapshot(`
6503+
"a=1
6504+
b=2
6505+
"
6506+
`);
6507+
expect(t.router.state.navigation.formData).toBeUndefined();
6508+
expect(t.router.state.navigation.json).toBeUndefined();
6509+
6510+
await nav.actions.root.resolve("ACTION");
6511+
6512+
expect(nav.actions.root.stub).toHaveBeenCalledWith({
6513+
params: {},
6514+
request: expect.any(Request),
6515+
});
6516+
6517+
let request = nav.actions.root.stub.mock.calls[0][0].request;
6518+
expect(request.method).toBe("POST");
6519+
expect(request.url).toBe("http://localhost/");
6520+
expect(request.headers.get("Content-Type")).toBe(
6521+
"text/plain;charset=UTF-8"
6522+
);
6523+
expect(await request.text()).toMatchInlineSnapshot(`
6524+
"a=1
6525+
b=2
6526+
"
6527+
`);
6528+
});
6529+
64896530
it("serializes body as FormData when encType=undefined", async () => {
64906531
let t = setup({
64916532
routes: [{ id: "root", path: "/", action: true }],

packages/router/router.ts

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3095,7 +3095,6 @@ function normalizeNavigateOptions(
30953095
}
30963096

30973097
// Create a Submission on non-GET navigations
3098-
let submission: Submission;
30993098
let rawFormMethod = opts.formMethod || "get";
31003099
let formMethod = normalizeFormMethod
31013100
? (rawFormMethod.toUpperCase() as V7_FormMethod)
@@ -3104,39 +3103,45 @@ function normalizeNavigateOptions(
31043103

31053104
if (opts.body) {
31063105
if (opts.formEncType === "text/plain") {
3107-
submission = {
3108-
formMethod,
3109-
formAction,
3110-
formEncType: opts.formEncType,
3111-
text:
3112-
typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body),
3113-
formData: undefined,
3114-
json: undefined,
3115-
};
3106+
let text =
3107+
typeof opts.body === "string"
3108+
? opts.body
3109+
: opts.body instanceof FormData ||
3110+
opts.body instanceof URLSearchParams
3111+
? // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#plain-text-form-data
3112+
Array.from(opts.body.entries()).reduce(
3113+
(acc, [name, value]) => `${acc}${name}=${value}\n`,
3114+
""
3115+
)
3116+
: String(opts.body);
31163117

3117-
return { path, submission };
3118+
return {
3119+
path,
3120+
submission: {
3121+
formMethod,
3122+
formAction,
3123+
formEncType: opts.formEncType,
3124+
text,
3125+
formData: undefined,
3126+
json: undefined,
3127+
},
3128+
};
31183129
} else if (opts.formEncType === "application/json") {
31193130
try {
3120-
if (typeof opts.body === "string") {
3121-
submission = {
3122-
formMethod,
3123-
formAction,
3124-
formEncType: opts.formEncType,
3125-
text: undefined,
3126-
formData: undefined,
3127-
json: JSON.parse(opts.body),
3128-
};
3129-
} else {
3130-
submission = {
3131+
let json =
3132+
typeof opts.body === "string" ? JSON.parse(opts.body) : opts.body;
3133+
3134+
return {
3135+
path,
3136+
submission: {
31313137
formMethod,
31323138
formAction,
31333139
formEncType: opts.formEncType,
31343140
text: undefined,
31353141
formData: undefined,
3136-
json: opts.body,
3137-
};
3138-
}
3139-
return { path, submission };
3142+
json,
3143+
},
3144+
};
31403145
} catch (e) {
31413146
return {
31423147
path,
@@ -3178,7 +3183,7 @@ function normalizeNavigateOptions(
31783183
}
31793184
}
31803185

3181-
submission = {
3186+
let submission: Submission = {
31823187
formMethod,
31833188
formAction,
31843189
formEncType:

0 commit comments

Comments
 (0)