@samitouri / QOS-React-2 / commits / d8089f2cf2

[Flight Reply] Encode FormData (#26663)

Builds on top of https://github.com/facebook/react/pull/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 <git@sophiebits.com>

Sebastian Markbåge committed Apr 18, 2023 at 14:57 UTC d8089f2cf27a17872a13805fce415b74fd3b107e
10 files changed +241 -69
fixtures/flight/src/App.js
+3 -1
@@ -7,8 +7,9 @@ import {Counter as Counter2} from './Counter2.js';
7
8 import ShowMore from './ShowMore.js';
9 import Button from './Button.js';
10 +import Form from './Form.js';
11
11 -import {like} from './actions.js';
12 +import {like, greet} from './actions.js';
13
14 export default async function App() {
15 const res = await fetch('http://localhost:3001/todos');
@@ -33,6 +34,7 @@ export default async function App() {
34 <ShowMore>
35 <p>Lorem ipsum</p>
36 </ShowMore>
37 + <Form action={greet} />
38 <div>
39 <Button action={like}>Like</Button>
40 </div>
fixtures/flight/src/Form.js new
+27
@@ -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
+4
@@ -3,3 +3,7 @@
3 export async function like() {
4 return new Promise((resolve, reject) => resolve('Liked'));
5 }
6 +
7 +export async function greet(formData) {
8 + return 'Hi ' + formData.get('name') + '!';
9 +}
packages/react-client/src/ReactFlightReplyClient.js
+27 -3
@@ -74,6 +74,11 @@ function serializeSymbolReference(name: string): string {
74 return '$S' + name;
75 }
76
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 +
82 function serializeNumber(number: number): string | number {
83 if (Number.isFinite(number)) {
84 if (number === 0 && 1 / number === -Infinity) {
@@ -112,6 +117,7 @@ function escapeStringValue(value: string): string {
117
118 export function processReply(
119 root: ReactServerValue,
120 + formFieldPrefix: string,
121 resolve: (string | FormData) => void,
122 reject: (error: mixed) => void,
123 ): void {
@@ -171,7 +177,7 @@ export function processReply(
177 // $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
178 const data: FormData = formData;
179 // eslint-disable-next-line react-internal/safe-string-coercion
174 - data.append('' + promiseId, partJSON);
180 + data.append(formFieldPrefix + promiseId, partJSON);
181 pendingParts--;
182 if (pendingParts === 0) {
183 resolve(data);
@@ -185,6 +191,24 @@ export function processReply(
191 );
192 return serializePromiseID(promiseId);
193 }
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 + }
212 if (!isArray(value)) {
213 const iteratorFn = getIteratorFn(value);
214 if (iteratorFn) {
@@ -268,7 +292,7 @@ export function processReply(
292 // The reference to this function came from the same client so we can pass it back.
293 const refId = nextPartId++;
294 // eslint-disable-next-line react-internal/safe-string-coercion
271 - formData.set('' + refId, metaDataJSON);
295 + formData.set(formFieldPrefix + refId, metaDataJSON);
296 return serializeServerReferenceID(refId);
297 }
298 throw new Error(
@@ -308,7 +332,7 @@ export function processReply(
332 resolve(json);
333 } else {
334 // Otherwise, we use FormData to let us stream in the result.
311 - formData.set('0', json);
335 + formData.set(formFieldPrefix + '0', json);
336 if (pendingParts === 0) {
337 // $FlowFixMe[incompatible-call] this has already been refined.
338 resolve(formData);
packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js
+1 -1
@@ -124,7 +124,7 @@ function encodeReply(
124 string | URLSearchParams | FormData,
125 > /* We don't use URLSearchParams yet but maybe */ {
126 return new Promise((resolve, reject) => {
127 - processReply(value, resolve, reject);
127 + processReply(value, '', resolve, reject);
128 });
129 }
130
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+4 -14
@@ -22,8 +22,6 @@ import {
22 import {
23 createResponse,
24 close,
25 - resolveField,
26 - resolveFile,
25 getRoot,
26 } from 'react-server/src/ReactFlightReplyServer';
27
@@ -79,20 +77,12 @@ function decodeReply<T>(
77 body: string | FormData,
78 webpackMap: ServerManifest,
79 ): Thenable<T> {
82 - const response = createResponse(webpackMap);
80 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;
84 }
85 + const response = createResponse(webpackMap, '', body);
86 close(response);
87 return getRoot(response);
88 }
packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+4 -14
@@ -22,8 +22,6 @@ import {
22 import {
23 createResponse,
24 close,
25 - resolveField,
26 - resolveFile,
25 getRoot,
26 } from 'react-server/src/ReactFlightReplyServer';
27
@@ -79,20 +77,12 @@ function decodeReply<T>(
77 body: string | FormData,
78 webpackMap: ServerManifest,
79 ): Thenable<T> {
82 - const response = createResponse(webpackMap);
80 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;
84 }
85 + const response = createResponse(webpackMap, '', body);
86 close(response);
87 return getRoot(response);
88 }
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+8 -19
@@ -30,7 +30,6 @@ import {
30 reportGlobalError,
31 close,
32 resolveField,
33 - resolveFile,
33 resolveFileInfo,
34 resolveFileChunk,
35 resolveFileComplete,
@@ -88,10 +87,9 @@ function decodeReplyFromBusboy<T>(
87 busboyStream: Busboy,
88 webpackMap: ServerManifest,
89 ): Thenable<T> {
91 - const response = createResponse(webpackMap);
90 + const response = createResponse(webpackMap, '');
91 busboyStream.on('field', (name, value) => {
93 - const id = +name;
94 - resolveField(response, id, value);
92 + resolveField(response, name, value);
93 });
94 busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
95 if (encoding.toLowerCase() === 'base64') {
@@ -101,13 +99,12 @@ function decodeReplyFromBusboy<T>(
99 'the wrong assumption, we can easily fix it.',
100 );
101 }
104 - const id = +name;
105 - const file = resolveFileInfo(response, id, filename, mimeType);
102 + const file = resolveFileInfo(response, name, filename, mimeType);
103 value.on('data', chunk => {
104 resolveFileChunk(response, file, chunk);
105 });
106 value.on('end', () => {
110 - resolveFileComplete(response, file);
107 + resolveFileComplete(response, name, file);
108 });
109 });
110 busboyStream.on('finish', () => {
@@ -123,20 +120,12 @@ function decodeReply<T>(
120 body: string | FormData,
121 webpackMap: ServerManifest,
122 ): Thenable<T> {
126 - const response = createResponse(webpackMap);
123 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;
127 }
128 + const response = createResponse(webpackMap, '', body);
129 close(response);
130 return getRoot(response);
131 }
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+94
@@ -30,6 +30,20 @@ describe('ReactFlightDOMReply', () => {
30 ReactServerDOMClient = require('react-server-dom-webpack/client');
31 });
32
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 +
47 it('can pass undefined as a reply', async () => {
48 const body = await ReactServerDOMClient.encodeReply(undefined);
49 const missing = await ReactServerDOMServer.decodeReply(
@@ -94,4 +108,84 @@ describe('ReactFlightDOMReply', () => {
108
109 expect(n).toEqual(90071992547409910000n);
110 });
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 + });
191 });
packages/react-server/src/ReactFlightReplyServer.js
+69 -17
@@ -131,6 +131,8 @@ Chunk.prototype.then = function <T>(
131
132 export type Response = {
133 _bundlerConfig: ServerManifest,
134 + _prefix: string,
135 + _formData: FormData,
136 _chunks: Map<number, SomeChunk<any>>,
137 _fromJSON: (key: string, value: JSONValue) => any,
138 };
@@ -309,7 +311,17 @@ function getChunk(response: Response, id: number): SomeChunk<any> {
311 const chunks = response._chunks;
312 let chunk = chunks.get(id);
313 if (!chunk) {
312 - chunk = createPendingChunk(response);
314 + const prefix = response._prefix;
315 + const key = prefix + id;
316 + // Check if we have this field in the backing store already.
317 + const backingEntry = response._formData.get(key);
318 + if (backingEntry != null) {
319 + // We assume that this is a string entry for now.
320 + chunk = createResolvedModelChunk(response, (backingEntry: any));
321 + } else {
322 + // We're still waiting on this entry to stream in.
323 + chunk = createPendingChunk(response);
324 + }
325 chunks.set(id, chunk);
326 }
327 return chunk;
@@ -397,6 +409,23 @@ function parseModelString(
409 key,
410 );
411 }
412 + case 'K': {
413 + // FormData
414 + const stringId = value.substring(2);
415 + const formPrefix = response._prefix + stringId + '_';
416 + const data = new FormData();
417 + const backingFormData = response._formData;
418 + // We assume that the reference to FormData always comes after each
419 + // entry that it references so we can assume they all exist in the
420 + // backing store already.
421 + // $FlowFixMe[prop-missing] FormData has forEach on it.
422 + backingFormData.forEach((entry: File | string, entryKey: string) => {
423 + if (entryKey.startsWith(formPrefix)) {
424 + data.append(entryKey.substr(formPrefix.length), entry);
425 + }
426 + });
427 + return data;
428 + }
429 case 'I': {
430 // $Infinity
431 return Infinity;
@@ -452,10 +481,16 @@ function parseModelString(
481 return value;
482 }
483
455 -export function createResponse(bundlerConfig: ServerManifest): Response {
484 +export function createResponse(
485 + bundlerConfig: ServerManifest,
486 + formFieldPrefix: string,
487 + backingFormData?: FormData = new FormData(),
488 +): Response {
489 const chunks: Map<number, SomeChunk<any>> = new Map();
490 const response: Response = {
491 _bundlerConfig: bundlerConfig,
492 + _prefix: formFieldPrefix,
493 + _formData: backingFormData,
494 _chunks: chunks,
495 _fromJSON: function (this: any, key: string, value: JSONValue) {
496 if (typeof value === 'string') {
@@ -470,31 +505,45 @@ export function createResponse(bundlerConfig: ServerManifest): Response {
505
506 export function resolveField(
507 response: Response,
473 - id: number,
474 - model: string,
508 + key: string,
509 + value: string,
510 ): void {
476 - const chunks = response._chunks;
477 - const chunk = chunks.get(id);
478 - if (!chunk) {
479 - chunks.set(id, createResolvedModelChunk(response, model));
480 - } else {
481 - resolveModelChunk(chunk, model);
511 + // Add this field to the backing store.
512 + response._formData.append(key, value);
513 + const prefix = response._prefix;
514 + if (key.startsWith(prefix)) {
515 + const chunks = response._chunks;
516 + const id = +key.substr(prefix.length);
517 + const chunk = chunks.get(id);
518 + if (chunk) {
519 + // We were waiting on this key so now we can resolve it.
520 + resolveModelChunk(chunk, value);
521 + }
522 }
523 }
524
485 -export function resolveFile(response: Response, id: number, file: File): void {
486 - throw new Error('Not implemented.');
525 +export function resolveFile(response: Response, key: string, file: File): void {
526 + // Add this field to the backing store.
527 + response._formData.append(key, file);
528 }
529
489 -export opaque type FileHandle = {};
530 +export opaque type FileHandle = {
531 + chunks: Array<Uint8Array>,
532 + filename: string,
533 + mime: string,
534 +};
535
536 export function resolveFileInfo(
537 response: Response,
493 - id: number,
538 + key: string,
539 filename: string,
540 mime: string,
541 ): FileHandle {
497 - throw new Error('Not implemented.');
542 + return {
543 + chunks: [],
544 + filename,
545 + mime,
546 + };
547 }
548
549 export function resolveFileChunk(
@@ -502,14 +551,17 @@ export function resolveFileChunk(
551 handle: FileHandle,
552 chunk: Uint8Array,
553 ): void {
505 - throw new Error('Not implemented.');
554 + handle.chunks.push(chunk);
555 }
556
557 export function resolveFileComplete(
558 response: Response,
559 + key: string,
560 handle: FileHandle,
561 ): void {
512 - throw new Error('Not implemented.');
562 + // Add this file to the backing store.
563 + const file = new File(handle.chunks, handle.filename, {type: handle.mime});
564 + response._formData.append(key, file);
565 }
566
567 export function close(response: Response): void {