Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion fixtures/flight/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -33,6 +34,7 @@ export default async function App() {
<ShowMore>
<p>Lorem ipsum</p>
</ShowMore>
<Form action={greet} />
<div>
<Button action={like}>Like</Button>
</div>
Expand Down
27 changes: 27 additions & 0 deletions fixtures/flight/src/Form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import * as React from 'react';

export default function Form({action, children}) {
const [isPending, setIsPending] = React.useState(false);

return (
<form
onSubmit={async e => {
e.preventDefault();
setIsPending(true);
try {
const formData = new FormData(e.target);
const result = await action(formData);
alert(result);
} catch (error) {
console.error(error);
} finally {
setIsPending(false);
}
}}>
<input name="name" />
<button>Say Hi</button>
</form>
);
}
4 changes: 4 additions & 0 deletions fixtures/flight/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') + '!';
}
30 changes: 27 additions & 3 deletions packages/react-client/src/ReactFlightReplyClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import {
import {
createResponse,
close,
resolveField,
resolveFile,
getRoot,
} from 'react-server/src/ReactFlightReplyServer';

Expand Down Expand Up @@ -79,20 +77,12 @@ function decodeReply<T>(
body: string | FormData,
webpackMap: ServerManifest,
): Thenable<T> {
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);
}
Expand Down
18 changes: 4 additions & 14 deletions packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import {
import {
createResponse,
close,
resolveField,
resolveFile,
getRoot,
} from 'react-server/src/ReactFlightReplyServer';

Expand Down Expand Up @@ -79,20 +77,12 @@ function decodeReply<T>(
body: string | FormData,
webpackMap: ServerManifest,
): Thenable<T> {
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);
}
Expand Down
27 changes: 8 additions & 19 deletions packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
reportGlobalError,
close,
resolveField,
resolveFile,
resolveFileInfo,
resolveFileChunk,
resolveFileComplete,
Expand Down Expand Up @@ -88,10 +87,9 @@ function decodeReplyFromBusboy<T>(
busboyStream: Busboy,
webpackMap: ServerManifest,
): Thenable<T> {
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') {
Expand All @@ -101,13 +99,12 @@ function decodeReplyFromBusboy<T>(
'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', () => {
Expand All @@ -123,20 +120,12 @@ function decodeReply<T>(
body: string | FormData,
webpackMap: ServerManifest,
): Thenable<T> {
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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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');
});
});
Loading