From ef8bdbecb6dbb9743b895c2e867e5a5264dd6651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 10 Mar 2023 11:36:15 -0500 Subject: [PATCH] [Flight Reply] Add Reply Encoding (#26360) This adds `encodeReply` to the Flight Client and `decodeReply` to the Flight Server. Basically, it's a reverse Flight. It serializes values passed from the client to the server. I call this a "Reply". The tradeoffs and implementation details are a bit different so it requires its own implementation but is basically a clone of the Flight Server/Client but in reverse. Either through callServer or ServerContext. The goal of this project is to provide the equivalent serialization as passing props through RSC to client. Except React Elements and Components and such. So that you can pass a value to the client and back and it should have the same serialization constraints so when we add features in one direction we should mostly add it in the other. Browser support for streaming request bodies are currently very limited in that only Chrome supports it. So this doesn't produce a ReadableStream. Instead `encodeReply` produces either a JSON string or FormData. It uses a JSON string if it's a simple enough payload. For advanced features it uses FormData. This will also let the browser stream things like File objects (even though they're not yet supported since it follows the same rules as the other Flight). On the server side, you can either consume this by blocking on generating a FormData object or you can stream in the `multipart/form-data`. Even if the client isn't streaming data, the network does. On Node.js busboy seems to be the canonical library for this, so I exposed a `decodeReplyFromBusboy` in the Node build. However, if there's ever a web-standard way to stream form data, or if a library wins in that space we can support it. We can also just build a multipart parser that takes a ReadableStream built-in. On the server, server references passed as arguments are loaded from Node or Webpack just like the client or SSR does. This means that you can create higher order functions on the client or server. This can be tokenized when done from a server components but this is a security implication as it might be tempting to think that these are not fungible but you can swap one function for another on the client. So you have to basically treat an incoming argument as insecure, even if it's a function. I'm not too happy with the naming parity: Encode `server.renderToReadableStream` Decode: `client.createFromFetch` Decode `client.encodeReply` Decode: `server.decodeReply` This is mainly an implementation details of frameworks but it's annoying nonetheless. This comes from that `renderToReadableStream` does do some "rendering" by unwrapping server components etc. The `create` part comes from the parity with Fizz/Fiber where you `render` on the server and `create` a root on the client. Open to bike-shedding this some more. --------- Co-authored-by: Josh Story --- fixtures/flight/package.json | 1 + fixtures/flight/server/region.js | 19 +- fixtures/flight/src/actions.js | 10 +- fixtures/flight/src/index.js | 10 +- .../react-client/src/ReactFlightClient.js | 3 + .../src/ReactFlightReplyClient.js | 278 ++++++++++ .../src/ReactFlightServerReferenceRegistry.js | 17 + .../ReactFlightClientHostConfig.custom.js | 3 + .../ReactFlightClientHostConfig.dom-bun.js | 3 + .../ReactFlightDOMRelayClientHostConfig.js | 9 + .../src/ReactFlightClientNodeBundlerConfig.js | 14 + .../ReactFlightClientWebpackBundlerConfig.js | 14 + .../src/ReactFlightDOMClientBrowser.js | 16 +- .../src/ReactFlightDOMClientEdge.js | 2 +- .../src/ReactFlightDOMServerBrowser.js | 35 +- .../src/ReactFlightDOMServerEdge.js | 35 +- .../src/ReactFlightDOMServerNode.js | 75 ++- .../src/__tests__/ReactFlightDOM-test.js | 80 +-- .../__tests__/ReactFlightDOMBrowser-test.js | 129 +++-- .../src/__tests__/ReactFlightDOMEdge-test.js | 12 +- .../src/__tests__/ReactFlightDOMNode-test.js | 12 +- .../ReactFlightNativeRelayClientHostConfig.js | 9 + .../src/ReactFlightReplyServer.js | 496 ++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 286 +--------- packages/shared/ReactSerializationErrors.js | 290 ++++++++++ scripts/error-codes/codes.json | 7 +- scripts/flow/environment.js | 83 +++ 27 files changed, 1535 insertions(+), 413 deletions(-) create mode 100644 packages/react-client/src/ReactFlightReplyClient.js create mode 100644 packages/react-client/src/ReactFlightServerReferenceRegistry.js create mode 100644 packages/react-server/src/ReactFlightReplyServer.js create mode 100644 packages/shared/ReactSerializationErrors.js diff --git a/fixtures/flight/package.json b/fixtures/flight/package.json index 7a9ba25b142f7..3aed108e60176 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 a0f0cab45b77e..3481af3bf802d 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 f7dccd6a62f65..687f3f39da0dc 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 4150713398397..b8ef94844f2a5 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 ea6a09619edf1..cded0a689a729 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 0000000000000..ec0c54bd35278 --- /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 0000000000000..7436a19915d2e --- /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 3300d3bbca0c7..30810a69ebeb0 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 8f240e36e6a2f..28a1f34997f91 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 1bf240428f5f0..8b831bbe88814 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 d7bc2e2897bd4..b3bd02c054119 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 739546cb378f3..85aaab79a9056 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 ab24bf79f70e2..537b96187a00d 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 7eb7b50655654..9a68b21f6c63a 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 76eb5da99d159..ef282d3317ef0 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 76eb5da99d159..ef282d3317ef0 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 16a38a33a1673..f2653dca98c46 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 bdde8502758d7..b2a25dec436da 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 bc012126aeb9b..d53a9df2e3eb8 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 d08aafbcf2a19..e090add747cea 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 615cff2db9765..17b65bcddf786 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 8e7c18d31a3a9..e21df4d13e80c 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 0000000000000..b8e7d1817afe2 --- /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 5f7319972d8bf..8979ef5d98829 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 0000000000000..4e9627ca92ae5 --- /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 b6c671cf9d9a7..543893c7937fc 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 046e0a143542a..8f5b43fb4532c 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,