Skip to content

Commit f963974

Browse files
committed
Opt-out of submission serialization via encType:null
1 parent dbc9ba3 commit f963974

File tree

6 files changed

+290
-106
lines changed

6 files changed

+290
-106
lines changed

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

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3130,7 +3130,7 @@ function testDomRouter(
31303130
expect(formData.get("b")).toBe("2");
31313131
});
31323132

3133-
it("gathers form data on submit(object) submissions", async () => {
3133+
it("serializes formData on submit(object) submissions", async () => {
31343134
let actionSpy = jest.fn();
31353135
let router = createTestRouter(
31363136
createRoutesFromElements(
@@ -3155,6 +3155,90 @@ function testDomRouter(
31553155
let formData = await actionSpy.mock.calls[0][0].request.formData();
31563156
expect(formData.get("a")).toBe("1");
31573157
expect(formData.get("b")).toBe("2");
3158+
expect(actionSpy.mock.calls[0][0].payload).toBe(undefined);
3159+
});
3160+
3161+
it("serializes formData on submit(object)/encType:application/x-www-form-urlencoded submissions", async () => {
3162+
let actionSpy = jest.fn();
3163+
let router = createTestRouter(
3164+
createRoutesFromElements(
3165+
<Route path="/" action={actionSpy} element={<FormPage />} />
3166+
),
3167+
{ window: getWindow("/") }
3168+
);
3169+
render(<RouterProvider router={router} />);
3170+
3171+
function FormPage() {
3172+
let submit = useSubmit();
3173+
return (
3174+
<button
3175+
onClick={() =>
3176+
submit(
3177+
{ a: "1", b: "2" },
3178+
{
3179+
method: "post",
3180+
encType: "application/x-www-form-urlencoded",
3181+
}
3182+
)
3183+
}
3184+
>
3185+
Submit
3186+
</button>
3187+
);
3188+
}
3189+
3190+
fireEvent.click(screen.getByText("Submit"));
3191+
let formData = await actionSpy.mock.calls[0][0].request.formData();
3192+
expect(formData.get("a")).toBe("1");
3193+
expect(formData.get("b")).toBe("2");
3194+
expect(actionSpy.mock.calls[0][0].payload).toBe(undefined);
3195+
});
3196+
3197+
it("does not serialize formData on submit(object)/encType:null submissions", async () => {
3198+
let actionSpy = jest.fn();
3199+
let payload;
3200+
let router = createTestRouter(
3201+
[
3202+
{
3203+
path: "/",
3204+
action: actionSpy,
3205+
Component() {
3206+
let submit = useSubmit();
3207+
return (
3208+
<button
3209+
onClick={() =>
3210+
submit(payload, { method: "post", encType: null })
3211+
}
3212+
>
3213+
Submit
3214+
</button>
3215+
);
3216+
},
3217+
},
3218+
],
3219+
{ window: getWindow("/") }
3220+
);
3221+
render(<RouterProvider router={router} />);
3222+
3223+
payload = "look ma no formData!";
3224+
fireEvent.click(screen.getByText("Submit"));
3225+
expect(actionSpy.mock.calls[0][0].request.body).toBe(null);
3226+
expect(actionSpy.mock.calls[0][0].payload).toBe(payload);
3227+
actionSpy.mockReset();
3228+
3229+
payload = { a: "1", b: "2" };
3230+
fireEvent.click(screen.getByText("Submit"));
3231+
expect(actionSpy.mock.calls[0][0].request.body).toBe(null);
3232+
expect(actionSpy.mock.calls[0][0].payload).toBe(payload);
3233+
actionSpy.mockReset();
3234+
3235+
payload = [1, 2, 3, 4, 5];
3236+
fireEvent.click(screen.getByText("Submit"));
3237+
expect(actionSpy.mock.calls[0][0].request.body).toBe(null);
3238+
expect(actionSpy.mock.calls[0][0].payload).toBe(payload);
3239+
actionSpy.mockReset();
3240+
3241+
router.dispose();
31583242
});
31593243

31603244
it("includes submit button name/value on form submission", async () => {
@@ -3964,6 +4048,42 @@ function testDomRouter(
39644048
`);
39654049
});
39664050

4051+
it("does not serialize fetcher.submit(object, { encType: null }) calls", async () => {
4052+
let actionSpy = jest.fn();
4053+
let payload = { key: "value" };
4054+
let router = createTestRouter(
4055+
[
4056+
{
4057+
path: "/",
4058+
action: actionSpy,
4059+
Component() {
4060+
let fetcher = useFetcher();
4061+
return (
4062+
<button
4063+
onClick={() =>
4064+
fetcher.submit(payload, {
4065+
method: "post",
4066+
encType: null,
4067+
})
4068+
}
4069+
>
4070+
Submit
4071+
</button>
4072+
);
4073+
},
4074+
},
4075+
],
4076+
{
4077+
window: getWindow("/"),
4078+
}
4079+
);
4080+
4081+
render(<RouterProvider router={router} />);
4082+
fireEvent.click(screen.getByText("Submit"));
4083+
expect(actionSpy.mock.calls[0][0].payload).toEqual(payload);
4084+
expect(actionSpy.mock.calls[0][0].request.body).toBe(null);
4085+
});
4086+
39674087
it("show all fetchers via useFetchers and cleans up fetchers on unmount", async () => {
39684088
let dfd1 = createDeferred();
39694089
let dfd2 = createDeferred();

packages/react-router-dom/dom.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@ export function getSearchParamsForLocation(
109109
return searchParams;
110110
}
111111

112+
export type SubmitTarget =
113+
| HTMLFormElement
114+
| HTMLButtonElement
115+
| HTMLInputElement
116+
| FormData
117+
| URLSearchParams
118+
| { [name: string]: string }
119+
| NonNullable<unknown> // Raw payload submissions
120+
| null;
121+
112122
export interface SubmitOptions {
113123
/**
114124
* The HTTP method used to submit the form. Overrides `<form method>`.
@@ -124,9 +134,14 @@ export interface SubmitOptions {
124134

125135
/**
126136
* The action URL used to submit the form. Overrides `<form encType>`.
127-
* Defaults to "application/x-www-form-urlencoded".
137+
* Defaults to "application/x-www-form-urlencoded". Specifying `null` will
138+
* opt-out of serialization and will submit the data directly to your action
139+
* in the `payload` parameter.
140+
*
141+
* In v7, the default behavior will change from "application/x-www-form-urlencoded"
142+
* to `null` and will make serialization opt-in
128143
*/
129-
encType?: FormEncType;
144+
encType?: FormEncType | null;
130145

131146
/**
132147
* Set `true` to replace the current entry in the browser's history stack
@@ -150,26 +165,21 @@ export interface SubmitOptions {
150165
}
151166

152167
export function getFormSubmissionInfo(
153-
target:
154-
| HTMLFormElement
155-
| HTMLButtonElement
156-
| HTMLInputElement
157-
| FormData
158-
| URLSearchParams
159-
| { [name: string]: string }
160-
| null,
168+
target: SubmitTarget,
161169
options: SubmitOptions,
162170
basename: string
163171
): {
164172
action: string | null;
165173
method: string;
166-
encType: string;
167-
formData: FormData;
174+
encType: string | null;
175+
formData: FormData | undefined;
176+
payload: any;
168177
} {
169178
let method: string;
170179
let action: string | null = null;
171-
let encType: string;
172-
let formData: FormData;
180+
let encType: string | null;
181+
let formData: FormData | undefined = undefined;
182+
let payload: unknown = undefined;
173183

174184
if (isFormElement(target)) {
175185
let submissionTrigger: HTMLButtonElement | HTMLInputElement = (
@@ -243,6 +253,11 @@ export function getFormSubmissionInfo(
243253
`Cannot submit element that is not <form>, <button>, or ` +
244254
`<input type="submit|image">`
245255
);
256+
} else if (options.encType === null) {
257+
method = options.method || defaultMethod;
258+
action = options.action || null;
259+
encType = null;
260+
payload = target;
246261
} else {
247262
method = options.method || defaultMethod;
248263
action = options.action || null;
@@ -259,11 +274,12 @@ export function getFormSubmissionInfo(
259274
}
260275
} else if (target != null) {
261276
for (let name of Object.keys(target)) {
277+
// @ts-expect-error
262278
formData.append(name, target[name]);
263279
}
264280
}
265281
}
266282
}
267283

268-
return { action, method: method.toLowerCase(), encType, formData };
284+
return { action, method: method.toLowerCase(), encType, formData, payload };
269285
}

packages/react-router-dom/index.tsx

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import type {
5555
SubmitOptions,
5656
ParamKeyValuePair,
5757
URLSearchParamsInit,
58+
SubmitTarget,
5859
} from "./dom";
5960
import {
6061
createSearchParams,
@@ -914,15 +915,6 @@ type SetURLSearchParams = (
914915
navigateOpts?: NavigateOptions
915916
) => void;
916917

917-
type SubmitTarget =
918-
| HTMLFormElement
919-
| HTMLButtonElement
920-
| HTMLInputElement
921-
| FormData
922-
| URLSearchParams
923-
| { [name: string]: string }
924-
| null;
925-
926918
/**
927919
* Submits a HTML `<form>` to the server without reloading the page.
928920
*/
@@ -971,18 +963,16 @@ function useSubmitImpl(
971963
);
972964
}
973965

974-
let { action, method, encType, formData } = getFormSubmissionInfo(
975-
target,
976-
options,
977-
basename
978-
);
966+
let { action, method, encType, formData, payload } =
967+
getFormSubmissionInfo(target, options, basename);
979968

980969
// Base options shared between fetch() and navigate()
981970
let opts = {
982971
preventScrollReset: options.preventScrollReset,
983972
formData,
984-
formMethod: method as HTMLFormMethod,
985-
formEncType: encType as FormEncType,
973+
payload,
974+
formMethod: method,
975+
formEncType: encType,
986976
};
987977

988978
if (fetcherKey) {

packages/router/__tests__/router-test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2220,6 +2220,7 @@ describe("a router", () => {
22202220
"formMethod": "post",
22212221
"nextParams": {},
22222222
"nextUrl": "http://localhost/",
2223+
"payload": undefined,
22232224
}
22242225
`);
22252226
expect(Object.fromEntries(arg.formData)).toEqual({ key: "value" });
@@ -2281,6 +2282,7 @@ describe("a router", () => {
22812282
"formMethod": "post",
22822283
"nextParams": {},
22832284
"nextUrl": "http://localhost/",
2285+
"payload": undefined,
22842286
}
22852287
`);
22862288

@@ -9746,6 +9748,7 @@ describe("a router", () => {
97469748
"b": "three",
97479749
},
97489750
"nextUrl": "http://localhost/two/three",
9751+
"payload": undefined,
97499752
}
97509753
`);
97519754

0 commit comments

Comments
 (0)