From 5868c0ed30a0693fcdb3453f79f40e5dc90be0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 19 Mar 2024 16:59:52 -0400 Subject: [PATCH] [Flight] Encode React Elements in Replies as Temporary References (#28564) Currently you can accidentally pass React Element to a Server Action. It warns but in prod it actually works because we can encode the symbol and otherwise it's mostly a plain object. It only works if you only pass host components and no function props etc. which makes it potentially error later. The first thing this does it just early hard error for elements. I made Lazy work by unwrapping though since that will be replaced by Promises later which works. Our protocol is not fully symmetric in that elements flow from Server -> Client. Only the Server can resolve Components and only the client should really be able to receive host components. It's not intended that a Server can actually do something with them other than passing them to the client. In the case of a Reply, we expect the client to be stateful. It's waiting for a response. So anything we can't serialize we can still pass by reference to an in memory object. So I introduce the concept of a TemporaryReferenceSet which is an opaque object that you create before encoding the reply. This then stashes any unserializable values in this set and encode the slot by id. When a new response from the Action then returns we pass the same temporary set into the parser which can then restore the objects. This lets you pass a value by reference to the server and back into another slot. For example it can be used to render children inside a parent tree from a server action: ``` export async function Component({ children }) { "use server"; return
{children}
; } ``` (You wouldn't normally do this due to the waterfalls but for advanced cases.) A common scenario where this comes up accidentally today is in `useActionState`. ``` export function action(state, formData) { "use server"; if (errored) { return
This action errored
; } return null; } ``` ``` const [errors, formAction] = useActionState(action); return
{errors}
; ``` It feels like I'm just passing the JSX from server to client. However, because `useActionState` also sends the previous state *back* to the server this should not actually be valid. Before this PR this actually worked accidentally. You get a DEV warning but it used to work in prod. Once you do something like pass a client reference it won't work tho. We could perhaps make client references work by stashing where we got them from but it wouldn't work with all possible JSX. By adding temporary references to the action implementation this will work again - on the client. It'll also be more efficient since we don't send back the JSX content that you shouldn't introspect on the server anyway. However, a flaw here is that the progressive enhancement of this case won't work because we can't use temporary references for progressive enhancement since there's no in memory stash. What is worse is that it won't error if you hydrate. ~It also will error late in the example above because the first state is "undefined" so invoking the form once works - it errors on the second attempt when it tries to send the error state back again.~ It actually errors on the first invocation because we need to eagerly serialize "previous state" into the form. So at least that's better. I think maybe the solution to this particular pattern would be to allow JSX to serialize if you have no temporary reference set, and remember client references so that client references can be returned back to the server as client references. That way anything you could send from the server could also be returned to the server. But it would only deopt to serializing it for progressive enhancement. The consequence of that would be that there's a lot of JSX that might accidentally seem like it should work but it's only if you've gotten it from the server before that it works. This would have to have pair them somehow though since you can't take a client reference from one implementation of Flight and use it with another. --- .../react-client/src/ReactFlightClient.js | 19 +++ .../src/ReactFlightReplyClient.js | 147 ++++++++++++++---- .../src/ReactFlightTemporaryReferences.js | 41 +++++ .../src/ReactFlightDOMClientBrowser.js | 21 ++- .../src/ReactFlightDOMClientNode.js | 1 + .../src/ReactFlightDOMClientBrowser.js | 21 ++- .../src/ReactFlightDOMClientEdge.js | 21 ++- .../src/ReactFlightDOMClientNode.js | 1 + .../src/ReactFlightDOMClientBrowser.js | 21 ++- .../src/ReactFlightDOMClientEdge.js | 21 ++- .../src/ReactFlightDOMClientNode.js | 1 + .../src/__tests__/ReactFlightDOMReply-test.js | 105 +++++++++++++ .../src/ReactFlightReplyServer.js | 6 + .../react-server/src/ReactFlightServer.js | 26 +++- .../ReactFlightServerTemporaryReferences.js | 99 ++++++++++++ scripts/error-codes/codes.json | 9 +- 16 files changed, 525 insertions(+), 35 deletions(-) create mode 100644 packages/react-client/src/ReactFlightTemporaryReferences.js create mode 100644 packages/react-server/src/ReactFlightServerTemporaryReferences.js diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 63342962e2a08..435ee06aac44b 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -35,6 +35,8 @@ import type { import type {Postpone} from 'react/src/ReactPostpone'; +import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; + import { enableBinaryFlight, enablePostpone, @@ -55,6 +57,8 @@ import { import {registerServerReference} from './ReactFlightReplyClient'; +import {readTemporaryReference} from './ReactFlightTemporaryReferences'; + import { REACT_LAZY_TYPE, REACT_ELEMENT_TYPE, @@ -224,6 +228,7 @@ export type Response = { _rowTag: number, // 0 indicates that we're currently parsing the row ID _rowLength: number, // remaining bytes in the row. 0 indicates that we're looking for a newline. _buffer: Array, // chunks received so far as part of this row + _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from }; function readChunk(chunk: SomeChunk): T { @@ -689,6 +694,18 @@ function parseModelString( const metadata = getOutlinedModel(response, id); return createServerReferenceProxy(response, metadata); } + case 'T': { + // Temporary Reference + const id = parseInt(value.slice(2), 16); + const temporaryReferences = response._tempRefs; + if (temporaryReferences == null) { + throw new Error( + 'Missing a temporary reference set but the RSC response returned a temporary reference. ' + + 'Pass a temporaryReference option with the set that was used with the reply.', + ); + } + return readTemporaryReference(temporaryReferences, id); + } case 'Q': { // Map const id = parseInt(value.slice(2), 16); @@ -837,6 +854,7 @@ export function createResponse( callServer: void | CallServerCallback, encodeFormAction: void | EncodeFormActionCallback, nonce: void | string, + temporaryReferences: void | TemporaryReferenceSet, ): Response { const chunks: Map> = new Map(); const response: Response = { @@ -853,6 +871,7 @@ export function createResponse( _rowTag: 0, _rowLength: 0, _buffer: [], + _tempRefs: temporaryReferences, }; // Don't inline this call because it causes closure to outline the call above. response._fromJSON = createFromJSONCallback(response); diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 6bae3d1a14de5..5f4bd00deb305 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -14,6 +14,9 @@ import type { RejectedThenable, ReactCustomFormAction, } from 'shared/ReactTypes'; +import type {LazyComponent} from 'react/src/ReactLazy'; +import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; + import {enableRenderableContext} from 'shared/ReactFeatureFlags'; import { @@ -30,6 +33,8 @@ import { objectName, } from 'shared/ReactSerializationErrors'; +import {writeTemporaryReference} from './ReactFlightTemporaryReferences'; + import isArray from 'shared/isArray'; import getPrototypeOf from 'shared/getPrototypeOf'; @@ -84,9 +89,9 @@ export type ReactServerValue = type ReactServerObject = {+[key: string]: ReactServerValue}; -// function serializeByValueID(id: number): string { -// return '$' + id.toString(16); -// } +function serializeByValueID(id: number): string { + return '$' + id.toString(16); +} function serializePromiseID(id: number): string { return '$@' + id.toString(16); @@ -96,6 +101,10 @@ function serializeServerReferenceID(id: number): string { return '$F' + id.toString(16); } +function serializeTemporaryReferenceID(id: number): string { + return '$T' + id.toString(16); +} + function serializeSymbolReference(name: string): string { return '$S' + name; } @@ -158,6 +167,7 @@ function escapeStringValue(value: string): string { export function processReply( root: ReactServerValue, formFieldPrefix: string, + temporaryReferences: void | TemporaryReferenceSet, resolve: (string | FormData) => void, reject: (error: mixed) => void, ): void { @@ -206,6 +216,81 @@ export function processReply( } if (typeof value === 'object') { + switch ((value: any).$$typeof) { + case REACT_ELEMENT_TYPE: { + if (temporaryReferences === undefined) { + throw new Error( + 'React Element cannot be passed to Server Functions from the Client without a ' + + 'temporary reference set. Pass a TemporaryReferenceSet to the options.' + + (__DEV__ ? describeObjectForErrorMessage(parent, key) : ''), + ); + } + return serializeTemporaryReferenceID( + writeTemporaryReference(temporaryReferences, value), + ); + } + case REACT_LAZY_TYPE: { + // Resolve lazy as if it wasn't here. In the future this will be encoded as a Promise. + const lazy: LazyComponent = (value: any); + const payload = lazy._payload; + const init = lazy._init; + if (formData === null) { + // Upgrade to use FormData to allow us to stream this value. + formData = new FormData(); + } + pendingParts++; + try { + const resolvedModel = init(payload); + // We always outline this as a separate part even though we could inline it + // because it ensures a more deterministic encoding. + const lazyId = nextPartId++; + const partJSON = JSON.stringify(resolvedModel, 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(formFieldPrefix + lazyId, partJSON); + return serializeByValueID(lazyId); + } catch (x) { + if ( + typeof x === 'object' && + x !== null && + typeof x.then === 'function' + ) { + // Suspended + pendingParts++; + const lazyId = nextPartId++; + const thenable: Thenable = (x: any); + const retry = function () { + // While the first promise resolved, its value isn't necessarily what we'll + // resolve into because we might suspend again. + try { + const partJSON = JSON.stringify(value, 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(formFieldPrefix + lazyId, partJSON); + pendingParts--; + if (pendingParts === 0) { + resolve(data); + } + } catch (reason) { + reject(reason); + } + }; + thenable.then(retry, retry); + return serializeByValueID(lazyId); + } else { + // In the future we could consider serializing this as an error + // that throws on the server instead. + reject(x); + return null; + } + } finally { + pendingParts--; + } + } + } + // $FlowFixMe[method-unbinding] if (typeof value.then === 'function') { // We assume that any object with a .then property is a "Thenable" type, @@ -219,14 +304,18 @@ export function processReply( 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(formFieldPrefix + promiseId, partJSON); - pendingParts--; - if (pendingParts === 0) { - resolve(data); + try { + 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(formFieldPrefix + promiseId, partJSON); + pendingParts--; + if (pendingParts === 0) { + resolve(data); + } + } catch (reason) { + reject(reason); } }, reason => { @@ -288,23 +377,19 @@ export function processReply( proto !== ObjectPrototype && (proto === null || getPrototypeOf(proto) !== null) ) { - throw new Error( - 'Only plain objects, and a few built-ins, can be passed to Server Actions. ' + - 'Classes or null prototypes are not supported.', + if (temporaryReferences === undefined) { + throw new Error( + 'Only plain objects, and a few built-ins, can be passed to Server Actions. ' + + 'Classes or null prototypes are not supported.', + ); + } + // We can serialize class instances as temporary references. + return serializeTemporaryReferenceID( + writeTemporaryReference(temporaryReferences, value), ); } if (__DEV__) { - 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 ( + if ( (value: any).$$typeof === (enableRenderableContext ? REACT_CONTEXT_TYPE : REACT_PROVIDER_TYPE) ) { @@ -382,9 +467,14 @@ export function processReply( formData.set(formFieldPrefix + 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 (temporaryReferences === undefined) { + throw new Error( + 'Client Functions cannot be passed directly to Server Functions. ' + + 'Only Functions passed from the Server can be passed back again.', + ); + } + return serializeTemporaryReferenceID( + writeTemporaryReference(temporaryReferences, value), ); } @@ -443,6 +533,7 @@ function encodeFormData(reference: any): Thenable { processReply( reference, '', + undefined, // TODO: This means React Elements can't be used as state in progressive enhancement. (body: string | FormData) => { if (typeof body === 'string') { const data = new FormData(); diff --git a/packages/react-client/src/ReactFlightTemporaryReferences.js b/packages/react-client/src/ReactFlightTemporaryReferences.js new file mode 100644 index 0000000000000..2f7453f0f9fa5 --- /dev/null +++ b/packages/react-client/src/ReactFlightTemporaryReferences.js @@ -0,0 +1,41 @@ +/** + * 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 + */ + +interface Reference {} + +export opaque type TemporaryReferenceSet = Array; + +export function createTemporaryReferenceSet(): TemporaryReferenceSet { + return []; +} + +export function writeTemporaryReference( + set: TemporaryReferenceSet, + object: Reference, +): number { + // We always create a new entry regardless if we've already written the same + // object. This ensures that we always generate a deterministic encoding of + // each slot in the reply for cacheability. + const newId = set.length; + set.push(object); + return newId; +} + +export function readTemporaryReference( + set: TemporaryReferenceSet, + id: number, +): Reference { + if (id < 0 || id >= set.length) { + throw new Error( + "The RSC response contained a reference that doesn't exist in the temporary reference set. " + + 'Always pass the matching set that was used to create the reply when parsing its response.', + ); + } + return set[id]; +} diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js index eabe0546913f0..dbc4430ec1d8f 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js @@ -26,11 +26,18 @@ import { createServerReference, } from 'react-client/src/ReactFlightReplyClient'; +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export type {TemporaryReferenceSet}; + type CallServerCallback = (string, args: A) => Promise; export type Options = { moduleBaseURL?: string, callServer?: CallServerCallback, + temporaryReferences?: TemporaryReferenceSet, }; function createResponseFromOptions(options: void | Options) { @@ -40,6 +47,9 @@ function createResponseFromOptions(options: void | Options) { options && options.callServer ? options.callServer : undefined, undefined, // encodeFormAction undefined, // nonce + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, ); } @@ -97,11 +107,20 @@ function createFromFetch( function encodeReply( value: ReactServerValue, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Promise< string | URLSearchParams | FormData, > /* We don't use URLSearchParams yet but maybe */ { return new Promise((resolve, reject) => { - processReply(value, '', resolve, reject); + processReply( + value, + '', + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, + resolve, + reject, + ); }); } diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js index 71bbcbd577397..97a9ec0a08476 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js @@ -60,6 +60,7 @@ function createFromNodeStream( noServerCall, options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, + undefined, // TODO: If encodeReply is supported, this should support temporaryReferences ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js index 108040653114d..2f5a554b5ac4a 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js @@ -26,10 +26,17 @@ import { createServerReference, } from 'react-client/src/ReactFlightReplyClient'; +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export type {TemporaryReferenceSet}; + type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, + temporaryReferences?: TemporaryReferenceSet, }; function createResponseFromOptions(options: void | Options) { @@ -39,6 +46,9 @@ function createResponseFromOptions(options: void | Options) { options && options.callServer ? options.callServer : undefined, undefined, // encodeFormAction undefined, // nonce + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, ); } @@ -96,11 +106,20 @@ function createFromFetch( function encodeReply( value: ReactServerValue, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Promise< string | URLSearchParams | FormData, > /* We don't use URLSearchParams yet but maybe */ { return new Promise((resolve, reject) => { - processReply(value, '', resolve, reject); + processReply( + value, + '', + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, + resolve, + reject, + ); }); } diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js index 5e379d3baa2f5..57ed079c5af2a 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js @@ -36,6 +36,12 @@ import { createServerReference as createServerReferenceImpl, } from 'react-client/src/ReactFlightReplyClient'; +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export type {TemporaryReferenceSet}; + function noServerCall() { throw new Error( 'Server Functions cannot be called during initial render. ' + @@ -60,6 +66,7 @@ export type Options = { ssrManifest: SSRManifest, nonce?: string, encodeFormAction?: EncodeFormActionCallback, + temporaryReferences?: TemporaryReferenceSet, }; function createResponseFromOptions(options: Options) { @@ -69,6 +76,9 @@ function createResponseFromOptions(options: Options) { noServerCall, options.encodeFormAction, typeof options.nonce === 'string' ? options.nonce : undefined, + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, ); } @@ -126,11 +136,20 @@ function createFromFetch( function encodeReply( value: ReactServerValue, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Promise< string | URLSearchParams | FormData, > /* We don't use URLSearchParams yet but maybe */ { return new Promise((resolve, reject) => { - processReply(value, '', resolve, reject); + processReply( + value, + '', + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, + resolve, + reject, + ); }); } diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js index 180f595dd9a08..b34958424c1cb 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js @@ -69,6 +69,7 @@ function createFromNodeStream( noServerCall, options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, + undefined, // TODO: If encodeReply is supported, this should support temporaryReferences ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js index 108040653114d..2f5a554b5ac4a 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js @@ -26,10 +26,17 @@ import { createServerReference, } from 'react-client/src/ReactFlightReplyClient'; +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export type {TemporaryReferenceSet}; + type CallServerCallback = (string, args: A) => Promise; export type Options = { callServer?: CallServerCallback, + temporaryReferences?: TemporaryReferenceSet, }; function createResponseFromOptions(options: void | Options) { @@ -39,6 +46,9 @@ function createResponseFromOptions(options: void | Options) { options && options.callServer ? options.callServer : undefined, undefined, // encodeFormAction undefined, // nonce + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, ); } @@ -96,11 +106,20 @@ function createFromFetch( function encodeReply( value: ReactServerValue, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Promise< string | URLSearchParams | FormData, > /* We don't use URLSearchParams yet but maybe */ { return new Promise((resolve, reject) => { - processReply(value, '', resolve, reject); + processReply( + value, + '', + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, + resolve, + reject, + ); }); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js index 5e379d3baa2f5..57ed079c5af2a 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -36,6 +36,12 @@ import { createServerReference as createServerReferenceImpl, } from 'react-client/src/ReactFlightReplyClient'; +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences'; + +export type {TemporaryReferenceSet}; + function noServerCall() { throw new Error( 'Server Functions cannot be called during initial render. ' + @@ -60,6 +66,7 @@ export type Options = { ssrManifest: SSRManifest, nonce?: string, encodeFormAction?: EncodeFormActionCallback, + temporaryReferences?: TemporaryReferenceSet, }; function createResponseFromOptions(options: Options) { @@ -69,6 +76,9 @@ function createResponseFromOptions(options: Options) { noServerCall, options.encodeFormAction, typeof options.nonce === 'string' ? options.nonce : undefined, + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, ); } @@ -126,11 +136,20 @@ function createFromFetch( function encodeReply( value: ReactServerValue, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Promise< string | URLSearchParams | FormData, > /* We don't use URLSearchParams yet but maybe */ { return new Promise((resolve, reject) => { - processReply(value, '', resolve, reject); + processReply( + value, + '', + options && options.temporaryReferences + ? options.temporaryReferences + : undefined, + resolve, + reject, + ); }); } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js index 180f595dd9a08..b34958424c1cb 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js @@ -69,6 +69,7 @@ function createFromNodeStream( noServerCall, options ? options.encodeFormAction : undefined, options && typeof options.nonce === 'string' ? options.nonce : undefined, + undefined, // TODO: If encodeReply is supported, this should support temporaryReferences ); stream.on('data', chunk => { processBinaryChunk(response, chunk); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js index 1162d1d0fe738..938937dba2afb 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -17,6 +17,7 @@ global.TextDecoder = require('util').TextDecoder; // let serverExports; let webpackServerMap; +let React; let ReactServerDOMServer; let ReactServerDOMClient; @@ -31,6 +32,7 @@ describe('ReactFlightDOMReply', () => { const WebpackMock = require('./utils/WebpackMock'); // serverExports = WebpackMock.serverExports; webpackServerMap = WebpackMock.webpackServerMap; + React = require('react'); ReactServerDOMServer = require('react-server-dom-webpack/server.browser'); jest.resetModules(); ReactServerDOMClient = require('react-server-dom-webpack/client'); @@ -241,4 +243,107 @@ describe('ReactFlightDOMReply', () => { } expect(error.message).toBe('Connection closed.'); }); + + it('resolves a promise and includes its value', async () => { + let resolve; + const promise = new Promise(r => (resolve = r)); + const bodyPromise = ReactServerDOMClient.encodeReply({promise: promise}); + resolve('Hi'); + const result = await ReactServerDOMServer.decodeReply(await bodyPromise); + expect(await result.promise).toBe('Hi'); + }); + + it('resolves a React.lazy and includes its value', async () => { + let resolve; + const lazy = React.lazy(() => new Promise(r => (resolve = r))); + const bodyPromise = ReactServerDOMClient.encodeReply({lazy: lazy}); + resolve({default: 'Hi'}); + const result = await ReactServerDOMServer.decodeReply(await bodyPromise); + expect(result.lazy).toBe('Hi'); + }); + + it('resolves a proxy throwing a promise inside React.lazy', async () => { + let resolve1; + let resolve2; + const lazy = React.lazy(() => new Promise(r => (resolve1 = r))); + const promise = new Promise(r => (resolve2 = r)); + const bodyPromise1 = ReactServerDOMClient.encodeReply({lazy: lazy}); + const target = {value: ''}; + let loaded = false; + const proxy = new Proxy(target, { + get(targetObj, prop, receiver) { + if (prop === 'value') { + if (!loaded) { + throw promise; + } + return 'Hello'; + } + return targetObj[prop]; + }, + }); + await resolve1({default: proxy}); + + // Encode it again so that we have an already initialized lazy + // This is now already resolved but the proxy inside isn't. This ensures + // we trigger the retry code path. + const bodyPromise2 = ReactServerDOMClient.encodeReply({lazy: lazy}); + + // Then resolve the inner thrown promise. + loaded = true; + await resolve2('Hello'); + + const result1 = await ReactServerDOMServer.decodeReply(await bodyPromise1); + expect(await result1.lazy.value).toBe('Hello'); + const result2 = await ReactServerDOMServer.decodeReply(await bodyPromise2); + expect(await result2.lazy.value).toBe('Hello'); + }); + + it('errors when called with JSX by default', async () => { + let error; + try { + await ReactServerDOMClient.encodeReply(
); + } catch (x) { + error = x; + } + expect(error).toEqual( + expect.objectContaining({ + message: __DEV__ + ? expect.stringContaining( + 'React Element cannot be passed to Server Functions from the Client without a temporary reference set.', + ) + : expect.stringContaining(''), + }), + ); + }); + + it('can pass JSX through a round trip using temporary references', async () => { + function Component() { + return
; + } + + const children = ; + + const temporaryReferences = + ReactServerDOMClient.createTemporaryReferenceSet(); + const body = await ReactServerDOMClient.encodeReply( + {children}, + { + temporaryReferences, + }, + ); + const serverPayload = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + ); + const stream = ReactServerDOMServer.renderToReadableStream(serverPayload); + const response = await ReactServerDOMClient.createFromReadableStream( + stream, + { + temporaryReferences, + }, + ); + + // This should've been the same reference that we already saw. + expect(response.children).toBe(children); + }); }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 291da7870760e..cf308ba5204ea 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -24,6 +24,8 @@ import { requireModule, } from 'react-client/src/ReactFlightClientConfig'; +import {createTemporaryReference} from './ReactFlightServerTemporaryReferences'; + export type JSONValue = | number | null @@ -413,6 +415,10 @@ function parseModelString( key, ); } + case 'T': { + // Temporary Reference + return createTemporaryReference(value.slice(2)); + } case 'Q': { // Map const id = parseInt(value.slice(2), 16); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 02533c7ca305b..d253db44c4a15 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -59,6 +59,7 @@ import type { ReactAsyncInfo, } from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; +import type {TemporaryReference} from './ReactFlightServerTemporaryReferences'; import { resolveClientReferenceMetadata, @@ -73,6 +74,11 @@ import { initAsyncDebugInfo, } from './ReactFlightServerConfig'; +import { + isTemporaryReference, + resolveTemporaryReferenceID, +} from './ReactFlightServerTemporaryReferences'; + import { HooksDispatcher, prepareToUseHooksForRequest, @@ -788,7 +794,7 @@ function renderElement( } } if (typeof type === 'function') { - if (isClientReference(type)) { + if (isClientReference(type) || isTemporaryReference(type)) { // This is a reference to a Client Component. return renderClientElement(task, type, key, props); } @@ -949,6 +955,10 @@ function serializeServerReferenceID(id: number): string { return '$F' + id.toString(16); } +function serializeTemporaryReferenceID(id: string): string { + return '$T' + id; +} + function serializeSymbolReference(name: string): string { return '$S' + name; } @@ -1085,6 +1095,14 @@ function serializeServerReference( return serializeServerReferenceID(metadataId); } +function serializeTemporaryReference( + request: Request, + temporaryReference: TemporaryReference, +): string { + const id = resolveTemporaryReferenceID(temporaryReference); + return serializeTemporaryReferenceID(id); +} + function serializeLargeTextString(request: Request, text: string): string { request.pendingChunks += 2; const textId = request.nextChunkId++; @@ -1635,6 +1653,9 @@ function renderModelDestructive( if (isServerReference(value)) { return serializeServerReference(request, (value: any)); } + if (isTemporaryReference(value)) { + return serializeTemporaryReference(request, (value: any)); + } if (enableTaint) { const tainted = TaintRegistryObjects.get(value); @@ -2103,6 +2124,9 @@ function renderConsoleValue( (value: any), ); } + if (isTemporaryReference(value)) { + return serializeTemporaryReference(request, (value: any)); + } // Serialize the body of the function as an eval so it can be printed. // $FlowFixMe[method-unbinding] diff --git a/packages/react-server/src/ReactFlightServerTemporaryReferences.js b/packages/react-server/src/ReactFlightServerTemporaryReferences.js new file mode 100644 index 0000000000000..c133f9030431d --- /dev/null +++ b/packages/react-server/src/ReactFlightServerTemporaryReferences.js @@ -0,0 +1,99 @@ +/** + * 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 + */ + +const TEMPORARY_REFERENCE_TAG = Symbol.for('react.temporary.reference'); + +// eslint-disable-next-line no-unused-vars +export opaque type TemporaryReference = { + $$typeof: symbol, + $$id: string, +}; + +export function isTemporaryReference(reference: Object): boolean { + return reference.$$typeof === TEMPORARY_REFERENCE_TAG; +} + +export function resolveTemporaryReferenceID( + temporaryReference: TemporaryReference, +): string { + return temporaryReference.$$id; +} + +const proxyHandlers = { + get: function ( + target: Function, + name: string | symbol, + receiver: Proxy, + ) { + switch (name) { + // These names are read by the Flight runtime if you end up using the exports object. + case '$$typeof': + // These names are a little too common. We should probably have a way to + // have the Flight runtime extract the inner target instead. + return target.$$typeof; + case '$$id': + return target.$$id; + case '$$async': + return target.$$async; + case 'name': + return undefined; + case 'displayName': + return undefined; + // We need to special case this because createElement reads it if we pass this + // reference. + case 'defaultProps': + return undefined; + // Avoid this attempting to be serialized. + case 'toJSON': + return undefined; + case Symbol.toPrimitive: + // $FlowFixMe[prop-missing] + return Object.prototype[Symbol.toPrimitive]; + case Symbol.toStringTag: + // $FlowFixMe[prop-missing] + return Object.prototype[Symbol.toStringTag]; + case 'Provider': + throw new Error( + `Cannot render a Client Context Provider on the Server. ` + + `Instead, you can export a Client Component wrapper ` + + `that itself renders a Client Context Provider.`, + ); + } + throw new Error( + // eslint-disable-next-line react-internal/safe-string-coercion + `Cannot access ${String(name)} on the server. ` + + 'You cannot dot into a temporary client reference from a server component. ' + + 'You can only pass the value through to the client.', + ); + }, + set: function () { + throw new Error( + 'Cannot assign to a temporary client reference from a server module.', + ); + }, +}; + +export function createTemporaryReference(id: string): TemporaryReference { + const reference: TemporaryReference = Object.defineProperties( + (function () { + throw new Error( + // eslint-disable-next-line react-internal/safe-string-coercion + `Attempted to call a temporary Client Reference from the server but it is on the client. ` + + `It's not possible to invoke a client function from the server, it can ` + + `only be rendered as a Component or passed to props of a Client Component.`, + ); + }: any), + { + $$typeof: {value: TEMPORARY_REFERENCE_TAG}, + $$id: {value: id}, + }, + ); + + return new Proxy(reference, proxyHandlers); +} diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 1614f95b86b42..2d7f833bff10d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -494,5 +494,12 @@ "506": "Functions are not valid as a child of Client Components. This may happen if you return %s instead of <%s /> from render. Or maybe you meant to call this function rather than return it.%s", "507": "Expected the last optional `callback` argument to be a function. Instead received: %s.", "508": "The first argument must be a React class instance. Instead received: %s.", - "509": "ReactDOM: Unsupported Legacy Mode API." + "509": "ReactDOM: Unsupported Legacy Mode API.", + "510": "React Element cannot be passed to Server Functions from the Client without a temporary reference set. Pass a TemporaryReferenceSet to the options.%s", + "511": "Missing a temporary reference set but the RSC response returned a temporary reference. Pass a temporaryReference option with the set that was used with the reply.", + "512": "The RSC response contained a reference that doesn't exist in the temporary reference set. Always pass the matching set that was used to create the reply when parsing its response.", + "513": "Cannot render a Client Context Provider on the Server. Instead, you can export a Client Component wrapper that itself renders a Client Context Provider.", + "514": "Cannot access %s on the server. You cannot dot into a temporary client reference from a server component. You can only pass the value through to the client.", + "515": "Cannot assign to a temporary client reference from a server module.", + "516": "Attempted to call a temporary Client Reference from the server but it is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component." }