diff --git a/fixtures/flight/package.json b/fixtures/flight/package.json index 7a9ba25b14..3aed108e60 100644 --- a/fixtures/flight/package.json +++ b/fixtures/flight/package.json @@ -17,6 +17,7 @@ "babel-preset-react-app": "^10.0.1", "body-parser": "^1.20.1", "browserslist": "^4.18.1", + "busboy": "^1.6.0", "camelcase": "^6.2.1", "case-sensitive-paths-webpack-plugin": "^2.4.0", "compression": "^1.7.4", diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index a0f0cab45b..3481af3bf8 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -33,6 +33,7 @@ if (typeof fetch === 'undefined') { const express = require('express'); const bodyParser = require('body-parser'); +const busboy = require('busboy'); const app = express(); const compress = require('compression'); @@ -95,9 +96,8 @@ app.get('/', async function (req, res) { }); app.post('/', bodyParser.text(), async function (req, res) { - const {renderToPipeableStream} = await import( - 'react-server-dom-webpack/server' - ); + const {renderToPipeableStream, decodeReply, decodeReplyFromBusboy} = + await import('react-server-dom-webpack/server'); const serverReference = req.get('rsc-action'); const [filepath, name] = serverReference.split('#'); const action = (await import(filepath))[name]; @@ -108,9 +108,18 @@ app.post('/', bodyParser.text(), async function (req, res) { throw new Error('Invalid action'); } - const args = JSON.parse(req.body); - const result = action.apply(null, args); + let args; + if (req.is('multipart/form-data')) { + // Use busboy to streamingly parse the reply from form-data. + const bb = busboy({headers: req.headers}); + const reply = decodeReplyFromBusboy(bb); + req.pipe(bb); + args = await reply; + } else { + args = await decodeReply(req.body); + } + const result = action.apply(null, args); const {pipe} = renderToPipeableStream(result, {}); pipe(res); }); diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index f7dccd6a62..687f3f39da 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -1,13 +1,5 @@ 'use server'; export async function like() { - return new Promise((resolve, reject) => - setTimeout( - () => - Math.random() > 0.5 - ? resolve('Liked') - : reject(new Error('Failed to like')), - 500 - ) - ); + return new Promise((resolve, reject) => resolve('Liked')); } diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index 4150713398..b8ef94844f 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -1,28 +1,28 @@ import * as React from 'react'; import {Suspense} from 'react'; import ReactDOM from 'react-dom/client'; -import ReactServerDOMReader from 'react-server-dom-webpack/client'; +import {createFromFetch, encodeReply} from 'react-server-dom-webpack/client'; // TODO: This should be a dependency of the App but we haven't implemented CSS in Node yet. import './style.css'; -let data = ReactServerDOMReader.createFromFetch( +let data = createFromFetch( fetch('/', { headers: { Accept: 'text/x-component', }, }), { - callServer(id, args) { + async callServer(id, args) { const response = fetch('/', { method: 'POST', headers: { Accept: 'text/x-component', 'rsc-action': id, }, - body: JSON.stringify(args), + body: await encodeReply(args), }); - return ReactServerDOMReader.createFromFetch(response); + return createFromFetch(response); }, } ); diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index ea6a09619e..cded0a689a 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -25,6 +25,8 @@ import { parseModel, } from './ReactFlightClientHostConfig'; +import {knownServerReferences} from './ReactFlightServerReferenceRegistry'; + import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry'; @@ -495,6 +497,7 @@ function createServerReferenceProxy, T>( return callServer(metaData.id, bound.concat(args)); }); }; + knownServerReferences.set(proxy, metaData); return proxy; } diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js new file mode 100644 index 0000000000..ec0c54bd35 --- /dev/null +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -0,0 +1,278 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Thenable} from 'shared/ReactTypes'; + +import {knownServerReferences} from './ReactFlightServerReferenceRegistry'; + +import { + REACT_ELEMENT_TYPE, + REACT_LAZY_TYPE, + REACT_PROVIDER_TYPE, +} from 'shared/ReactSymbols'; + +import { + describeObjectForErrorMessage, + isSimpleObject, + objectName, +} from 'shared/ReactSerializationErrors'; + +import isArray from 'shared/isArray'; + +type ReactJSONValue = + | string + | boolean + | number + | null + | $ReadOnlyArray + | ReactServerObject; + +export opaque type ServerReference = T; + +// Serializable values +export type ReactServerValue = + // References are passed by their value + | ServerReference + // The rest are passed as is. Sub-types can be passed in but lose their + // subtype, so the receiver can only accept once of these. + | string + | boolean + | number + | symbol + | null + | Iterable + | Array + | ReactServerObject + | Promise; // Thenable + +type ReactServerObject = {+[key: string]: ReactServerValue}; + +// function serializeByValueID(id: number): string { +// return '$' + id.toString(16); +// } + +function serializePromiseID(id: number): string { + return '$@' + id.toString(16); +} + +function serializeServerReferenceID(id: number): string { + return '$F' + id.toString(16); +} + +function serializeSymbolReference(name: string): string { + return '$S' + name; +} + +function escapeStringValue(value: string): string { + if (value[0] === '$') { + // We need to escape $ prefixed strings since we use those to encode + // references to IDs and as special symbol values. + return '$' + value; + } else { + return value; + } +} + +export function processReply( + root: ReactServerValue, + resolve: (string | FormData) => void, + reject: (error: mixed) => void, +): void { + let nextPartId = 1; + let pendingParts = 0; + let formData: null | FormData = null; + + function resolveToJSON( + this: + | {+[key: string | number]: ReactServerValue} + | $ReadOnlyArray, + key: string, + value: ReactServerValue, + ): ReactJSONValue { + const parent = this; + if (__DEV__) { + // $FlowFixMe + const originalValue = this[key]; + if (typeof originalValue === 'object' && originalValue !== value) { + if (objectName(originalValue) !== 'Object') { + console.error( + 'Only plain objects can be passed to Server Functions from the Client. ' + + '%s objects are not supported.%s', + objectName(originalValue), + describeObjectForErrorMessage(parent, key), + ); + } else { + console.error( + 'Only plain objects can be passed to Server Functions from the Client. ' + + 'Objects with toJSON methods are not supported. Convert it manually ' + + 'to a simple value before passing it to props.%s', + describeObjectForErrorMessage(parent, key), + ); + } + } + } + + if (value === null) { + return null; + } + + if (typeof value === 'object') { + // $FlowFixMe[method-unbinding] + if (typeof value.then === 'function') { + // We assume that any object with a .then property is a "Thenable" type, + // or a Promise type. Either of which can be represented by a Promise. + if (formData === null) { + // Upgrade to use FormData to allow us to stream this value. + formData = new FormData(); + } + pendingParts++; + const promiseId = nextPartId++; + const thenable: Thenable = (value: any); + thenable.then( + partValue => { + const partJSON = JSON.stringify(partValue, resolveToJSON); + // $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); + pendingParts--; + if (pendingParts === 0) { + resolve(data); + } + }, + reason => { + // In the future we could consider serializing this as an error + // that throws on the server instead. + reject(reason); + }, + ); + return serializePromiseID(promiseId); + } + + if (__DEV__) { + if (value !== null && !isArray(value)) { + // Verify that this is a simple plain object. + if ((value: any).$$typeof === REACT_ELEMENT_TYPE) { + console.error( + 'React Element cannot be passed to Server Functions from the Client.%s', + describeObjectForErrorMessage(parent, key), + ); + } else if ((value: any).$$typeof === REACT_LAZY_TYPE) { + console.error( + 'React Lazy cannot be passed to Server Functions from the Client.%s', + describeObjectForErrorMessage(parent, key), + ); + } else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) { + console.error( + 'React Context Providers cannot be passed to Server Functions from the Client.%s', + describeObjectForErrorMessage(parent, key), + ); + } else if (objectName(value) !== 'Object') { + console.error( + 'Only plain objects can be passed to Client Components from Server Components. ' + + '%s objects are not supported.%s', + objectName(value), + describeObjectForErrorMessage(parent, key), + ); + } else if (!isSimpleObject(value)) { + console.error( + 'Only plain objects can be passed to Client Components from Server Components. ' + + 'Classes or other objects with methods are not supported.%s', + describeObjectForErrorMessage(parent, key), + ); + } else if (Object.getOwnPropertySymbols) { + const symbols = Object.getOwnPropertySymbols(value); + if (symbols.length > 0) { + console.error( + 'Only plain objects can be passed to Client Components from Server Components. ' + + 'Objects with symbol properties like %s are not supported.%s', + symbols[0].description, + describeObjectForErrorMessage(parent, key), + ); + } + } + } + } + + // $FlowFixMe + return value; + } + + if (typeof value === 'string') { + return escapeStringValue(value); + } + + if ( + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'undefined' + ) { + return value; + } + + if (typeof value === 'function') { + const metaData = knownServerReferences.get(value); + if (metaData !== undefined) { + const metaDataJSON = JSON.stringify(metaData, resolveToJSON); + if (formData === null) { + // Upgrade to use FormData to allow us to stream this value. + formData = new FormData(); + } + // 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); + return serializeServerReferenceID(refId); + } + throw new Error( + 'Client Functions cannot be passed directly to Server Functions. ' + + 'Only Functions passed from the Server can be passed back again.', + ); + } + + if (typeof value === 'symbol') { + // $FlowFixMe `description` might be undefined + const name: string = value.description; + if (Symbol.for(name) !== value) { + throw new Error( + 'Only global symbols received from Symbol.for(...) can be passed to Server Functions. ' + + `The symbol Symbol.for(${ + // $FlowFixMe `description` might be undefined + value.description + }) cannot be found among global symbols.`, + ); + } + return serializeSymbolReference(name); + } + + if (typeof value === 'bigint') { + throw new Error( + `BigInt (${value}) is not yet supported as an argument to a Server Function.`, + ); + } + + throw new Error( + `Type ${typeof value} is not supported as an argument to a Server Function.`, + ); + } + + // $FlowFixMe[incompatible-type] it's not going to be undefined because we'll encode it. + const json: string = JSON.stringify(root, resolveToJSON); + if (formData === null) { + // If it's a simple data structure, we just use plain JSON. + resolve(json); + } else { + // Otherwise, we use FormData to let us stream in the result. + formData.set('0', json); + if (pendingParts === 0) { + // $FlowFixMe[incompatible-call] this has already been refined. + resolve(formData); + } + } +} diff --git a/packages/react-client/src/ReactFlightServerReferenceRegistry.js b/packages/react-client/src/ReactFlightServerReferenceRegistry.js new file mode 100644 index 0000000000..7436a19915 --- /dev/null +++ b/packages/react-client/src/ReactFlightServerReferenceRegistry.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Thenable} from 'shared/ReactTypes'; + +type ServerReferenceId = any; + +export const knownServerReferences: WeakMap< + Function, + {id: ServerReferenceId, bound: null | Thenable>}, +> = new WeakMap(); diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js index 3300d3bbca..30810a69eb 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js @@ -27,9 +27,12 @@ declare var $$$hostConfig: any; export type Response = any; export opaque type SSRManifest = mixed; +export opaque type ServerManifest = mixed; +export opaque type ServerReferenceId = string; export opaque type ClientReferenceMetadata = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export const resolveClientReference = $$$hostConfig.resolveClientReference; +export const resolveServerReference = $$$hostConfig.resolveServerReference; export const preloadModule = $$$hostConfig.preloadModule; export const requireModule = $$$hostConfig.requireModule; diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-bun.js index 8f240e36e6..28a1f34997 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.dom-bun.js @@ -12,8 +12,11 @@ export * from 'react-client/src/ReactFlightClientHostConfigStream'; export type Response = any; export opaque type SSRManifest = mixed; +export opaque type ServerManifest = mixed; +export opaque type ServerReferenceId = string; export opaque type ClientReferenceMetadata = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export const resolveClientReference: any = null; +export const resolveServerReference: any = null; export const preloadModule: any = null; export const requireModule: any = null; diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js index 1bf240428f..8b831bbe88 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js @@ -32,6 +32,8 @@ import isArray from 'shared/isArray'; export type {ClientReferenceMetadata} from 'ReactFlightDOMRelayClientIntegration'; export type SSRManifest = null; +export type ServerManifest = null; +export type ServerReferenceId = string; export type UninitializedModel = JSONValue; @@ -44,6 +46,13 @@ export function resolveClientReference( return resolveClientReferenceImpl(metadata); } +export function resolveServerReference( + bundlerConfig: ServerManifest, + id: ServerReferenceId, +): ClientReference { + throw new Error('Not implemented.'); +} + function parseModelRecursively( response: Response, parentObj: {+[key: string]: JSONValue} | $ReadOnlyArray, diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig.js index d7bc2e2897..b3bd02c054 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientNodeBundlerConfig.js @@ -19,6 +19,10 @@ export type SSRManifest = { }, }; +export type ServerManifest = void; + +export type ServerReferenceId = string; + export opaque type ClientReferenceMetadata = { id: string, chunks: Array, @@ -39,6 +43,16 @@ export function resolveClientReference( return resolvedModuleData; } +export function resolveServerReference( + bundlerConfig: ServerManifest, + id: ServerReferenceId, +): ClientReference { + const idx = id.lastIndexOf('#'); + const specifier = id.substr(0, idx); + const name = id.substr(idx + 1); + return {specifier, name}; +} + const asyncModuleCache: Map> = new Map(); export function preloadModule( diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js index 739546cb37..85aaab79a9 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js @@ -19,6 +19,12 @@ export type SSRManifest = null | { }, }; +export type ServerManifest = { + [id: string]: ClientReference, +}; + +export type ServerReferenceId = string; + export opaque type ClientReferenceMetadata = { id: string, chunks: Array, @@ -49,6 +55,14 @@ export function resolveClientReference( return metadata; } +export function resolveServerReference( + bundlerConfig: ServerManifest, + id: ServerReferenceId, +): ClientReference { + // This needs to return async: true if it's an async module. + return bundlerConfig[id]; +} + // The chunk cache contains all the chunks we've preloaded so far. // If they're still pending they're a thenable. This map also exists // in Webpack but unfortunately it's not exposed so we have to diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js index ab24bf79f7..537b96187a 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js @@ -11,6 +11,8 @@ import type {Thenable} from 'shared/ReactTypes.js'; import type {Response as FlightResponse} from 'react-client/src/ReactFlightClientStream'; +import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient'; + import { createResponse, getRoot, @@ -20,6 +22,8 @@ import { close, } from 'react-client/src/ReactFlightClientStream'; +import {processReply} from 'react-client/src/ReactFlightReplyClient'; + type CallServerCallback = (string, args: A) => Promise; export type Options = { @@ -111,4 +115,14 @@ function createFromXHR( return getRoot(response); } -export {createFromXHR, createFromFetch, createFromReadableStream}; +function encodeReply( + value: ReactServerValue, +): Promise< + string | URLSearchParams | FormData, +> /* We don't use URLSearchParams yet but maybe */ { + return new Promise((resolve, reject) => { + processReply(value, resolve, reject); + }); +} + +export {createFromXHR, createFromFetch, createFromReadableStream, encodeReply}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js index 7eb7b50655..9a68b21f6c 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -30,7 +30,7 @@ function noServerCall() { } export type Options = { - moduleMap?: SSRManifest, + moduleMap?: $NonMaybeType, }; function createResponseFromOptions(options: void | Options) { diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js index 76eb5da99d..ef282d3317 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js @@ -8,8 +8,9 @@ */ import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; -import type {ServerContextJSONValue} from 'shared/ReactTypes'; +import type {ServerContextJSONValue, Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerWebpackBundlerConfig'; +import type {ServerManifest} from 'react-client/src/ReactFlightClientHostConfig'; import { createRequest, @@ -18,6 +19,14 @@ import { abort, } from 'react-server/src/ReactFlightServer'; +import { + createResponse, + close, + resolveField, + resolveFile, + getRoot, +} from 'react-server/src/ReactFlightReplyServer'; + type Options = { identifierPrefix?: string, signal?: AbortSignal, @@ -66,4 +75,26 @@ function renderToReadableStream( return stream; } -export {renderToReadableStream}; +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); + } + }); + } + close(response); + return getRoot(response); +} + +export {renderToReadableStream, decodeReply}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js index 76eb5da99d..ef282d3317 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js @@ -8,8 +8,9 @@ */ import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; -import type {ServerContextJSONValue} from 'shared/ReactTypes'; +import type {ServerContextJSONValue, Thenable} from 'shared/ReactTypes'; import type {ClientManifest} from './ReactFlightServerWebpackBundlerConfig'; +import type {ServerManifest} from 'react-client/src/ReactFlightClientHostConfig'; import { createRequest, @@ -18,6 +19,14 @@ import { abort, } from 'react-server/src/ReactFlightServer'; +import { + createResponse, + close, + resolveField, + resolveFile, + getRoot, +} from 'react-server/src/ReactFlightReplyServer'; + type Options = { identifierPrefix?: string, signal?: AbortSignal, @@ -66,4 +75,26 @@ function renderToReadableStream( return stream; } -export {renderToReadableStream}; +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); + } + }); + } + close(response); + return getRoot(response); +} + +export {renderToReadableStream, decodeReply}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js index 16a38a33a1..f2653dca98 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js @@ -13,8 +13,10 @@ import type { } from 'react-server/src/ReactFlightServer'; import type {Destination} from 'react-server/src/ReactServerStreamConfigNode'; import type {ClientManifest} from './ReactFlightServerWebpackBundlerConfig'; +import type {ServerManifest} from 'react-client/src/ReactFlightClientHostConfig'; +import type {Busboy} from 'busboy'; import type {Writable} from 'stream'; -import type {ServerContextJSONValue} from 'shared/ReactTypes'; +import type {ServerContextJSONValue, Thenable} from 'shared/ReactTypes'; import { createRequest, @@ -23,6 +25,18 @@ import { abort, } from 'react-server/src/ReactFlightServer'; +import { + createResponse, + reportGlobalError, + close, + resolveField, + resolveFile, + resolveFileInfo, + resolveFileChunk, + resolveFileComplete, + getRoot, +} from 'react-server/src/ReactFlightReplyServer'; + function createDrainHandler(destination: Destination, request: Request) { return () => startFlowing(request, destination); } @@ -70,4 +84,61 @@ function renderToPipeableStream( }; } -export {renderToPipeableStream}; +function decodeReplyFromBusboy( + busboyStream: Busboy, + webpackMap: ServerManifest, +): Thenable { + const response = createResponse(webpackMap); + busboyStream.on('field', (name, value) => { + const id = +name; + resolveField(response, id, value); + }); + busboyStream.on('file', (name, value, {filename, encoding, mimeType}) => { + if (encoding.toLowerCase() === 'base64') { + throw new Error( + "React doesn't accept base64 encoded file uploads because we don't expect " + + "form data passed from a browser to ever encode data that way. If that's " + + 'the wrong assumption, we can easily fix it.', + ); + } + const id = +name; + const file = resolveFileInfo(response, id, filename, mimeType); + value.on('data', chunk => { + resolveFileChunk(response, file, chunk); + }); + value.on('end', () => { + resolveFileComplete(response, file); + }); + }); + busboyStream.on('finish', () => { + close(response); + }); + busboyStream.on('error', err => { + reportGlobalError(response, err); + }); + return getRoot(response); +} + +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); + } + }); + } + close(response); + return getRoot(response); +} + +export {renderToPipeableStream, decodeReplyFromBusboy, decodeReply}; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index bdde850275..b2a25dec43 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -26,8 +26,8 @@ let webpackMap; let Stream; let React; let ReactDOMClient; -let ReactServerDOMWriter; -let ReactServerDOMReader; +let ReactServerDOMServer; +let ReactServerDOMClient; let Suspense; let ErrorBoundary; @@ -45,8 +45,8 @@ describe('ReactFlightDOM', () => { use = React.use; Suspense = React.Suspense; ReactDOMClient = require('react-dom/client'); - ReactServerDOMWriter = require('react-server-dom-webpack/server.node.unbundled'); - ReactServerDOMReader = require('react-server-dom-webpack/client'); + ReactServerDOMClient = require('react-server-dom-webpack/client'); + ReactServerDOMServer = require('react-server-dom-webpack/server.node.unbundled'); ErrorBoundary = class extends React.Component { state = {hasError: false, error: null}; @@ -109,12 +109,12 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const model = await response; expect(model).toEqual({ html: ( @@ -159,12 +159,12 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -196,12 +196,12 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -231,12 +231,12 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -281,12 +281,12 @@ describe('ReactFlightDOM', () => { ); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -319,12 +319,12 @@ describe('ReactFlightDOM', () => { const {Component} = clientExports(Module); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -360,12 +360,12 @@ describe('ReactFlightDOM', () => { const AsyncModuleRef2 = await clientExports(AsyncModule2); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -399,12 +399,12 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -437,12 +437,12 @@ describe('ReactFlightDOM', () => { const ThenRef = clientExports(thenExports).then; const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -592,7 +592,7 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( model, webpackMap, { @@ -603,7 +603,7 @@ describe('ReactFlightDOM', () => { }, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -715,12 +715,12 @@ describe('ReactFlightDOM', () => { const root = ReactDOMClient.createRoot(container); const stream1 = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(stream1.writable); - const response1 = ReactServerDOMReader.createFromReadableStream( + const response1 = ReactServerDOMClient.createFromReadableStream( stream1.readable, ); await act(() => { @@ -743,12 +743,12 @@ describe('ReactFlightDOM', () => { inputB.value = 'goodbye'; const stream2 = getTestStream(); - const {pipe: pipe2} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe: pipe2} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe2(stream2.writable); - const response2 = ReactServerDOMReader.createFromReadableStream( + const response2 = ReactServerDOMClient.createFromReadableStream( stream2.readable, ); await act(() => { @@ -776,7 +776,7 @@ describe('ReactFlightDOM', () => { const reportedErrors = []; const {writable, readable} = getTestStream(); - const {pipe, abort} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe, abort} = ReactServerDOMServer.renderToPipeableStream(
, @@ -790,7 +790,7 @@ describe('ReactFlightDOM', () => { }, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -841,7 +841,7 @@ describe('ReactFlightDOM', () => { const ClientReference = clientModuleError(new Error('module init error')); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream(
, @@ -853,7 +853,7 @@ describe('ReactFlightDOM', () => { }, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -892,7 +892,7 @@ describe('ReactFlightDOM', () => { ); const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream(
, @@ -904,7 +904,7 @@ describe('ReactFlightDOM', () => { }, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -952,7 +952,7 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream(
, @@ -966,7 +966,7 @@ describe('ReactFlightDOM', () => { ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -1032,12 +1032,12 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -1089,7 +1089,7 @@ describe('ReactFlightDOM', () => { } const {writable, readable} = getTestStream(); - const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + const {pipe} = ReactServerDOMServer.renderToPipeableStream( , webpackMap, { @@ -1100,7 +1100,7 @@ describe('ReactFlightDOM', () => { }, ); pipe(writable); - const response = ReactServerDOMReader.createFromReadableStream(readable); + const response = ReactServerDOMClient.createFromReadableStream(readable); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index bc012126ae..d53a9df2e3 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -22,8 +22,8 @@ let webpackServerMap; let act; let React; let ReactDOMClient; -let ReactServerDOMWriter; -let ReactServerDOMReader; +let ReactServerDOMServer; +let ReactServerDOMClient; let Suspense; let use; @@ -38,8 +38,8 @@ describe('ReactFlightDOMBrowser', () => { webpackServerMap = WebpackMock.webpackServerMap; React = require('react'); ReactDOMClient = require('react-dom/client'); - ReactServerDOMWriter = require('react-server-dom-webpack/server.browser'); - ReactServerDOMReader = require('react-server-dom-webpack/client'); + ReactServerDOMServer = require('react-server-dom-webpack/server.browser'); + ReactServerDOMClient = require('react-server-dom-webpack/client'); Suspense = React.Suspense; use = React.use; }); @@ -74,6 +74,21 @@ describe('ReactFlightDOMBrowser', () => { throw theInfinitePromise; } + function requireServerRef(ref) { + const metaData = webpackServerMap[ref]; + const mod = __webpack_require__(metaData.id); + if (metaData.name === '*') { + return mod; + } + return mod[metaData.name]; + } + + async function callServer(actionId, body) { + const fn = requireServerRef(actionId); + const args = await ReactServerDOMServer.decodeReply(body, webpackServerMap); + return fn.apply(null, args); + } + it('should resolve HTML using W3C streams', async () => { function Text({children}) { return {children}; @@ -94,8 +109,8 @@ describe('ReactFlightDOMBrowser', () => { return model; } - const stream = ReactServerDOMWriter.renderToReadableStream(); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const stream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(stream); const model = await response; expect(model).toEqual({ html: ( @@ -127,8 +142,8 @@ describe('ReactFlightDOMBrowser', () => { return model; } - const stream = ReactServerDOMWriter.renderToReadableStream(); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const stream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(stream); const model = await response; expect(model).toEqual({ html: ( @@ -250,7 +265,7 @@ describe('ReactFlightDOMBrowser', () => { return use(response).rootContent; } - const stream = ReactServerDOMWriter.renderToReadableStream( + const stream = ReactServerDOMServer.renderToReadableStream( model, webpackMap, { @@ -260,7 +275,7 @@ describe('ReactFlightDOMBrowser', () => { }, }, ); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const response = ReactServerDOMClient.createFromReadableStream(stream); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -389,7 +404,7 @@ describe('ReactFlightDOMBrowser', () => { rootContent: , }; - const stream = ReactServerDOMWriter.renderToReadableStream( + const stream = ReactServerDOMServer.renderToReadableStream( model, webpackMap, ); @@ -489,7 +504,7 @@ describe('ReactFlightDOMBrowser', () => { } const controller = new AbortController(); - const stream = ReactServerDOMWriter.renderToReadableStream( + const stream = ReactServerDOMServer.renderToReadableStream(
, @@ -503,7 +518,7 @@ describe('ReactFlightDOMBrowser', () => { }, }, ); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const response = ReactServerDOMClient.createFromReadableStream(stream); const container = document.createElement('div'); const root = ReactDOMClient.createRoot(container); @@ -544,8 +559,8 @@ describe('ReactFlightDOMBrowser', () => { ); } - const stream = ReactServerDOMWriter.renderToReadableStream(); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const stream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { return use(response); @@ -578,8 +593,8 @@ describe('ReactFlightDOMBrowser', () => { ); } - const stream = ReactServerDOMWriter.renderToReadableStream(); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const stream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { return use(response); @@ -609,8 +624,8 @@ describe('ReactFlightDOMBrowser', () => { ); } - const stream = ReactServerDOMWriter.renderToReadableStream(); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const stream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { return use(response); @@ -643,7 +658,7 @@ describe('ReactFlightDOMBrowser', () => { } const reportedErrors = []; - const stream = ReactServerDOMWriter.renderToReadableStream( + const stream = ReactServerDOMServer.renderToReadableStream( , webpackMap, { @@ -653,7 +668,7 @@ describe('ReactFlightDOMBrowser', () => { }, }, ); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const response = ReactServerDOMClient.createFromReadableStream(stream); class ErrorBoundary extends React.Component { state = {error: null}; @@ -703,8 +718,8 @@ describe('ReactFlightDOMBrowser', () => { return use(thenable); } - const stream = ReactServerDOMWriter.renderToReadableStream(); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const stream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { return use(response); @@ -739,8 +754,8 @@ describe('ReactFlightDOMBrowser', () => { // Because the thenable resolves synchronously, we should be able to finish // rendering synchronously, with no fallback. - const stream = ReactServerDOMWriter.renderToReadableStream(); - const response = ReactServerDOMReader.createFromReadableStream(stream); + const stream = ReactServerDOMServer.renderToReadableStream(); + const response = ReactServerDOMClient.createFromReadableStream(stream); function Client() { return use(response); @@ -754,16 +769,7 @@ describe('ReactFlightDOMBrowser', () => { expect(container.innerHTML).toBe('Hi'); }); - function requireServerRef(ref) { - const metaData = webpackServerMap[ref]; - const mod = __webpack_require__(metaData.id); - if (metaData.name === '*') { - return mod; - } - return mod[metaData.name]; - } - - it('can pass a function by reference from server to client', async () => { + it('can pass a higher order function by reference from server to client', async () => { let actionProxy; function Client({action}) { @@ -771,24 +777,33 @@ describe('ReactFlightDOMBrowser', () => { return 'Click Me'; } - function send(text) { + function greet(transform, text) { + return 'Hello ' + transform(text); + } + + function upper(text) { return text.toUpperCase(); } - const ServerModule = serverExports({ - send, + const ServerModuleA = serverExports({ + greet, + }); + const ServerModuleB = serverExports({ + upper, }); const ClientRef = clientExports(Client); - const stream = ReactServerDOMWriter.renderToReadableStream( - , + const boundFn = ServerModuleA.greet.bind(null, ServerModuleB.upper); + + const stream = ReactServerDOMServer.renderToReadableStream( + , webpackMap, ); - const response = ReactServerDOMReader.createFromReadableStream(stream, { + const response = ReactServerDOMClient.createFromReadableStream(stream, { async callServer(ref, args) { - const fn = requireServerRef(ref); - return fn.apply(null, args); + const body = await ReactServerDOMClient.encodeReply(args); + return callServer(ref, body); }, }); @@ -803,10 +818,10 @@ describe('ReactFlightDOMBrowser', () => { }); expect(container.innerHTML).toBe('Click Me'); expect(typeof actionProxy).toBe('function'); - expect(actionProxy).not.toBe(send); + expect(actionProxy).not.toBe(boundFn); const result = await actionProxy('hi'); - expect(result).toBe('HI'); + expect(result).toBe('Hello HI'); }); it('can bind arguments to a server reference', async () => { @@ -822,15 +837,15 @@ describe('ReactFlightDOMBrowser', () => { }); const ClientRef = clientExports(Client); - const stream = ReactServerDOMWriter.renderToReadableStream( + const stream = ReactServerDOMServer.renderToReadableStream( , webpackMap, ); - const response = ReactServerDOMReader.createFromReadableStream(stream, { - async callServer(ref, args) { - const fn = requireServerRef(ref); - return fn.apply(null, args); + const response = ReactServerDOMClient.createFromReadableStream(stream, { + async callServer(actionId, args) { + const body = await ReactServerDOMClient.encodeReply(args); + return callServer(actionId, body); }, }); @@ -866,17 +881,17 @@ describe('ReactFlightDOMBrowser', () => { const ServerModule = serverExports({send}); const ClientRef = clientExports(Client); - const stream = ReactServerDOMWriter.renderToReadableStream( + const stream = ReactServerDOMServer.renderToReadableStream( , webpackMap, ); - const response = ReactServerDOMReader.createFromReadableStream(stream, { - async callServer(ref, args) { - const fn = requireServerRef(ref); - return ReactServerDOMReader.createFromReadableStream( - ReactServerDOMWriter.renderToReadableStream( - fn.apply(null, args), + const response = ReactServerDOMClient.createFromReadableStream(stream, { + async callServer(actionId, args) { + const body = await ReactServerDOMClient.encodeReply(args); + return ReactServerDOMClient.createFromReadableStream( + ReactServerDOMServer.renderToReadableStream( + callServer(actionId, body), null, {onError: error => 'test-error-digest'}, ), diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index d08aafbcf2..e090add747 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -24,8 +24,8 @@ let webpackMap; let webpackModules; let React; let ReactDOMServer; -let ReactServerDOMWriter; -let ReactServerDOMReader; +let ReactServerDOMServer; +let ReactServerDOMClient; let use; describe('ReactFlightDOMEdge', () => { @@ -37,8 +37,8 @@ describe('ReactFlightDOMEdge', () => { webpackModules = WebpackMock.webpackModules; React = require('react'); ReactDOMServer = require('react-dom/server.edge'); - ReactServerDOMWriter = require('react-server-dom-webpack/server.edge'); - ReactServerDOMReader = require('react-server-dom-webpack/client.edge'); + ReactServerDOMServer = require('react-server-dom-webpack/server.edge'); + ReactServerDOMClient = require('react-server-dom-webpack/client.edge'); use = React.use; }); @@ -81,11 +81,11 @@ describe('ReactFlightDOMEdge', () => { return ; } - const stream = ReactServerDOMWriter.renderToReadableStream( + const stream = ReactServerDOMServer.renderToReadableStream( , webpackMap, ); - const response = ReactServerDOMReader.createFromReadableStream(stream, { + const response = ReactServerDOMClient.createFromReadableStream(stream, { moduleMap: translationMap, }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index 615cff2db9..17b65bcddf 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -18,8 +18,8 @@ let webpackMap; let webpackModules; let React; let ReactDOMServer; -let ReactServerDOMWriter; -let ReactServerDOMReader; +let ReactServerDOMServer; +let ReactServerDOMClient; let Stream; let use; @@ -32,8 +32,8 @@ describe('ReactFlightDOMNode', () => { webpackModules = WebpackMock.webpackModules; React = require('react'); ReactDOMServer = require('react-dom/server.node'); - ReactServerDOMWriter = require('react-server-dom-webpack/server.node'); - ReactServerDOMReader = require('react-server-dom-webpack/client.node'); + ReactServerDOMServer = require('react-server-dom-webpack/server.node'); + ReactServerDOMClient = require('react-server-dom-webpack/client.node'); Stream = require('stream'); use = React.use; }); @@ -83,12 +83,12 @@ describe('ReactFlightDOMNode', () => { return ; } - const stream = ReactServerDOMWriter.renderToPipeableStream( + const stream = ReactServerDOMServer.renderToPipeableStream( , webpackMap, ); const readable = new Stream.PassThrough(); - const response = ReactServerDOMReader.createFromNodeStream( + const response = ReactServerDOMClient.createFromNodeStream( readable, translationMap, ); diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js index 8e7c18d31a..e21df4d13e 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js @@ -32,6 +32,8 @@ import isArray from 'shared/isArray'; export type {ClientReferenceMetadata} from 'ReactFlightNativeRelayClientIntegration'; export type SSRManifest = null; +export type ServerManifest = null; +export type ServerReferenceId = string; export type UninitializedModel = JSONValue; @@ -44,6 +46,13 @@ export function resolveClientReference( return resolveClientReferenceImpl(metadata); } +export function resolveServerReference( + bundlerConfig: ServerManifest, + id: ServerReferenceId, +): ClientReference { + throw new Error('Not implemented.'); +} + function parseModelRecursively( response: Response, parentObj: {+[key: string]: JSONValue} | $ReadOnlyArray, diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js new file mode 100644 index 0000000000..b8e7d1817a --- /dev/null +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -0,0 +1,496 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Thenable} from 'shared/ReactTypes'; + +// The server acts as a Client of itself when resolving Server References. +// That's why we import the Client configuration from the Server. +// Everything is aliased as their Server equivalence for clarity. +import type { + ServerReferenceId, + ServerManifest, + ClientReference as ServerReference, +} from 'react-client/src/ReactFlightClientHostConfig'; + +import { + resolveServerReference, + preloadModule, + requireModule, +} from 'react-client/src/ReactFlightClientHostConfig'; + +export type JSONValue = + | number + | null + | boolean + | string + | {+[key: string]: JSONValue} + | $ReadOnlyArray; + +const PENDING = 'pending'; +const BLOCKED = 'blocked'; +const RESOLVED_MODEL = 'resolved_model'; +const INITIALIZED = 'fulfilled'; +const ERRORED = 'rejected'; + +type PendingChunk = { + status: 'pending', + value: null | Array<(T) => mixed>, + reason: null | Array<(mixed) => mixed>, + _response: Response, + then(resolve: (T) => mixed, reject: (mixed) => mixed): void, +}; +type BlockedChunk = { + status: 'blocked', + value: null | Array<(T) => mixed>, + reason: null | Array<(mixed) => mixed>, + _response: Response, + then(resolve: (T) => mixed, reject: (mixed) => mixed): void, +}; +type ResolvedModelChunk = { + status: 'resolved_model', + value: string, + reason: null, + _response: Response, + then(resolve: (T) => mixed, reject: (mixed) => mixed): void, +}; +type InitializedChunk = { + status: 'fulfilled', + value: T, + reason: null, + _response: Response, + then(resolve: (T) => mixed, reject: (mixed) => mixed): void, +}; +type ErroredChunk = { + status: 'rejected', + value: null, + reason: mixed, + _response: Response, + then(resolve: (T) => mixed, reject: (mixed) => mixed): void, +}; +type SomeChunk = + | PendingChunk + | BlockedChunk + | ResolvedModelChunk + | InitializedChunk + | ErroredChunk; + +// $FlowFixMe[missing-this-annot] +function Chunk(status: any, value: any, reason: any, response: Response) { + this.status = status; + this.value = value; + this.reason = reason; + this._response = response; +} +// We subclass Promise.prototype so that we get other methods like .catch +Chunk.prototype = (Object.create(Promise.prototype): any); +// TODO: This doesn't return a new Promise chain unlike the real .then +Chunk.prototype.then = function ( + this: SomeChunk, + resolve: (value: T) => mixed, + reject: (reason: mixed) => mixed, +) { + const chunk: SomeChunk = this; + // If we have resolved content, we try to initialize it first which + // might put us back into one of the other states. + switch (chunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(chunk); + break; + } + // The status might have changed after initialization. + switch (chunk.status) { + case INITIALIZED: + resolve(chunk.value); + break; + case PENDING: + case BLOCKED: + if (resolve) { + if (chunk.value === null) { + chunk.value = ([]: Array<(T) => mixed>); + } + chunk.value.push(resolve); + } + if (reject) { + if (chunk.reason === null) { + chunk.reason = ([]: Array<(mixed) => mixed>); + } + chunk.reason.push(reject); + } + break; + default: + reject(chunk.reason); + break; + } +}; + +export type Response = { + _bundlerConfig: ServerManifest, + _chunks: Map>, + _fromJSON: (key: string, value: JSONValue) => any, +}; + +export function getRoot(response: Response): Thenable { + const chunk = getChunk(response, 0); + return (chunk: any); +} + +function createPendingChunk(response: Response): PendingChunk { + // $FlowFixMe Flow doesn't support functions as constructors + return new Chunk(PENDING, null, null, response); +} + +function wakeChunk(listeners: Array<(T) => mixed>, value: T): void { + for (let i = 0; i < listeners.length; i++) { + const listener = listeners[i]; + listener(value); + } +} + +function wakeChunkIfInitialized( + chunk: SomeChunk, + resolveListeners: Array<(T) => mixed>, + rejectListeners: null | Array<(mixed) => mixed>, +): void { + switch (chunk.status) { + case INITIALIZED: + wakeChunk(resolveListeners, chunk.value); + break; + case PENDING: + case BLOCKED: + chunk.value = resolveListeners; + chunk.reason = rejectListeners; + break; + case ERRORED: + if (rejectListeners) { + wakeChunk(rejectListeners, chunk.reason); + } + break; + } +} + +function triggerErrorOnChunk(chunk: SomeChunk, error: mixed): void { + if (chunk.status !== PENDING && chunk.status !== BLOCKED) { + // We already resolved. We didn't expect to see this. + return; + } + const listeners = chunk.reason; + const erroredChunk: ErroredChunk = (chunk: any); + erroredChunk.status = ERRORED; + erroredChunk.reason = error; + if (listeners !== null) { + wakeChunk(listeners, error); + } +} + +function createResolvedModelChunk( + response: Response, + value: string, +): ResolvedModelChunk { + // $FlowFixMe Flow doesn't support functions as constructors + return new Chunk(RESOLVED_MODEL, value, null, response); +} + +function resolveModelChunk(chunk: SomeChunk, value: string): void { + if (chunk.status !== PENDING) { + // We already resolved. We didn't expect to see this. + return; + } + const resolveListeners = chunk.value; + const rejectListeners = chunk.reason; + const resolvedChunk: ResolvedModelChunk = (chunk: any); + resolvedChunk.status = RESOLVED_MODEL; + resolvedChunk.value = value; + if (resolveListeners !== null) { + // This is unfortunate that we're reading this eagerly if + // we already have listeners attached since they might no + // longer be rendered or might not be the highest pri. + initializeModelChunk(resolvedChunk); + // The status might have changed after initialization. + wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners); + } +} + +function bindArgs(fn: any, args: any) { + return fn.bind.apply(fn, [null].concat(args)); +} + +function loadServerReference( + response: Response, + id: ServerReferenceId, + bound: null | Thenable>, + parentChunk: SomeChunk, + parentObject: Object, + key: string, +): T { + const serverReference: ServerReference = + resolveServerReference<$FlowFixMe>(response._bundlerConfig, id); + // We expect most servers to not really need this because you'd just have all + // the relevant modules already loaded but it allows for lazy loading of code + // if needed. + const preloadPromise = preloadModule(serverReference); + let promise: Promise; + if (bound) { + promise = Promise.all([(bound: any), preloadPromise]).then( + ([args]: Array) => bindArgs(requireModule(serverReference), args), + ); + } else { + if (preloadPromise) { + promise = Promise.resolve(preloadPromise).then(() => + requireModule(serverReference), + ); + } else { + // Synchronously available + return requireModule(serverReference); + } + } + promise.then( + createModelResolver(parentChunk, parentObject, key), + createModelReject(parentChunk), + ); + // We need a placeholder value that will be replaced later. + return (null: any); +} + +let initializingChunk: ResolvedModelChunk = (null: any); +let initializingChunkBlockedModel: null | {deps: number, value: any} = null; +function initializeModelChunk(chunk: ResolvedModelChunk): void { + const prevChunk = initializingChunk; + const prevBlocked = initializingChunkBlockedModel; + initializingChunk = chunk; + initializingChunkBlockedModel = null; + try { + const value: T = JSON.parse(chunk.value, chunk._response._fromJSON); + if ( + initializingChunkBlockedModel !== null && + initializingChunkBlockedModel.deps > 0 + ) { + initializingChunkBlockedModel.value = value; + // We discovered new dependencies on modules that are not yet resolved. + // We have to go the BLOCKED state until they're resolved. + const blockedChunk: BlockedChunk = (chunk: any); + blockedChunk.status = BLOCKED; + blockedChunk.value = null; + blockedChunk.reason = null; + } else { + const initializedChunk: InitializedChunk = (chunk: any); + initializedChunk.status = INITIALIZED; + initializedChunk.value = value; + } + } catch (error) { + const erroredChunk: ErroredChunk = (chunk: any); + erroredChunk.status = ERRORED; + erroredChunk.reason = error; + } finally { + initializingChunk = prevChunk; + initializingChunkBlockedModel = prevBlocked; + } +} + +// Report that any missing chunks in the model is now going to throw this +// error upon read. Also notify any pending promises. +export function reportGlobalError(response: Response, error: Error): void { + response._chunks.forEach(chunk => { + // If this chunk was already resolved or errored, it won't + // trigger an error but if it wasn't then we need to + // because we won't be getting any new data to resolve it. + if (chunk.status === PENDING) { + triggerErrorOnChunk(chunk, error); + } + }); +} + +function getChunk(response: Response, id: number): SomeChunk { + const chunks = response._chunks; + let chunk = chunks.get(id); + if (!chunk) { + chunk = createPendingChunk(response); + chunks.set(id, chunk); + } + return chunk; +} + +function createModelResolver( + chunk: SomeChunk, + parentObject: Object, + key: string, +): (value: any) => void { + let blocked; + if (initializingChunkBlockedModel) { + blocked = initializingChunkBlockedModel; + blocked.deps++; + } else { + blocked = initializingChunkBlockedModel = { + deps: 1, + value: null, + }; + } + return value => { + parentObject[key] = value; + blocked.deps--; + if (blocked.deps === 0) { + if (chunk.status !== BLOCKED) { + return; + } + const resolveListeners = chunk.value; + const initializedChunk: InitializedChunk = (chunk: any); + initializedChunk.status = INITIALIZED; + initializedChunk.value = blocked.value; + if (resolveListeners !== null) { + wakeChunk(resolveListeners, blocked.value); + } + } + }; +} + +function createModelReject(chunk: SomeChunk): (error: mixed) => void { + return (error: mixed) => triggerErrorOnChunk(chunk, error); +} + +function parseModelString( + response: Response, + parentObject: Object, + key: string, + value: string, +): any { + if (value[0] === '$') { + switch (value[1]) { + case '$': { + // This was an escaped string value. + return value.substring(1); + } + case '@': { + // Promise + const id = parseInt(value.substring(2), 16); + const chunk = getChunk(response, id); + return chunk; + } + case 'S': { + // Symbol + return Symbol.for(value.substring(2)); + } + case 'F': { + // Server Reference + const id = parseInt(value.substring(2), 16); + const chunk = getChunk(response, id); + if (chunk.status === RESOLVED_MODEL) { + initializeModelChunk(chunk); + } + if (chunk.status !== INITIALIZED) { + // We know that this is emitted earlier so otherwise it's an error. + throw chunk.reason; + } + // TODO: Just encode this in the reference inline instead of as a model. + const metaData: {id: ServerReferenceId, bound: Thenable>} = + chunk.value; + return loadServerReference( + response, + metaData.id, + metaData.bound, + initializingChunk, + parentObject, + key, + ); + } + default: { + // We assume that anything else is a reference ID. + const id = parseInt(value.substring(1), 16); + const chunk = getChunk(response, id); + switch (chunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(chunk); + break; + } + // The status might have changed after initialization. + switch (chunk.status) { + case INITIALIZED: + return chunk.value; + case PENDING: + case BLOCKED: + const parentChunk = initializingChunk; + chunk.then( + createModelResolver(parentChunk, parentObject, key), + createModelReject(parentChunk), + ); + return null; + default: + throw chunk.reason; + } + } + } + } + return value; +} + +export function createResponse(bundlerConfig: ServerManifest): Response { + const chunks: Map> = new Map(); + const response: Response = { + _bundlerConfig: bundlerConfig, + _chunks: chunks, + _fromJSON: function (this: any, key: string, value: JSONValue) { + if (typeof value === 'string') { + // We can't use .bind here because we need the "this" value. + return parseModelString(response, this, key, value); + } + return value; + }, + }; + return response; +} + +export function resolveField( + response: Response, + id: number, + model: string, +): void { + const chunks = response._chunks; + const chunk = chunks.get(id); + if (!chunk) { + chunks.set(id, createResolvedModelChunk(response, model)); + } else { + resolveModelChunk(chunk, model); + } +} + +export function resolveFile(response: Response, id: number, file: File): void { + throw new Error('Not implemented.'); +} + +export opaque type FileHandle = {}; + +export function resolveFileInfo( + response: Response, + id: number, + filename: string, + mime: string, +): FileHandle { + throw new Error('Not implemented.'); +} + +export function resolveFileChunk( + response: Response, + handle: FileHandle, + chunk: Uint8Array, +): void { + throw new Error('Not implemented.'); +} + +export function resolveFileComplete( + response: Response, + handle: FileHandle, +): void { + throw new Error('Not implemented.'); +} + +export function close(response: Response): void { + // In case there are any remaining unresolved chunks, they won't + // be resolved now. So we need to issue an error to those. + // Ideally we should be able to early bail out if we kept a + // ref count of pending chunks. + reportGlobalError(response, new Error('Connection closed.')); +} diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 5f7319972d..8979ef5d98 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -82,10 +82,17 @@ import { REACT_LAZY_TYPE, REACT_MEMO_TYPE, REACT_PROVIDER_TYPE, - REACT_SUSPENSE_TYPE, - REACT_SUSPENSE_LIST_TYPE, } from 'shared/ReactSymbols'; +import { + describeValueForErrorMessage, + describeObjectForErrorMessage, + isSimpleObject, + jsxPropsParents, + jsxChildrenParents, + objectName, +} from 'shared/ReactSerializationErrors'; + import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import isArray from 'shared/isArray'; @@ -233,11 +240,6 @@ function createRootContext( const POP = {}; -// Used for DEV messages to keep track of which parent rendered some props, -// in case they error. -const jsxPropsParents: WeakMap = new WeakMap(); -const jsxChildrenParents: WeakMap = new WeakMap(); - function serializeThenable(request: Request, thenable: Thenable): number { request.pendingChunks++; const newTask = createTask( @@ -644,7 +646,7 @@ function serializeServerReference( function escapeStringValue(value: string): string { if (value[0] === '$') { - // We need to escape $ or @ prefixed strings since we use those to encode + // We need to escape $ prefixed strings since we use those to encode // references to IDs and as special symbol values. return '$' + value; } else { @@ -652,274 +654,6 @@ function escapeStringValue(value: string): string { } } -function isObjectPrototype(object: any): boolean { - if (!object) { - return false; - } - const ObjectPrototype = Object.prototype; - if (object === ObjectPrototype) { - return true; - } - // It might be an object from a different Realm which is - // still just a plain simple object. - if (Object.getPrototypeOf(object)) { - return false; - } - const names = Object.getOwnPropertyNames(object); - for (let i = 0; i < names.length; i++) { - if (!(names[i] in ObjectPrototype)) { - return false; - } - } - return true; -} - -function isSimpleObject(object: any): boolean { - if (!isObjectPrototype(Object.getPrototypeOf(object))) { - return false; - } - const names = Object.getOwnPropertyNames(object); - for (let i = 0; i < names.length; i++) { - const descriptor = Object.getOwnPropertyDescriptor(object, names[i]); - if (!descriptor) { - return false; - } - if (!descriptor.enumerable) { - if ( - (names[i] === 'key' || names[i] === 'ref') && - typeof descriptor.get === 'function' - ) { - // React adds key and ref getters to props objects to issue warnings. - // Those getters will not be transferred to the client, but that's ok, - // so we'll special case them. - continue; - } - return false; - } - } - return true; -} - -function objectName(object: mixed): string { - // $FlowFixMe[method-unbinding] - const name = Object.prototype.toString.call(object); - return name.replace(/^\[object (.*)\]$/, function (m, p0) { - return p0; - }); -} - -function describeKeyForErrorMessage(key: string): string { - const encodedKey = JSON.stringify(key); - return '"' + key + '"' === encodedKey ? key : encodedKey; -} - -function describeValueForErrorMessage(value: ReactClientValue): string { - switch (typeof value) { - case 'string': { - return JSON.stringify( - value.length <= 10 ? value : value.substr(0, 10) + '...', - ); - } - case 'object': { - if (isArray(value)) { - return '[...]'; - } - const name = objectName(value); - if (name === 'Object') { - return '{...}'; - } - return name; - } - case 'function': - return 'function'; - default: - // eslint-disable-next-line react-internal/safe-string-coercion - return String(value); - } -} - -function describeElementType(type: any): string { - if (typeof type === 'string') { - return type; - } - switch (type) { - case REACT_SUSPENSE_TYPE: - return 'Suspense'; - case REACT_SUSPENSE_LIST_TYPE: - return 'SuspenseList'; - } - if (typeof type === 'object') { - switch (type.$$typeof) { - case REACT_FORWARD_REF_TYPE: - return describeElementType(type.render); - case REACT_MEMO_TYPE: - return describeElementType(type.type); - case REACT_LAZY_TYPE: { - const lazyComponent: LazyComponent = (type: any); - const payload = lazyComponent._payload; - const init = lazyComponent._init; - try { - // Lazy may contain any component type so we recursively resolve it. - return describeElementType(init(payload)); - } catch (x) {} - } - } - } - return ''; -} - -function describeObjectForErrorMessage( - objectOrArray: - | {+[key: string | number]: ReactClientValue, ...} - | $ReadOnlyArray, - expandedName?: string, -): string { - const objKind = objectName(objectOrArray); - if (objKind !== 'Object' && objKind !== 'Array') { - return objKind; - } - let str = ''; - let start = -1; - let length = 0; - if (isArray(objectOrArray)) { - if (__DEV__ && jsxChildrenParents.has(objectOrArray)) { - // Print JSX Children - const type = jsxChildrenParents.get(objectOrArray); - str = '<' + describeElementType(type) + '>'; - const array: $ReadOnlyArray = objectOrArray; - for (let i = 0; i < array.length; i++) { - const value = array[i]; - let substr; - if (typeof value === 'string') { - substr = value; - } else if (typeof value === 'object' && value !== null) { - // $FlowFixMe[incompatible-call] found when upgrading Flow - substr = '{' + describeObjectForErrorMessage(value) + '}'; - } else { - substr = '{' + describeValueForErrorMessage(value) + '}'; - } - if ('' + i === expandedName) { - start = str.length; - length = substr.length; - str += substr; - } else if (substr.length < 15 && str.length + substr.length < 40) { - str += substr; - } else { - str += '{...}'; - } - } - str += ''; - } else { - // Print Array - str = '['; - const array: $ReadOnlyArray = objectOrArray; - for (let i = 0; i < array.length; i++) { - if (i > 0) { - str += ', '; - } - const value = array[i]; - let substr; - if (typeof value === 'object' && value !== null) { - // $FlowFixMe[incompatible-call] found when upgrading Flow - substr = describeObjectForErrorMessage(value); - } else { - substr = describeValueForErrorMessage(value); - } - if ('' + i === expandedName) { - start = str.length; - length = substr.length; - str += substr; - } else if (substr.length < 10 && str.length + substr.length < 40) { - str += substr; - } else { - str += '...'; - } - } - str += ']'; - } - } else { - if (objectOrArray.$$typeof === REACT_ELEMENT_TYPE) { - str = '<' + describeElementType(objectOrArray.type) + '/>'; - } else if (__DEV__ && jsxPropsParents.has(objectOrArray)) { - // Print JSX - const type = jsxPropsParents.get(objectOrArray); - str = '<' + (describeElementType(type) || '...'); - const object: {+[key: string | number]: ReactClientValue, ...} = - objectOrArray; - const names = Object.keys(object); - for (let i = 0; i < names.length; i++) { - str += ' '; - const name = names[i]; - str += describeKeyForErrorMessage(name) + '='; - const value = object[name]; - let substr; - if ( - name === expandedName && - typeof value === 'object' && - value !== null - ) { - // $FlowFixMe[incompatible-call] found when upgrading Flow - substr = describeObjectForErrorMessage(value); - } else { - substr = describeValueForErrorMessage(value); - } - if (typeof value !== 'string') { - substr = '{' + substr + '}'; - } - if (name === expandedName) { - start = str.length; - length = substr.length; - str += substr; - } else if (substr.length < 10 && str.length + substr.length < 40) { - str += substr; - } else { - str += '...'; - } - } - str += '>'; - } else { - // Print Object - str = '{'; - const object: {+[key: string | number]: ReactClientValue, ...} = - objectOrArray; - const names = Object.keys(object); - for (let i = 0; i < names.length; i++) { - if (i > 0) { - str += ', '; - } - const name = names[i]; - str += describeKeyForErrorMessage(name) + ': '; - const value = object[name]; - let substr; - if (typeof value === 'object' && value !== null) { - // $FlowFixMe[incompatible-call] found when upgrading Flow - substr = describeObjectForErrorMessage(value); - } else { - substr = describeValueForErrorMessage(value); - } - if (name === expandedName) { - start = str.length; - length = substr.length; - str += substr; - } else if (substr.length < 10 && str.length + substr.length < 40) { - str += substr; - } else { - str += '...'; - } - } - str += '}'; - } - } - if (expandedName === undefined) { - return str; - } - if (start > -1 && length > 0) { - const highlight = ' '.repeat(start) + '^'.repeat(length); - return '\n ' + str + '\n ' + highlight; - } - return '\n ' + str; -} - let insideContextProps = null; let isInsideContextValue = false; diff --git a/packages/shared/ReactSerializationErrors.js b/packages/shared/ReactSerializationErrors.js new file mode 100644 index 0000000000..4e9627ca92 --- /dev/null +++ b/packages/shared/ReactSerializationErrors.js @@ -0,0 +1,290 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import { + REACT_ELEMENT_TYPE, + REACT_FORWARD_REF_TYPE, + REACT_LAZY_TYPE, + REACT_MEMO_TYPE, + REACT_SUSPENSE_TYPE, + REACT_SUSPENSE_LIST_TYPE, +} from 'shared/ReactSymbols'; + +import type {LazyComponent} from 'react/src/ReactLazy'; + +import isArray from 'shared/isArray'; + +// Used for DEV messages to keep track of which parent rendered some props, +// in case they error. +export const jsxPropsParents: WeakMap = new WeakMap(); +export const jsxChildrenParents: WeakMap = new WeakMap(); + +function isObjectPrototype(object: any): boolean { + if (!object) { + return false; + } + const ObjectPrototype = Object.prototype; + if (object === ObjectPrototype) { + return true; + } + // It might be an object from a different Realm which is + // still just a plain simple object. + if (Object.getPrototypeOf(object)) { + return false; + } + const names = Object.getOwnPropertyNames(object); + for (let i = 0; i < names.length; i++) { + if (!(names[i] in ObjectPrototype)) { + return false; + } + } + return true; +} + +export function isSimpleObject(object: any): boolean { + if (!isObjectPrototype(Object.getPrototypeOf(object))) { + return false; + } + const names = Object.getOwnPropertyNames(object); + for (let i = 0; i < names.length; i++) { + const descriptor = Object.getOwnPropertyDescriptor(object, names[i]); + if (!descriptor) { + return false; + } + if (!descriptor.enumerable) { + if ( + (names[i] === 'key' || names[i] === 'ref') && + typeof descriptor.get === 'function' + ) { + // React adds key and ref getters to props objects to issue warnings. + // Those getters will not be transferred to the client, but that's ok, + // so we'll special case them. + continue; + } + return false; + } + } + return true; +} + +export function objectName(object: mixed): string { + // $FlowFixMe[method-unbinding] + const name = Object.prototype.toString.call(object); + return name.replace(/^\[object (.*)\]$/, function (m, p0) { + return p0; + }); +} + +function describeKeyForErrorMessage(key: string): string { + const encodedKey = JSON.stringify(key); + return '"' + key + '"' === encodedKey ? key : encodedKey; +} + +export function describeValueForErrorMessage(value: mixed): string { + switch (typeof value) { + case 'string': { + return JSON.stringify( + value.length <= 10 ? value : value.substr(0, 10) + '...', + ); + } + case 'object': { + if (isArray(value)) { + return '[...]'; + } + const name = objectName(value); + if (name === 'Object') { + return '{...}'; + } + return name; + } + case 'function': + return 'function'; + default: + // eslint-disable-next-line react-internal/safe-string-coercion + return String(value); + } +} + +function describeElementType(type: any): string { + if (typeof type === 'string') { + return type; + } + switch (type) { + case REACT_SUSPENSE_TYPE: + return 'Suspense'; + case REACT_SUSPENSE_LIST_TYPE: + return 'SuspenseList'; + } + if (typeof type === 'object') { + switch (type.$$typeof) { + case REACT_FORWARD_REF_TYPE: + return describeElementType(type.render); + case REACT_MEMO_TYPE: + return describeElementType(type.type); + case REACT_LAZY_TYPE: { + const lazyComponent: LazyComponent = (type: any); + const payload = lazyComponent._payload; + const init = lazyComponent._init; + try { + // Lazy may contain any component type so we recursively resolve it. + return describeElementType(init(payload)); + } catch (x) {} + } + } + } + return ''; +} + +export function describeObjectForErrorMessage( + objectOrArray: {+[key: string | number]: mixed, ...} | $ReadOnlyArray, + expandedName?: string, +): string { + const objKind = objectName(objectOrArray); + if (objKind !== 'Object' && objKind !== 'Array') { + return objKind; + } + let str = ''; + let start = -1; + let length = 0; + if (isArray(objectOrArray)) { + if (__DEV__ && jsxChildrenParents.has(objectOrArray)) { + // Print JSX Children + const type = jsxChildrenParents.get(objectOrArray); + str = '<' + describeElementType(type) + '>'; + const array: $ReadOnlyArray = objectOrArray; + for (let i = 0; i < array.length; i++) { + const value = array[i]; + let substr; + if (typeof value === 'string') { + substr = value; + } else if (typeof value === 'object' && value !== null) { + // $FlowFixMe[incompatible-call] found when upgrading Flow + substr = '{' + describeObjectForErrorMessage(value) + '}'; + } else { + substr = '{' + describeValueForErrorMessage(value) + '}'; + } + if ('' + i === expandedName) { + start = str.length; + length = substr.length; + str += substr; + } else if (substr.length < 15 && str.length + substr.length < 40) { + str += substr; + } else { + str += '{...}'; + } + } + str += ''; + } else { + // Print Array + str = '['; + const array: $ReadOnlyArray = objectOrArray; + for (let i = 0; i < array.length; i++) { + if (i > 0) { + str += ', '; + } + const value = array[i]; + let substr; + if (typeof value === 'object' && value !== null) { + // $FlowFixMe[incompatible-call] found when upgrading Flow + substr = describeObjectForErrorMessage(value); + } else { + substr = describeValueForErrorMessage(value); + } + if ('' + i === expandedName) { + start = str.length; + length = substr.length; + str += substr; + } else if (substr.length < 10 && str.length + substr.length < 40) { + str += substr; + } else { + str += '...'; + } + } + str += ']'; + } + } else { + if (objectOrArray.$$typeof === REACT_ELEMENT_TYPE) { + str = '<' + describeElementType(objectOrArray.type) + '/>'; + } else if (__DEV__ && jsxPropsParents.has(objectOrArray)) { + // Print JSX + const type = jsxPropsParents.get(objectOrArray); + str = '<' + (describeElementType(type) || '...'); + const object: {+[key: string | number]: mixed, ...} = objectOrArray; + const names = Object.keys(object); + for (let i = 0; i < names.length; i++) { + str += ' '; + const name = names[i]; + str += describeKeyForErrorMessage(name) + '='; + const value = object[name]; + let substr; + if ( + name === expandedName && + typeof value === 'object' && + value !== null + ) { + // $FlowFixMe[incompatible-call] found when upgrading Flow + substr = describeObjectForErrorMessage(value); + } else { + substr = describeValueForErrorMessage(value); + } + if (typeof value !== 'string') { + substr = '{' + substr + '}'; + } + if (name === expandedName) { + start = str.length; + length = substr.length; + str += substr; + } else if (substr.length < 10 && str.length + substr.length < 40) { + str += substr; + } else { + str += '...'; + } + } + str += '>'; + } else { + // Print Object + str = '{'; + const object: {+[key: string | number]: mixed, ...} = objectOrArray; + const names = Object.keys(object); + for (let i = 0; i < names.length; i++) { + if (i > 0) { + str += ', '; + } + const name = names[i]; + str += describeKeyForErrorMessage(name) + ': '; + const value = object[name]; + let substr; + if (typeof value === 'object' && value !== null) { + // $FlowFixMe[incompatible-call] found when upgrading Flow + substr = describeObjectForErrorMessage(value); + } else { + substr = describeValueForErrorMessage(value); + } + if (name === expandedName) { + start = str.length; + length = substr.length; + str += substr; + } else if (substr.length < 10 && str.length + substr.length < 40) { + str += substr; + } else { + str += '...'; + } + } + str += '}'; + } + } + if (expandedName === undefined) { + return str; + } + if (start > -1 && length > 0) { + const highlight = ' '.repeat(start) + '^'.repeat(length); + return '\n ' + str + '\n ' + highlight; + } + return '\n ' + str; +} diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index b6c671cf9d..543893c793 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -453,5 +453,10 @@ "465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.", "466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime.", "467": "Update hook called on initial render. This is likely a bug in React. Please file an issue.", - "468": "getNodesForType encountered a type it did not expect: \"%s\". This is a bug in React." + "468": "getNodesForType encountered a type it did not expect: \"%s\". This is a bug in React.", + "469": "Client Functions cannot be passed directly to Server Functions. Only Functions passed from the Server can be passed back again.", + "470": "Only global symbols received from Symbol.for(...) can be passed to Server Functions. The symbol Symbol.for(%s) cannot be found among global symbols.", + "471": "BigInt (%s) is not yet supported as an argument to a Server Function.", + "472": "Type %s is not supported as an argument to a Server Function.", + "473": "React doesn't accept base64 encoded file uploads because we don't except form data passed from a browser to ever encode data that way. If that's the wrong assumption, we can easily fix it." } \ No newline at end of file diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js index 046e0a1435..8f5b43fb45 100644 --- a/scripts/flow/environment.js +++ b/scripts/flow/environment.js @@ -169,6 +169,89 @@ declare module 'util' { } } +declare module 'busboy' { + import type {Writable, Readable} from 'stream'; + + declare interface Info { + encoding: string; + mimeType: string; + } + + declare interface FileInfo extends Info { + filename: string; + } + + declare interface FieldInfo extends Info { + nameTruncated: boolean; + valueTruncated: boolean; + } + + declare interface BusboyEvents { + file: (name: string, stream: Readable, info: FileInfo) => void; + field: (name: string, value: string, info: FieldInfo) => void; + partsLimit: () => void; + filesLimit: () => void; + fieldsLimit: () => void; + error: (error: mixed) => void; + close: () => void; + } + declare interface Busboy extends Writable { + addListener>( + event: Event, + listener: BusboyEvents[Event], + ): Busboy; + addListener( + event: string | symbol, + listener: (...args: any[]) => void, + ): Busboy; + + on>( + event: Event, + listener: BusboyEvents[Event], + ): Busboy; + on(event: string | symbol, listener: (...args: any[]) => void): Busboy; + + once>( + event: Event, + listener: BusboyEvents[Event], + ): Busboy; + once(event: string | symbol, listener: (...args: any[]) => void): Busboy; + + removeListener>( + event: Event, + listener: BusboyEvents[Event], + ): Busboy; + removeListener( + event: string | symbol, + listener: (...args: any[]) => void, + ): Busboy; + + off>( + event: Event, + listener: BusboyEvents[Event], + ): Busboy; + off(event: string | symbol, listener: (...args: any[]) => void): Busboy; + + prependListener>( + event: Event, + listener: BusboyEvents[Event], + ): Busboy; + prependListener( + event: string | symbol, + listener: (...args: any[]) => void, + ): Busboy; + + prependOnceListener>( + event: Event, + listener: BusboyEvents[Event], + ): Busboy; + prependOnceListener( + event: string | symbol, + listener: (...args: any[]) => void, + ): Busboy; + } +} + declare module 'pg/lib/utils' { declare module.exports: { prepareValue(val: any): mixed,