Skip to content

Commit 72f8ef1

Browse files
authored
fix(router): url encode file names (#9867)
This is the RR port of remix-run/remix#4473 Bring FormData serialization in line with the spec with respect to File entries in url-encoded payloads: send the `name` as the value, as is the case with a vanilla `<form>` submission. Rework various 400-error tests not to rely on the old behavior, as it's no longer a bad request :) References: remix-run/remix#4342 Spec: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#convert-to-a-list-of-name-value-pairs
1 parent 1143652 commit 72f8ef1

File tree

3 files changed

+65
-122
lines changed

3 files changed

+65
-122
lines changed

.changeset/sweet-swans-cry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/router": patch
3+
---
4+
5+
Send the name as the value when url-encoding File form data entries

packages/router/__tests__/router-test.ts

Lines changed: 51 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4494,7 +4494,7 @@ describe("a router", () => {
44944494
);
44954495
});
44964496

4497-
it("returns a 400 error if binary data is attempted to be submitted using formMethod=GET", async () => {
4497+
it("url-encodes File names on GET submissions", async () => {
44984498
let t = setup({
44994499
routes: TASK_ROUTES,
45004500
initialEntries: ["/"],
@@ -4511,26 +4511,17 @@ describe("a router", () => {
45114511
"blob",
45124512
new Blob(["<h1>Some html file contents</h1>"], {
45134513
type: "text/html",
4514-
})
4514+
}),
4515+
"blob.html"
45154516
);
45164517

4517-
await t.navigate("/tasks", {
4518+
let A = await t.navigate("/tasks", {
45184519
formMethod: "get",
45194520
formData: formData,
45204521
});
4521-
expect(t.router.state.navigation.state).toBe("idle");
4522-
expect(t.router.state.location).toMatchObject({
4523-
pathname: "/tasks",
4524-
search: "",
4525-
});
4526-
expect(t.router.state.errors).toEqual({
4527-
tasks: new ErrorResponse(
4528-
400,
4529-
"Bad Request",
4530-
new Error("Cannot submit binary form data using GET"),
4531-
true
4532-
),
4533-
});
4522+
let params = new URL(A.loaders.tasks.stub.mock.calls[0][0].request.url)
4523+
.searchParams;
4524+
expect(params.get("blob")).toEqual("blob.html");
45344525
});
45354526

45364527
it("returns a 405 error if attempting to submit with method=HEAD", async () => {
@@ -4612,61 +4603,6 @@ describe("a router", () => {
46124603
),
46134604
});
46144605
});
4615-
4616-
it("runs loaders above the boundary for 400 errors if binary data is attempted to be submitted using formMethod=GET", async () => {
4617-
let t = setup({
4618-
routes: [
4619-
{
4620-
id: "index",
4621-
index: true,
4622-
},
4623-
{
4624-
id: "parent",
4625-
path: "parent",
4626-
loader: true,
4627-
children: [
4628-
{
4629-
id: "child",
4630-
path: "child",
4631-
loader: true,
4632-
hasErrorBoundary: true,
4633-
},
4634-
],
4635-
},
4636-
],
4637-
initialEntries: ["/"],
4638-
});
4639-
4640-
let formData = new FormData();
4641-
formData.append(
4642-
"blob",
4643-
new Blob(["<h1>Some html file contents</h1>"], {
4644-
type: "text/html",
4645-
})
4646-
);
4647-
4648-
let A = await t.navigate("/parent/child", {
4649-
formMethod: "get",
4650-
formData: formData,
4651-
});
4652-
expect(t.router.state.navigation.state).toBe("loading");
4653-
expect(t.router.state.errors).toEqual(null);
4654-
4655-
await A.loaders.parent.resolve("PARENT");
4656-
expect(A.loaders.child.stub).not.toHaveBeenCalled();
4657-
expect(t.router.state.navigation.state).toBe("idle");
4658-
expect(t.router.state.loaderData).toEqual({
4659-
parent: "PARENT",
4660-
});
4661-
expect(t.router.state.errors).toEqual({
4662-
child: new ErrorResponse(
4663-
400,
4664-
"Bad Request",
4665-
new Error("Cannot submit binary form data using GET"),
4666-
true
4667-
),
4668-
});
4669-
});
46704606
});
46714607

46724608
describe("data loading (new)", () => {
@@ -5711,6 +5647,37 @@ describe("a router", () => {
57115647
);
57125648
});
57135649

5650+
it("url-encodes File names on x-www-form-urlencoded submissions", async () => {
5651+
let t = setup({
5652+
routes: [
5653+
{
5654+
id: "root",
5655+
path: "/",
5656+
action: true,
5657+
},
5658+
],
5659+
initialEntries: ["/"],
5660+
hydrationData: {
5661+
loaderData: {
5662+
root: "ROOT_DATA",
5663+
},
5664+
},
5665+
});
5666+
5667+
let fd = new FormData();
5668+
fd.append("key", "value");
5669+
fd.append("file", new Blob(["1", "2", "3"]), "file.txt");
5670+
5671+
let A = await t.navigate("/", {
5672+
formMethod: "post",
5673+
formEncType: "application/x-www-form-urlencoded",
5674+
formData: fd,
5675+
});
5676+
5677+
let req = A.actions.root.stub.mock.calls[0][0].request.clone();
5678+
expect((await req.formData()).get("file")).toEqual("file.txt");
5679+
});
5680+
57145681
it("races actions and loaders against abort signals", async () => {
57155682
let loaderDfd = createDeferred();
57165683
let actionDfd = createDeferred();
@@ -10192,6 +10159,7 @@ describe("a router", () => {
1019210159
{
1019310160
id: "b",
1019410161
path: "b",
10162+
loader: true,
1019510163
},
1019610164
],
1019710165
},
@@ -10230,12 +10198,11 @@ describe("a router", () => {
1023010198
// Perform an invalid navigation to /parent/b which will be handled
1023110199
// using parent's error boundary. Parent's deferred should be left alone
1023210200
// while A's should be cancelled since they will no longer be rendered
10233-
let formData = new FormData();
10234-
formData.append("file", new Blob(["1", "2"]), "file.txt");
10235-
await t.navigate("/parent/b", {
10236-
formMethod: "get",
10237-
formData,
10238-
});
10201+
let B = await t.navigate("/parent/b");
10202+
await B.loaders.b.reject(
10203+
new Response("broken", { status: 400, statusText: "Bad Request" })
10204+
);
10205+
1023910206
// Navigation completes immediately with an error at the boundary
1024010207
expect(t.router.state.loaderData).toEqual({
1024110208
parent: {
@@ -10244,12 +10211,7 @@ describe("a router", () => {
1024410211
},
1024510212
});
1024610213
expect(t.router.state.errors).toEqual({
10247-
parent: new ErrorResponse(
10248-
400,
10249-
"Bad Request",
10250-
new Error("Cannot submit binary form data using GET"),
10251-
true
10252-
),
10214+
parent: new ErrorResponse(400, "Bad Request", "broken", false),
1025310215
});
1025410216

1025510217
await parentDfd.resolve("Yep!");
@@ -10330,12 +10292,11 @@ describe("a router", () => {
1033010292
// Perform an invalid navigation to /b/child which should cancel all
1033110293
// pending deferred's since nothing is reused. It should not call bChild's
1033210294
// loader since it's below the boundary but should call b's loader.
10333-
let formData = new FormData();
10334-
formData.append("file", new Blob(["1", "2"]), "file.txt");
10335-
let B = await t.navigate("/b/child", {
10336-
formMethod: "get",
10337-
formData,
10338-
});
10295+
let B = await t.navigate("/b/child");
10296+
10297+
await B.loaders.bChild.reject(
10298+
new Response("broken", { status: 400, statusText: "Bad Request" })
10299+
);
1033910300

1034010301
// Both should be cancelled
1034110302
await aDfd.resolve("Nope!");
@@ -10356,14 +10317,8 @@ describe("a router", () => {
1035610317
b: "B LOADER",
1035710318
});
1035810319
expect(t.router.state.errors).toEqual({
10359-
bChild: new ErrorResponse(
10360-
400,
10361-
"Bad Request",
10362-
new Error("Cannot submit binary form data using GET"),
10363-
true
10364-
),
10320+
bChild: new ErrorResponse(400, "Bad Request", "broken", false),
1036510321
});
10366-
expect(B.loaders.bChild.stub).not.toHaveBeenCalled();
1036710322
});
1036810323

1036910324
it("does not cancel pending deferreds on hash change only navigations", async () => {

packages/router/router.ts

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2845,25 +2845,14 @@ function normalizeNavigateOptions(
28452845

28462846
// Flatten submission onto URLSearchParams for GET submissions
28472847
let parsedPath = parsePath(path);
2848-
try {
2849-
let searchParams = convertFormDataToSearchParams(opts.formData);
2850-
// Since fetcher GET submissions only run a single loader (as opposed to
2851-
// navigation GET submissions which run all loaders), we need to preserve
2852-
// any incoming ?index params
2853-
if (
2854-
isFetcher &&
2855-
parsedPath.search &&
2856-
hasNakedIndexQuery(parsedPath.search)
2857-
) {
2858-
searchParams.append("index", "");
2859-
}
2860-
parsedPath.search = `?${searchParams}`;
2861-
} catch (e) {
2862-
return {
2863-
path,
2864-
error: getInternalRouterError(400),
2865-
};
2848+
let searchParams = convertFormDataToSearchParams(opts.formData);
2849+
// Since fetcher GET submissions only run a single loader (as opposed to
2850+
// navigation GET submissions which run all loaders), we need to preserve
2851+
// any incoming ?index params
2852+
if (isFetcher && parsedPath.search && hasNakedIndexQuery(parsedPath.search)) {
2853+
searchParams.append("index", "");
28662854
}
2855+
parsedPath.search = `?${searchParams}`;
28672856

28682857
return { path: createPath(parsedPath), submission };
28692858
}
@@ -3222,12 +3211,8 @@ function convertFormDataToSearchParams(formData: FormData): URLSearchParams {
32223211
let searchParams = new URLSearchParams();
32233212

32243213
for (let [key, value] of formData.entries()) {
3225-
invariant(
3226-
typeof value === "string",
3227-
'File inputs are not supported with encType "application/x-www-form-urlencoded", ' +
3228-
'please use "multipart/form-data" instead.'
3229-
);
3230-
searchParams.append(key, value);
3214+
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs
3215+
searchParams.append(key, value instanceof File ? value.name : value);
32313216
}
32323217

32333218
return searchParams;
@@ -3490,8 +3475,6 @@ function getInternalRouterError(
34903475
`so there is no way to handle the request.`;
34913476
} else if (type === "defer-action") {
34923477
errorMessage = "defer() is not supported in actions";
3493-
} else {
3494-
errorMessage = "Cannot submit binary form data using GET";
34953478
}
34963479
} else if (status === 403) {
34973480
statusText = "Forbidden";

0 commit comments

Comments
 (0)