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." }