mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[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>
This commit is contained in:
co-authored by
Sophie Alpert
parent
1b4a0daba8
commit
d8089f2cf2
@@ -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() {
|
||||
<ShowMore>
|
||||
<p>Lorem ipsum</p>
|
||||
</ShowMore>
|
||||
<Form action={greet} />
|
||||
<div>
|
||||
<Button action={like}>Like</Button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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') + '!';
|
||||
}
|
||||
|
||||
+27
-3
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
import {
|
||||
createResponse,
|
||||
close,
|
||||
resolveField,
|
||||
resolveFile,
|
||||
getRoot,
|
||||
} from 'react-server/src/ReactFlightReplyServer';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
import {
|
||||
createResponse,
|
||||
close,
|
||||
resolveField,
|
||||
resolveFile,
|
||||
getRoot,
|
||||
} from 'react-server/src/ReactFlightReplyServer';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
reportGlobalError,
|
||||
close,
|
||||
resolveField,
|
||||
resolveFile,
|
||||
resolveFileInfo,
|
||||
resolveFileChunk,
|
||||
resolveFileComplete,
|
||||
@@ -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') {
|
||||
@@ -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', () => {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+69
-17
@@ -131,6 +131,8 @@ Chunk.prototype.then = function <T>(
|
||||
|
||||
export type Response = {
|
||||
_bundlerConfig: ServerManifest,
|
||||
_prefix: string,
|
||||
_formData: FormData,
|
||||
_chunks: Map<number, SomeChunk<any>>,
|
||||
_fromJSON: (key: string, value: JSONValue) => any,
|
||||
};
|
||||
@@ -309,7 +311,17 @@ function getChunk(response: Response, id: number): SomeChunk<any> {
|
||||
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<number, SomeChunk<any>> = 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<Uint8Array>,
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user