From e0dec33136a659859d3c58f24af870f034b68707 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 13 Mar 2024 15:55:35 -0400 Subject: [PATCH] Temporary references --- .../react-client/src/ReactFlightClient.js | 19 ++++ .../src/ReactFlightReplyClient.js | 44 +++++++-- .../src/ReactFlightTemporaryReferences.js | 41 ++++++++ .../src/ReactFlightDOMClientBrowser.js | 21 +++- .../src/ReactFlightDOMClientBrowser.js | 21 +++- .../src/ReactFlightDOMClientEdge.js | 2 +- .../src/ReactFlightDOMClientBrowser.js | 21 +++- .../src/ReactFlightDOMClientEdge.js | 2 +- .../src/__tests__/ReactFlightDOMReply-test.js | 37 ++++++- .../src/ReactFlightReplyServer.js | 6 ++ .../react-server/src/ReactFlightServer.js | 26 ++++- .../ReactFlightServerTemporaryReferences.js | 99 +++++++++++++++++++ scripts/error-codes/codes.json | 8 +- 13 files changed, 330 insertions(+), 17 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 dd4cd5527a100..61253ab19ef5f 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -15,6 +15,7 @@ import type { ReactCustomFormAction, } from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; +import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; import {enableRenderableContext} from 'shared/ReactFeatureFlags'; @@ -32,6 +33,8 @@ import { objectName, } from 'shared/ReactSerializationErrors'; +import {writeTemporaryReference} from './ReactFlightTemporaryReferences'; + import isArray from 'shared/isArray'; import getPrototypeOf from 'shared/getPrototypeOf'; @@ -98,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; } @@ -160,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 { @@ -210,9 +218,15 @@ export function processReply( if (typeof value === 'object') { switch ((value: any).$$typeof) { case REACT_ELEMENT_TYPE: { - throw new Error( - 'React Element cannot be passed to Server Functions from the Client.' + - (__DEV__ ? describeObjectForErrorMessage(parent, key) : ''), + 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: { @@ -366,9 +380,15 @@ 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__) { @@ -450,9 +470,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), ); } @@ -511,6 +536,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-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..8c8bbe0f2d715 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js @@ -130,7 +130,7 @@ function encodeReply( string | URLSearchParams | FormData, > /* We don't use URLSearchParams yet but maybe */ { return new Promise((resolve, reject) => { - processReply(value, '', resolve, reject); + processReply(value, '', undefined, resolve, reject); }); } 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..8c8bbe0f2d715 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js @@ -130,7 +130,7 @@ function encodeReply( string | URLSearchParams | FormData, > /* We don't use URLSearchParams yet but maybe */ { return new Promise((resolve, reject) => { - processReply(value, '', resolve, reject); + processReply(value, '', undefined, resolve, reject); }); } 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 f173b84755730..99b1e1f1e8e01 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -271,8 +271,43 @@ describe('ReactFlightDOMReply', () => { } expect(error).toEqual( expect.objectContaining({ - message: expect.stringContaining(''), + 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 6131ba981b0a6..2d7f833bff10d 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -495,5 +495,11 @@ "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.", - "510": "React Element cannot be passed to Server Functions from the Client.%s" + "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." }