Skip to content

Commit a3ac883

Browse files
sebmarkbagesophiebits
authored andcommitted
[Flight Reply] Encode FormData (#26663)
Builds on top of #26661 This lets you pass FormData objects through the Flight Reply serialization. It does that by prefixing each entry with the ID of the reference and then the decoding side creates a new FormData object containing only those fields (without the prefix). Ideally this should be more generic. E.g. you should be able to pass Blobs, Streams and Typed Arrays by reference inside plain objects too. You should also be able to send Blobs and FormData in the regular Flight serialization too so that they can go both directions. They should be symmetrical. We'll get around to adding more of those features in the Flight protocol as we go. --------- Co-authored-by: Sophie Alpert <[email protected]>
1 parent 73d7956 commit a3ac883

File tree

10 files changed

+241
-69
lines changed

10 files changed

+241
-69
lines changed

fixtures/flight/src/App.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import {Counter as Counter2} from './Counter2.js';
77

88
import ShowMore from './ShowMore.js';
99
import Button from './Button.js';
10+
import Form from './Form.js';
1011

11-
import {like} from './actions.js';
12+
import {like, greet} from './actions.js';
1213

1314
export default async function App() {
1415
const res = await fetch('http://localhost:3001/todos');
@@ -33,6 +34,7 @@ export default async function App() {
3334
<ShowMore>
3435
<p>Lorem ipsum</p>
3536
</ShowMore>
37+
<Form action={greet} />
3638
<div>
3739
<Button action={like}>Like</Button>
3840
</div>

fixtures/flight/src/Form.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
5+
export default function Form({action, children}) {
6+
const [isPending, setIsPending] = React.useState(false);
7+
8+
return (
9+
<form
10+
onSubmit={async e => {
11+
e.preventDefault();
12+
setIsPending(true);
13+
try {
14+
const formData = new FormData(e.target);
15+
const result = await action(formData);
16+
alert(result);
17+
} catch (error) {
18+
console.error(error);
19+
} finally {
20+
setIsPending(false);
21+
}
22+
}}>
23+
<input name="name" />
24+
<button>Say Hi</button>
25+
</form>
26+
);
27+
}

fixtures/flight/src/actions.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
export async function like() {
44
return new Promise((resolve, reject) => resolve('Liked'));
55
}
6+
7+
export async function greet(formData) {
8+
return 'Hi ' + formData.get('name') + '!';
9+
}

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ function serializeSymbolReference(name: string): string {
7474
return '$S' + name;
7575
}
7676

77+
function serializeFormDataReference(id: number): string {
78+
// Why K? F is "Function". D is "Date". What else?
79+
return '$K' + id.toString(16);
80+
}
81+
7782
function serializeNumber(number: number): string | number {
7883
if (Number.isFinite(number)) {
7984
if (number === 0 && 1 / number === -Infinity) {
@@ -112,6 +117,7 @@ function escapeStringValue(value: string): string {
112117

113118
export function processReply(
114119
root: ReactServerValue,
120+
formFieldPrefix: string,
115121
resolve: (string | FormData) => void,
116122
reject: (error: mixed) => void,
117123
): void {
@@ -171,7 +177,7 @@ export function processReply(
171177
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
172178
const data: FormData = formData;
173179
// eslint-disable-next-line react-internal/safe-string-coercion
174-
data.append('' + promiseId, partJSON);
180+
data.append(formFieldPrefix + promiseId, partJSON);
175181
pendingParts--;
176182
if (pendingParts === 0) {
177183
resolve(data);
@@ -185,6 +191,24 @@ export function processReply(
185191
);
186192
return serializePromiseID(promiseId);
187193
}
194+
// TODO: Should we the Object.prototype.toString.call() to test for cross-realm objects?
195+
if (value instanceof FormData) {
196+
if (formData === null) {
197+
// Upgrade to use FormData to allow us to use rich objects as its values.
198+
formData = new FormData();
199+
}
200+
const data: FormData = formData;
201+
const refId = nextPartId++;
202+
// Copy all the form fields with a prefix for this reference.
203+
// These must come first in the form order because we assume that all the
204+
// fields are available before this is referenced.
205+
const prefix = formFieldPrefix + refId + '_';
206+
// $FlowFixMe[prop-missing]: FormData has forEach.
207+
value.forEach((originalValue: string | File, originalKey: string) => {
208+
data.append(prefix + originalKey, originalValue);
209+
});
210+
return serializeFormDataReference(refId);
211+
}
188212
if (!isArray(value)) {
189213
const iteratorFn = getIteratorFn(value);
190214
if (iteratorFn) {
@@ -268,7 +292,7 @@ export function processReply(
268292
// The reference to this function came from the same client so we can pass it back.
269293
const refId = nextPartId++;
270294
// eslint-disable-next-line react-internal/safe-string-coercion
271-
formData.set('' + refId, metaDataJSON);
295+
formData.set(formFieldPrefix + refId, metaDataJSON);
272296
return serializeServerReferenceID(refId);
273297
}
274298
throw new Error(
@@ -308,7 +332,7 @@ export function processReply(
308332
resolve(json);
309333
} else {
310334
// Otherwise, we use FormData to let us stream in the result.
311-
formData.set('0', json);
335+
formData.set(formFieldPrefix + '0', json);
312336
if (pendingParts === 0) {
313337
// $FlowFixMe[incompatible-call] this has already been refined.
314338
resolve(formData);

packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ function encodeReply(
124124
string | URLSearchParams | FormData,
125125
> /* We don't use URLSearchParams yet but maybe */ {
126126
return new Promise((resolve, reject) => {
127-
processReply(value, resolve, reject);
127+
processReply(value, '', resolve, reject);
128128
});
129129
}
130130

packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ import {
2222
import {
2323
createResponse,
2424
close,
25-
resolveField,
26-
resolveFile,
2725
getRoot,
2826
} from 'react-server/src/ReactFlightReplyServer';
2927

@@ -79,20 +77,12 @@ function decodeReply<T>(
7977
body: string | FormData,
8078
webpackMap: ServerManifest,
8179
): Thenable<T> {
82-
const response = createResponse(webpackMap);
8380
if (typeof body === 'string') {
84-
resolveField(response, 0, body);
85-
} else {
86-
// $FlowFixMe[prop-missing] Flow doesn't know that forEach exists.
87-
body.forEach((value: string | File, key: string) => {
88-
const id = +key;
89-
if (typeof value === 'string') {
90-
resolveField(response, id, value);
91-
} else {
92-
resolveFile(response, id, value);
93-
}
94-
});
81+
const form = new FormData();
82+
form.append('0', body);
83+
body = form;
9584
}
85+
const response = createResponse(webpackMap, '', body);
9686
close(response);
9787
return getRoot(response);
9888
}

packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,6 @@ import {
2222
import {
2323
createResponse,
2424
close,
25-
resolveField,
26-
resolveFile,
2725
getRoot,
2826
} from 'react-server/src/ReactFlightReplyServer';
2927

@@ -79,20 +77,12 @@ function decodeReply<T>(
7977
body: string | FormData,
8078
webpackMap: ServerManifest,
8179
): Thenable<T> {
82-
const response = createResponse(webpackMap);
8380
if (typeof body === 'string') {
84-
resolveField(response, 0, body);
85-
} else {
86-
// $FlowFixMe[prop-missing] Flow doesn't know that forEach exists.
87-
body.forEach((value: string | File, key: string) => {
88-
const id = +key;
89-
if (typeof value === 'string') {
90-
resolveField(response, id, value);
91-
} else {
92-
resolveFile(response, id, value);
93-
}
94-
});
81+
const form = new FormData();
82+
form.append('0', body);
83+
body = form;
9584
}
85+
const response = createResponse(webpackMap, '', body);
9686
close(response);
9787
return getRoot(response);
9888
}

packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
reportGlobalError,
3131
close,
3232
resolveField,
33-
resolveFile,
3433
resolveFileInfo,
3534
resolveFileChunk,
3635
resolveFileComplete,
@@ -88,10 +87,9 @@ function decodeReplyFromBusboy<T>(
8887
busboyStream: Busboy,
8988
webpackMap: ServerManifest,
9089
): Thenable<T> {
91-
const response = createResponse(webpackMap);
90+
const response = createResponse(webpackMap, '');
9291
busboyStream.on('field', (name, value) => {
93-
const id = +name;
94-
resolveField(response, id, value);
92+
resolveField(response, name, value);
9593
});
9694
busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
9795
if (encoding.toLowerCase() === 'base64') {
@@ -101,13 +99,12 @@ function decodeReplyFromBusboy<T>(
10199
'the wrong assumption, we can easily fix it.',
102100
);
103101
}
104-
const id = +name;
105-
const file = resolveFileInfo(response, id, filename, mimeType);
102+
const file = resolveFileInfo(response, name, filename, mimeType);
106103
value.on('data', chunk => {
107104
resolveFileChunk(response, file, chunk);
108105
});
109106
value.on('end', () => {
110-
resolveFileComplete(response, file);
107+
resolveFileComplete(response, name, file);
111108
});
112109
});
113110
busboyStream.on('finish', () => {
@@ -123,20 +120,12 @@ function decodeReply<T>(
123120
body: string | FormData,
124121
webpackMap: ServerManifest,
125122
): Thenable<T> {
126-
const response = createResponse(webpackMap);
127123
if (typeof body === 'string') {
128-
resolveField(response, 0, body);
129-
} else {
130-
// $FlowFixMe[prop-missing] Flow doesn't know that forEach exists.
131-
body.forEach((value: string | File, key: string) => {
132-
const id = +key;
133-
if (typeof value === 'string') {
134-
resolveField(response, id, value);
135-
} else {
136-
resolveFile(response, id, value);
137-
}
138-
});
124+
const form = new FormData();
125+
form.append('0', body);
126+
body = form;
139127
}
128+
const response = createResponse(webpackMap, '', body);
140129
close(response);
141130
return getRoot(response);
142131
}

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ describe('ReactFlightDOMReply', () => {
3030
ReactServerDOMClient = require('react-server-dom-webpack/client');
3131
});
3232

33+
// This method should exist on File but is not implemented in JSDOM
34+
async function arrayBuffer(file) {
35+
return new Promise((resolve, reject) => {
36+
const reader = new FileReader();
37+
reader.onload = function () {
38+
return resolve(reader.result);
39+
};
40+
reader.onerror = function () {
41+
return reject(reader.error);
42+
};
43+
reader.readAsArrayBuffer(file);
44+
});
45+
}
46+
3347
it('can pass undefined as a reply', async () => {
3448
const body = await ReactServerDOMClient.encodeReply(undefined);
3549
const missing = await ReactServerDOMServer.decodeReply(
@@ -94,4 +108,84 @@ describe('ReactFlightDOMReply', () => {
94108

95109
expect(n).toEqual(90071992547409910000n);
96110
});
111+
112+
it('can pass FormData as a reply', async () => {
113+
const formData = new FormData();
114+
formData.set('hello', 'world');
115+
formData.append('list', '1');
116+
formData.append('list', '2');
117+
formData.append('list', '3');
118+
const typedArray = new Uint8Array([0, 1, 2, 3]);
119+
const blob = new Blob([typedArray]);
120+
formData.append('blob', blob, 'filename.blob');
121+
122+
const body = await ReactServerDOMClient.encodeReply(formData);
123+
const formData2 = await ReactServerDOMServer.decodeReply(
124+
body,
125+
webpackServerMap,
126+
);
127+
128+
expect(formData2).not.toBe(formData);
129+
expect(Array.from(formData2).length).toBe(5);
130+
expect(formData2.get('hello')).toBe('world');
131+
expect(formData2.getAll('list')).toEqual(['1', '2', '3']);
132+
const blob2 = formData.get('blob');
133+
expect(blob2.size).toBe(4);
134+
expect(blob2.name).toBe('filename.blob');
135+
expect(blob2.type).toBe('');
136+
const typedArray2 = new Uint8Array(await arrayBuffer(blob2));
137+
expect(typedArray2).toEqual(typedArray);
138+
});
139+
140+
it('can pass multiple Files in FormData', async () => {
141+
const typedArrayA = new Uint8Array([0, 1, 2, 3]);
142+
const typedArrayB = new Uint8Array([4, 5]);
143+
const blobA = new Blob([typedArrayA]);
144+
const blobB = new Blob([typedArrayB]);
145+
const formData = new FormData();
146+
formData.append('filelist', 'string');
147+
formData.append('filelist', blobA);
148+
formData.append('filelist', blobB);
149+
150+
const body = await ReactServerDOMClient.encodeReply(formData);
151+
const formData2 = await ReactServerDOMServer.decodeReply(
152+
body,
153+
webpackServerMap,
154+
);
155+
156+
const filelist2 = formData2.getAll('filelist');
157+
expect(filelist2.length).toBe(3);
158+
expect(filelist2[0]).toBe('string');
159+
const blobA2 = filelist2[1];
160+
expect(blobA2.size).toBe(4);
161+
expect(blobA2.name).toBe('blob');
162+
expect(blobA2.type).toBe('');
163+
const typedArrayA2 = new Uint8Array(await arrayBuffer(blobA2));
164+
expect(typedArrayA2).toEqual(typedArrayA);
165+
const blobB2 = filelist2[2];
166+
expect(blobB2.size).toBe(2);
167+
expect(blobB2.name).toBe('blob');
168+
expect(blobB2.type).toBe('');
169+
const typedArrayB2 = new Uint8Array(await arrayBuffer(blobB2));
170+
expect(typedArrayB2).toEqual(typedArrayB);
171+
});
172+
173+
it('can pass two independent FormData with same keys', async () => {
174+
const formDataA = new FormData();
175+
formDataA.set('greeting', 'hello');
176+
const formDataB = new FormData();
177+
formDataB.set('greeting', 'hi');
178+
179+
const body = await ReactServerDOMClient.encodeReply({
180+
a: formDataA,
181+
b: formDataB,
182+
});
183+
const {a: formDataA2, b: formDataB2} =
184+
await ReactServerDOMServer.decodeReply(body, webpackServerMap);
185+
186+
expect(Array.from(formDataA2).length).toBe(1);
187+
expect(Array.from(formDataB2).length).toBe(1);
188+
expect(formDataA2.get('greeting')).toBe('hello');
189+
expect(formDataB2.get('greeting')).toBe('hi');
190+
});
97191
});

0 commit comments

Comments
 (0)