Repository: facebook/react
Pull request: [Flight] Fix File Upload in Node.js

Use the Blob constructor + append with filename instead of File constructor. Node.js doesn't expose a global File constructor but does support it in this form.

Queue fields until we get the 'end' event from the previous file. We rely on previous files being available by the time a field is resolved. However, since the 'end' event in Readable is fired after two micro-tasks, these are not resolved in order.

I use a queue of the fields while we're still waiting on files to finish. This still doesn't resolve files and fields in order relative to each other but that doesn't matter for our usage.

Changed files:
filename: fixtures/flight/src/Form.js
patch: @@ -20,8 +20,14 @@ export default function Form({action, children}) {
             React.startTransition(() => setIsPending(false));
           }
         }}>
-        <input name="name" />
+        <label>
+          Name: <input name="name" />
+        </label>
+        <label>
+          File: <input type="file" name="file" />
+        </label>
         <button>Say Hi</button>
+        {isPending ? 'Saving...' : null}
       </form>
     </ErrorBoundary>
   );

filename: fixtures/flight/src/actions.js
patch: @@ -5,5 +5,12 @@ export async function like() {
 }
 
 export async function greet(formData) {
-  return 'Hi ' + formData.get('name') + '!';
+  const name = formData.get('name') || 'you';
+  const file = formData.get('file');
+  if (file) {
+    return `Ok, ${name}, here is ${file.name}:
+      ${(await file.text()).toUpperCase()}
+    `;
+  }
+  return 'Hi ' + name + '!';
 }

filename: packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
patch: @@ -88,8 +88,17 @@ function decodeReplyFromBusboy<T>(
   webpackMap: ServerManifest,
 ): Thenable<T> {
   const response = createResponse(webpackMap, '');
+  let pendingFiles = 0;
+  const queuedFields: Array<string> = [];
   busboyStream.on('field', (name, value) => {
-    resolveField(response, name, value);
+    if (pendingFiles > 0) {
+      // Because the 'end' event fires two microtasks after the next 'field'
+      // we would resolve files and fields out of order. To handle this properly
+      // we queue any fields we receive until the previous file is done.
+      queuedFields.push(name, value);
+    } else {
+      resolveField(response, name, value);
+    }
   });
   busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
     if (encoding.toLowerCase() === 'base64') {
@@ -99,12 +108,21 @@ function decodeReplyFromBusboy<T>(
           'the wrong assumption, we can easily fix it.',
       );
     }
+    pendingFiles++;
     const file = resolveFileInfo(response, name, filename, mimeType);
     value.on('data', chunk => {
       resolveFileChunk(response, file, chunk);
     });
     value.on('end', () => {
       resolveFileComplete(response, name, file);
+      pendingFiles--;
+      if (pendingFiles === 0) {
+        // Release any queued fields
+        for (let i = 0; i < queuedFields.length; i += 2) {
+          resolveField(response, queuedFields[i], queuedFields[i + 1]);
+        }
+        queuedFields.length = 0;
+      }
     });
   });
   busboyStream.on('finish', () => {

filename: packages/react-server/src/ReactFlightReplyServer.js
patch: @@ -564,8 +564,11 @@ export function resolveFileComplete(
   handle: FileHandle,
 ): void {
   // Add this file to the backing store.
-  const file = new File(handle.chunks, handle.filename, {type: handle.mime});
-  response._formData.append(key, file);
+  // Node.js doesn't expose a global File constructor so we need to use
+  // the append() form that takes the file name as the third argument,
+  // to create a File object.
+  const blob = new Blob(handle.chunks, {type: handle.mime});
+  response._formData.append(key, blob, handle.filename);
 }
 
 export function close(response: Response): void {