Skip to content

Commit bf281ed

Browse files
committed
fix(router): url encode file names
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 9640d01 commit bf281ed

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
@@ -4493,7 +4493,7 @@ describe("a router", () => {
44934493
);
44944494
});
44954495

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

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

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

46714607
describe("data loading (new)", () => {
@@ -5710,6 +5646,37 @@ describe("a router", () => {
57105646
);
57115647
});
57125648

5649+
it("url-encodes File names on x-www-form-urlencoded submissions", async () => {
5650+
let t = setup({
5651+
routes: [
5652+
{
5653+
id: "root",
5654+
path: "/",
5655+
action: true,
5656+
},
5657+
],
5658+
initialEntries: ["/"],
5659+
hydrationData: {
5660+
loaderData: {
5661+
root: "ROOT_DATA",
5662+
},
5663+
},
5664+
});
5665+
5666+
let fd = new FormData();
5667+
fd.append("key", "value");
5668+
fd.append("file", new Blob(["1", "2", "3"]), "file.txt");
5669+
5670+
let A = await t.navigate("/", {
5671+
formMethod: "post",
5672+
formEncType: "application/x-www-form-urlencoded",
5673+
formData: fd,
5674+
});
5675+
5676+
let req = A.actions.root.stub.mock.calls[0][0].request.clone();
5677+
expect((await req.formData()).get("file")).toEqual("file.txt");
5678+
});
5679+
57135680
it("races actions and loaders against abort signals", async () => {
57145681
let loaderDfd = createDeferred();
57155682
let actionDfd = createDeferred();
@@ -9909,6 +9876,7 @@ describe("a router", () => {
99099876
{
99109877
id: "b",
99119878
path: "b",
9879+
loader: true,
99129880
},
99139881
],
99149882
},
@@ -9947,12 +9915,11 @@ describe("a router", () => {
99479915
// Perform an invalid navigation to /parent/b which will be handled
99489916
// using parent's error boundary. Parent's deferred should be left alone
99499917
// while A's should be cancelled since they will no longer be rendered
9950-
let formData = new FormData();
9951-
formData.append("file", new Blob(["1", "2"]), "file.txt");
9952-
await t.navigate("/parent/b", {
9953-
formMethod: "get",
9954-
formData,
9955-
});
9918+
let B = await t.navigate("/parent/b");
9919+
await B.loaders.b.reject(
9920+
new Response("broken", { status: 400, statusText: "Bad Request" })
9921+
);
9922+
99569923
// Navigation completes immediately with an error at the boundary
99579924
expect(t.router.state.loaderData).toEqual({
99589925
parent: {
@@ -9961,12 +9928,7 @@ describe("a router", () => {
99619928
},
99629929
});
99639930
expect(t.router.state.errors).toEqual({
9964-
parent: new ErrorResponse(
9965-
400,
9966-
"Bad Request",
9967-
new Error("Cannot submit binary form data using GET"),
9968-
true
9969-
),
9931+
parent: new ErrorResponse(400, "Bad Request", "broken", false),
99709932
});
99719933

99729934
await parentDfd.resolve("Yep!");
@@ -10047,12 +10009,11 @@ describe("a router", () => {
1004710009
// Perform an invalid navigation to /b/child which should cancel all
1004810010
// pending deferred's since nothing is reused. It should not call bChild's
1004910011
// loader since it's below the boundary but should call b's loader.
10050-
let formData = new FormData();
10051-
formData.append("file", new Blob(["1", "2"]), "file.txt");
10052-
let B = await t.navigate("/b/child", {
10053-
formMethod: "get",
10054-
formData,
10055-
});
10012+
let B = await t.navigate("/b/child");
10013+
10014+
await B.loaders.bChild.reject(
10015+
new Response("broken", { status: 400, statusText: "Bad Request" })
10016+
);
1005610017

1005710018
// Both should be cancelled
1005810019
await aDfd.resolve("Nope!");
@@ -10073,14 +10034,8 @@ describe("a router", () => {
1007310034
b: "B LOADER",
1007410035
});
1007510036
expect(t.router.state.errors).toEqual({
10076-
bChild: new ErrorResponse(
10077-
400,
10078-
"Bad Request",
10079-
new Error("Cannot submit binary form data using GET"),
10080-
true
10081-
),
10037+
bChild: new ErrorResponse(400, "Bad Request", "broken", false),
1008210038
});
10083-
expect(B.loaders.bChild.stub).not.toHaveBeenCalled();
1008410039
});
1008510040

1008610041
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
@@ -2588,25 +2588,14 @@ function normalizeNavigateOptions(
25882588

25892589
// Flatten submission onto URLSearchParams for GET submissions
25902590
let parsedPath = parsePath(path);
2591-
try {
2592-
let searchParams = convertFormDataToSearchParams(opts.formData);
2593-
// Since fetcher GET submissions only run a single loader (as opposed to
2594-
// navigation GET submissions which run all loaders), we need to preserve
2595-
// any incoming ?index params
2596-
if (
2597-
isFetcher &&
2598-
parsedPath.search &&
2599-
hasNakedIndexQuery(parsedPath.search)
2600-
) {
2601-
searchParams.append("index", "");
2602-
}
2603-
parsedPath.search = `?${searchParams}`;
2604-
} catch (e) {
2605-
return {
2606-
path,
2607-
error: getInternalRouterError(400),
2608-
};
2591+
let searchParams = convertFormDataToSearchParams(opts.formData);
2592+
// Since fetcher GET submissions only run a single loader (as opposed to
2593+
// navigation GET submissions which run all loaders), we need to preserve
2594+
// any incoming ?index params
2595+
if (isFetcher && parsedPath.search && hasNakedIndexQuery(parsedPath.search)) {
2596+
searchParams.append("index", "");
26092597
}
2598+
parsedPath.search = `?${searchParams}`;
26102599

26112600
return { path: createPath(parsedPath), submission };
26122601
}
@@ -2955,12 +2944,8 @@ function convertFormDataToSearchParams(formData: FormData): URLSearchParams {
29552944
let searchParams = new URLSearchParams();
29562945

29572946
for (let [key, value] of formData.entries()) {
2958-
invariant(
2959-
typeof value === "string",
2960-
'File inputs are not supported with encType "application/x-www-form-urlencoded", ' +
2961-
'please use "multipart/form-data" instead.'
2962-
);
2963-
searchParams.append(key, value);
2947+
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs
2948+
searchParams.append(key, value instanceof File ? value.name : value);
29642949
}
29652950

29662951
return searchParams;
@@ -3223,8 +3208,6 @@ function getInternalRouterError(
32233208
`so there is no way to handle the request.`;
32243209
} else if (type === "defer-action") {
32253210
errorMessage = "defer() is not supported in actions";
3226-
} else {
3227-
errorMessage = "Cannot submit binary form data using GET";
32283211
}
32293212
} else if (status === 403) {
32303213
statusText = "Forbidden";

0 commit comments

Comments
 (0)