diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js
index e3ba9462d63..5e6fe4927d2 100644
--- a/fixtures/flight/src/App.js
+++ b/fixtures/flight/src/App.js
@@ -7,8 +7,9 @@ import {Counter as Counter2} from './Counter2.js';
import ShowMore from './ShowMore.js';
import Button from './Button.js';
+import Form from './Form.js';
-import {like} from './actions.js';
+import {like, greet} from './actions.js';
export default async function App() {
const res = await fetch('http://localhost:3001/todos');
@@ -33,6 +34,7 @@ export default async function App() {
Lorem ipsum
+
diff --git a/fixtures/flight/src/Form.js b/fixtures/flight/src/Form.js
new file mode 100644
index 00000000000..3e8d8c244c0
--- /dev/null
+++ b/fixtures/flight/src/Form.js
@@ -0,0 +1,27 @@
+'use client';
+
+import * as React from 'react';
+
+export default function Form({action, children}) {
+ const [isPending, setIsPending] = React.useState(false);
+
+ return (
+
+ );
+}
diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js
index 687f3f39da0..7143c31a39d 100644
--- a/fixtures/flight/src/actions.js
+++ b/fixtures/flight/src/actions.js
@@ -3,3 +3,7 @@
export async function like() {
return new Promise((resolve, reject) => resolve('Liked'));
}
+
+export async function greet(formData) {
+ return 'Hi ' + formData.get('name') + '!';
+}
diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js
index 18fc2834e1d..eb3d8f83e6e 100644
--- a/packages/react-client/src/ReactFlightReplyClient.js
+++ b/packages/react-client/src/ReactFlightReplyClient.js
@@ -74,6 +74,11 @@ function serializeSymbolReference(name: string): string {
return '$S' + name;
}
+function serializeFormDataReference(id: number): string {
+ // Why K? F is "Function". D is "Date". What else?
+ return '$K' + id.toString(16);
+}
+
function serializeNumber(number: number): string | number {
if (Number.isFinite(number)) {
if (number === 0 && 1 / number === -Infinity) {
@@ -112,6 +117,7 @@ function escapeStringValue(value: string): string {
export function processReply(
root: ReactServerValue,
+ formFieldPrefix: string,
resolve: (string | FormData) => void,
reject: (error: mixed) => void,
): void {
@@ -171,7 +177,7 @@ export function processReply(
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
const data: FormData = formData;
// eslint-disable-next-line react-internal/safe-string-coercion
- data.append('' + promiseId, partJSON);
+ data.append(formFieldPrefix + promiseId, partJSON);
pendingParts--;
if (pendingParts === 0) {
resolve(data);
@@ -185,6 +191,24 @@ export function processReply(
);
return serializePromiseID(promiseId);
}
+ // TODO: Should we the Object.prototype.toString.call() to test for cross-realm objects?
+ if (value instanceof FormData) {
+ if (formData === null) {
+ // Upgrade to use FormData to allow us to use rich objects as its values.
+ formData = new FormData();
+ }
+ const data: FormData = formData;
+ const refId = nextPartId++;
+ // Copy all the form fields with a prefix for this reference.
+ // These must come first in the form order because we assume that all the
+ // fields are available before this is referenced.
+ const prefix = formFieldPrefix + refId + '_';
+ // $FlowFixMe[prop-missing]: FormData has forEach.
+ value.forEach((originalValue: string | File, originalKey: string) => {
+ data.append(prefix + originalKey, originalValue);
+ });
+ return serializeFormDataReference(refId);
+ }
if (!isArray(value)) {
const iteratorFn = getIteratorFn(value);
if (iteratorFn) {
@@ -268,7 +292,7 @@ export function processReply(
// The reference to this function came from the same client so we can pass it back.
const refId = nextPartId++;
// eslint-disable-next-line react-internal/safe-string-coercion
- formData.set('' + refId, metaDataJSON);
+ formData.set(formFieldPrefix + refId, metaDataJSON);
return serializeServerReferenceID(refId);
}
throw new Error(
@@ -308,7 +332,7 @@ export function processReply(
resolve(json);
} else {
// Otherwise, we use FormData to let us stream in the result.
- formData.set('0', json);
+ formData.set(formFieldPrefix + '0', json);
if (pendingParts === 0) {
// $FlowFixMe[incompatible-call] this has already been refined.
resolve(formData);
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js
index f847a636e6c..c835d0b81d7 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js
@@ -124,7 +124,7 @@ function encodeReply(
string | URLSearchParams | FormData,
> /* We don't use URLSearchParams yet but maybe */ {
return new Promise((resolve, reject) => {
- processReply(value, resolve, reject);
+ processReply(value, '', resolve, reject);
});
}
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
index d549c10693c..777e4271e6e 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
@@ -22,8 +22,6 @@ import {
import {
createResponse,
close,
- resolveField,
- resolveFile,
getRoot,
} from 'react-server/src/ReactFlightReplyServer';
@@ -79,20 +77,12 @@ function decodeReply(
body: string | FormData,
webpackMap: ServerManifest,
): Thenable {
- const response = createResponse(webpackMap);
if (typeof body === 'string') {
- resolveField(response, 0, body);
- } else {
- // $FlowFixMe[prop-missing] Flow doesn't know that forEach exists.
- body.forEach((value: string | File, key: string) => {
- const id = +key;
- if (typeof value === 'string') {
- resolveField(response, id, value);
- } else {
- resolveFile(response, id, value);
- }
- });
+ const form = new FormData();
+ form.append('0', body);
+ body = form;
}
+ const response = createResponse(webpackMap, '', body);
close(response);
return getRoot(response);
}
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
index d549c10693c..777e4271e6e 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
@@ -22,8 +22,6 @@ import {
import {
createResponse,
close,
- resolveField,
- resolveFile,
getRoot,
} from 'react-server/src/ReactFlightReplyServer';
@@ -79,20 +77,12 @@ function decodeReply(
body: string | FormData,
webpackMap: ServerManifest,
): Thenable {
- const response = createResponse(webpackMap);
if (typeof body === 'string') {
- resolveField(response, 0, body);
- } else {
- // $FlowFixMe[prop-missing] Flow doesn't know that forEach exists.
- body.forEach((value: string | File, key: string) => {
- const id = +key;
- if (typeof value === 'string') {
- resolveField(response, id, value);
- } else {
- resolveFile(response, id, value);
- }
- });
+ const form = new FormData();
+ form.append('0', body);
+ body = form;
}
+ const response = createResponse(webpackMap, '', body);
close(response);
return getRoot(response);
}
diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
index 98d4291de98..b1cda7e1042 100644
--- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
@@ -30,7 +30,6 @@ import {
reportGlobalError,
close,
resolveField,
- resolveFile,
resolveFileInfo,
resolveFileChunk,
resolveFileComplete,
@@ -88,10 +87,9 @@ function decodeReplyFromBusboy(
busboyStream: Busboy,
webpackMap: ServerManifest,
): Thenable {
- const response = createResponse(webpackMap);
+ const response = createResponse(webpackMap, '');
busboyStream.on('field', (name, value) => {
- const id = +name;
- resolveField(response, id, value);
+ resolveField(response, name, value);
});
busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => {
if (encoding.toLowerCase() === 'base64') {
@@ -101,13 +99,12 @@ function decodeReplyFromBusboy(
'the wrong assumption, we can easily fix it.',
);
}
- const id = +name;
- const file = resolveFileInfo(response, id, filename, mimeType);
+ const file = resolveFileInfo(response, name, filename, mimeType);
value.on('data', chunk => {
resolveFileChunk(response, file, chunk);
});
value.on('end', () => {
- resolveFileComplete(response, file);
+ resolveFileComplete(response, name, file);
});
});
busboyStream.on('finish', () => {
@@ -123,20 +120,12 @@ function decodeReply(
body: string | FormData,
webpackMap: ServerManifest,
): Thenable {
- const response = createResponse(webpackMap);
if (typeof body === 'string') {
- resolveField(response, 0, body);
- } else {
- // $FlowFixMe[prop-missing] Flow doesn't know that forEach exists.
- body.forEach((value: string | File, key: string) => {
- const id = +key;
- if (typeof value === 'string') {
- resolveField(response, id, value);
- } else {
- resolveFile(response, id, value);
- }
- });
+ const form = new FormData();
+ form.append('0', body);
+ body = form;
}
+ const response = createResponse(webpackMap, '', body);
close(response);
return getRoot(response);
}
diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
index f21fea4d41c..b49c1587211 100644
--- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
@@ -30,6 +30,20 @@ describe('ReactFlightDOMReply', () => {
ReactServerDOMClient = require('react-server-dom-webpack/client');
});
+ // This method should exist on File but is not implemented in JSDOM
+ async function arrayBuffer(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = function () {
+ return resolve(reader.result);
+ };
+ reader.onerror = function () {
+ return reject(reader.error);
+ };
+ reader.readAsArrayBuffer(file);
+ });
+ }
+
it('can pass undefined as a reply', async () => {
const body = await ReactServerDOMClient.encodeReply(undefined);
const missing = await ReactServerDOMServer.decodeReply(
@@ -94,4 +108,84 @@ describe('ReactFlightDOMReply', () => {
expect(n).toEqual(90071992547409910000n);
});
+
+ it('can pass FormData as a reply', async () => {
+ const formData = new FormData();
+ formData.set('hello', 'world');
+ formData.append('list', '1');
+ formData.append('list', '2');
+ formData.append('list', '3');
+ const typedArray = new Uint8Array([0, 1, 2, 3]);
+ const blob = new Blob([typedArray]);
+ formData.append('blob', blob, 'filename.blob');
+
+ const body = await ReactServerDOMClient.encodeReply(formData);
+ const formData2 = await ReactServerDOMServer.decodeReply(
+ body,
+ webpackServerMap,
+ );
+
+ expect(formData2).not.toBe(formData);
+ expect(Array.from(formData2).length).toBe(5);
+ expect(formData2.get('hello')).toBe('world');
+ expect(formData2.getAll('list')).toEqual(['1', '2', '3']);
+ const blob2 = formData.get('blob');
+ expect(blob2.size).toBe(4);
+ expect(blob2.name).toBe('filename.blob');
+ expect(blob2.type).toBe('');
+ const typedArray2 = new Uint8Array(await arrayBuffer(blob2));
+ expect(typedArray2).toEqual(typedArray);
+ });
+
+ it('can pass multiple Files in FormData', async () => {
+ const typedArrayA = new Uint8Array([0, 1, 2, 3]);
+ const typedArrayB = new Uint8Array([4, 5]);
+ const blobA = new Blob([typedArrayA]);
+ const blobB = new Blob([typedArrayB]);
+ const formData = new FormData();
+ formData.append('filelist', 'string');
+ formData.append('filelist', blobA);
+ formData.append('filelist', blobB);
+
+ const body = await ReactServerDOMClient.encodeReply(formData);
+ const formData2 = await ReactServerDOMServer.decodeReply(
+ body,
+ webpackServerMap,
+ );
+
+ const filelist2 = formData2.getAll('filelist');
+ expect(filelist2.length).toBe(3);
+ expect(filelist2[0]).toBe('string');
+ const blobA2 = filelist2[1];
+ expect(blobA2.size).toBe(4);
+ expect(blobA2.name).toBe('blob');
+ expect(blobA2.type).toBe('');
+ const typedArrayA2 = new Uint8Array(await arrayBuffer(blobA2));
+ expect(typedArrayA2).toEqual(typedArrayA);
+ const blobB2 = filelist2[2];
+ expect(blobB2.size).toBe(2);
+ expect(blobB2.name).toBe('blob');
+ expect(blobB2.type).toBe('');
+ const typedArrayB2 = new Uint8Array(await arrayBuffer(blobB2));
+ expect(typedArrayB2).toEqual(typedArrayB);
+ });
+
+ it('can pass two independent FormData with same keys', async () => {
+ const formDataA = new FormData();
+ formDataA.set('greeting', 'hello');
+ const formDataB = new FormData();
+ formDataB.set('greeting', 'hi');
+
+ const body = await ReactServerDOMClient.encodeReply({
+ a: formDataA,
+ b: formDataB,
+ });
+ const {a: formDataA2, b: formDataB2} =
+ await ReactServerDOMServer.decodeReply(body, webpackServerMap);
+
+ expect(Array.from(formDataA2).length).toBe(1);
+ expect(Array.from(formDataB2).length).toBe(1);
+ expect(formDataA2.get('greeting')).toBe('hello');
+ expect(formDataB2.get('greeting')).toBe('hi');
+ });
});
diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js
index a39ccb91bae..c3d5bef11c6 100644
--- a/packages/react-server/src/ReactFlightReplyServer.js
+++ b/packages/react-server/src/ReactFlightReplyServer.js
@@ -131,6 +131,8 @@ Chunk.prototype.then = function (
export type Response = {
_bundlerConfig: ServerManifest,
+ _prefix: string,
+ _formData: FormData,
_chunks: Map>,
_fromJSON: (key: string, value: JSONValue) => any,
};
@@ -309,7 +311,17 @@ function getChunk(response: Response, id: number): SomeChunk {
const chunks = response._chunks;
let chunk = chunks.get(id);
if (!chunk) {
- chunk = createPendingChunk(response);
+ const prefix = response._prefix;
+ const key = prefix + id;
+ // Check if we have this field in the backing store already.
+ const backingEntry = response._formData.get(key);
+ if (backingEntry != null) {
+ // We assume that this is a string entry for now.
+ chunk = createResolvedModelChunk(response, (backingEntry: any));
+ } else {
+ // We're still waiting on this entry to stream in.
+ chunk = createPendingChunk(response);
+ }
chunks.set(id, chunk);
}
return chunk;
@@ -397,6 +409,23 @@ function parseModelString(
key,
);
}
+ case 'K': {
+ // FormData
+ const stringId = value.substring(2);
+ const formPrefix = response._prefix + stringId + '_';
+ const data = new FormData();
+ const backingFormData = response._formData;
+ // We assume that the reference to FormData always comes after each
+ // entry that it references so we can assume they all exist in the
+ // backing store already.
+ // $FlowFixMe[prop-missing] FormData has forEach on it.
+ backingFormData.forEach((entry: File | string, entryKey: string) => {
+ if (entryKey.startsWith(formPrefix)) {
+ data.append(entryKey.substr(formPrefix.length), entry);
+ }
+ });
+ return data;
+ }
case 'I': {
// $Infinity
return Infinity;
@@ -452,10 +481,16 @@ function parseModelString(
return value;
}
-export function createResponse(bundlerConfig: ServerManifest): Response {
+export function createResponse(
+ bundlerConfig: ServerManifest,
+ formFieldPrefix: string,
+ backingFormData?: FormData = new FormData(),
+): Response {
const chunks: Map> = new Map();
const response: Response = {
_bundlerConfig: bundlerConfig,
+ _prefix: formFieldPrefix,
+ _formData: backingFormData,
_chunks: chunks,
_fromJSON: function (this: any, key: string, value: JSONValue) {
if (typeof value === 'string') {
@@ -470,31 +505,45 @@ export function createResponse(bundlerConfig: ServerManifest): Response {
export function resolveField(
response: Response,
- id: number,
- model: string,
+ key: string,
+ value: string,
): void {
- const chunks = response._chunks;
- const chunk = chunks.get(id);
- if (!chunk) {
- chunks.set(id, createResolvedModelChunk(response, model));
- } else {
- resolveModelChunk(chunk, model);
+ // Add this field to the backing store.
+ response._formData.append(key, value);
+ const prefix = response._prefix;
+ if (key.startsWith(prefix)) {
+ const chunks = response._chunks;
+ const id = +key.substr(prefix.length);
+ const chunk = chunks.get(id);
+ if (chunk) {
+ // We were waiting on this key so now we can resolve it.
+ resolveModelChunk(chunk, value);
+ }
}
}
-export function resolveFile(response: Response, id: number, file: File): void {
- throw new Error('Not implemented.');
+export function resolveFile(response: Response, key: string, file: File): void {
+ // Add this field to the backing store.
+ response._formData.append(key, file);
}
-export opaque type FileHandle = {};
+export opaque type FileHandle = {
+ chunks: Array,
+ filename: string,
+ mime: string,
+};
export function resolveFileInfo(
response: Response,
- id: number,
+ key: string,
filename: string,
mime: string,
): FileHandle {
- throw new Error('Not implemented.');
+ return {
+ chunks: [],
+ filename,
+ mime,
+ };
}
export function resolveFileChunk(
@@ -502,14 +551,17 @@ export function resolveFileChunk(
handle: FileHandle,
chunk: Uint8Array,
): void {
- throw new Error('Not implemented.');
+ handle.chunks.push(chunk);
}
export function resolveFileComplete(
response: Response,
+ key: string,
handle: FileHandle,
): void {
- throw new Error('Not implemented.');
+ // Add this file to the backing store.
+ const file = new File(handle.chunks, handle.filename, {type: handle.mime});
+ response._formData.append(key, file);
}
export function close(response: Response): void {