diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 435ee06aac44b..38d3b0baf69ea 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -55,7 +55,10 @@ import { printToConsole, } from './ReactFlightClientConfig'; -import {registerServerReference} from './ReactFlightReplyClient'; +import { + registerServerReference, + registerClientReference, +} from './ReactFlightReplyClient'; import {readTemporaryReference} from './ReactFlightTemporaryReferences'; @@ -128,7 +131,7 @@ type ResolvedModelChunk = { type ResolvedModuleChunk = { status: 'resolved_module', value: ClientReference, - reason: null, + reason: ClientReferenceMetadata, _response: Response, _debugInfo?: null | ReactDebugInfo, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, @@ -334,9 +337,10 @@ function createResolvedModelChunk( function createResolvedModuleChunk( response: Response, value: ClientReference, + metadata: ClientReferenceMetadata, ): ResolvedModuleChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk(RESOLVED_MODULE, value, null, response); + return new Chunk(RESOLVED_MODULE, value, metadata, response); } function createInitializedTextChunk( @@ -381,6 +385,7 @@ function resolveModelChunk( function resolveModuleChunk( chunk: SomeChunk, value: ClientReference, + metadata: ClientReferenceMetadata, ): void { if (chunk.status !== PENDING && chunk.status !== BLOCKED) { // We already resolved. We didn't expect to see this. @@ -391,6 +396,7 @@ function resolveModuleChunk( const resolvedChunk: ResolvedModuleChunk = (chunk: any); resolvedChunk.status = RESOLVED_MODULE; resolvedChunk.value = value; + resolvedChunk.reason = metadata; if (resolveListeners !== null) { initializeModuleChunk(resolvedChunk); wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners); @@ -450,9 +456,11 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { function initializeModuleChunk(chunk: ResolvedModuleChunk): void { try { const value: T = requireModule(chunk.value); + registerClientReference(value, chunk.reason); const initializedChunk: InitializedChunk = (chunk: any); initializedChunk.status = INITIALIZED; initializedChunk.value = value; + initializedChunk.reason = null; } catch (error) { const erroredChunk: ErroredChunk = (chunk: any); erroredChunk.status = ERRORED; @@ -949,16 +957,28 @@ function resolveModule( blockedChunk.status = BLOCKED; } promise.then( - () => resolveModuleChunk(blockedChunk, clientReference), + () => + resolveModuleChunk( + blockedChunk, + clientReference, + clientReferenceMetadata, + ), error => triggerErrorOnChunk(blockedChunk, error), ); } else { if (!chunk) { - chunks.set(id, createResolvedModuleChunk(response, clientReference)); + chunks.set( + id, + createResolvedModuleChunk( + response, + clientReference, + clientReferenceMetadata, + ), + ); } else { // This can't actually happen because we don't have any forward // references to modules. - resolveModuleChunk(chunk, clientReference); + resolveModuleChunk(chunk, clientReference, clientReferenceMetadata); } } } diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index 5f4bd00deb305..4427e144e1b95 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -16,6 +16,7 @@ import type { } from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; +import type {ClientReferenceMetadata} from './ReactFlightClientConfig'; import {enableRenderableContext} from 'shared/ReactFeatureFlags'; @@ -59,6 +60,9 @@ export type EncodeFormActionCallback = ( args: Promise, ) => ReactCustomFormAction; +const knownClientReferences: WeakMap = + new WeakMap(); + export type ServerReferenceId = any; const knownServerReferences: WeakMap< @@ -97,6 +101,10 @@ function serializePromiseID(id: number): string { return '$@' + id.toString(16); } +function serializeClientReferenceID(id: number): string { + return '$C' + id.toString(16); +} + function serializeServerReferenceID(id: number): string { return '$F' + id.toString(16); } @@ -219,11 +227,18 @@ export function processReply( 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) : ''), - ); + const element: React$Element = (value: any); + // Serialize as a plain object with a symbol property + // TODO: Consider if we should use a special encoding for this or restore a proper + // element object on the server. E.g. we probably need the _store stuff in case it + // is passed as a child. For now we assume it'll just be passed back to Flight. + return { + $$typeof: REACT_ELEMENT_TYPE, + type: element.type, + key: element.key, + ref: element.ref, + props: element.props, + }; } return serializeTemporaryReferenceID( writeTemporaryReference(temporaryReferences, value), @@ -454,9 +469,12 @@ export function processReply( } if (typeof value === 'function') { - const metaData = knownServerReferences.get(value); - if (metaData !== undefined) { - const metaDataJSON = JSON.stringify(metaData, resolveToJSON); + const serverMetaData = knownServerReferences.get(value); + if (serverMetaData !== undefined) { + const metaDataJSON: string = JSON.stringify( + serverMetaData, + resolveToJSON, + ); if (formData === null) { // Upgrade to use FormData to allow us to stream this value. formData = new FormData(); @@ -468,6 +486,25 @@ export function processReply( return serializeServerReferenceID(refId); } if (temporaryReferences === undefined) { + const clientMetadata = knownClientReferences.get(value); + if (clientMetadata !== undefined) { + // If this function once was loaded by a server response from this same client + // we can pass it back given the same metadata that was sent to us. So that the + // server can return it to us again. + // $FlowFixMe[incompatible-type]: The meta data should be known to never yield undefined. + const metaDataJSON: string = JSON.stringify( + clientMetadata, + resolveToJSON, + ); + if (formData === null) { + // Upgrade to use FormData to allow us to stream this value. + formData = new FormData(); + } + const refId = nextPartId++; + // eslint-disable-next-line react-internal/safe-string-coercion + formData.set(formFieldPrefix + refId, metaDataJSON); + return serializeClientReferenceID(refId); + } throw new Error( 'Client Functions cannot be passed directly to Server Functions. ' + 'Only Functions passed from the Server can be passed back again.', @@ -686,6 +723,18 @@ function isSignatureEqual( } } +export function registerClientReference( + reference: T, + metadata: ClientReferenceMetadata, +): void { + if (typeof reference === 'function') { + // Currently we only track functions, like Components, because objects might be + // serializable and if they are the receiving side might expect to be able to + // use them. Functions on the other hand would error anywayy. + knownClientReferences.set(reference, metadata); + } +} + export function registerServerReference( proxy: any, reference: {id: ServerReferenceId, bound: null | Thenable>}, diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js index 45a47a8c1405f..fbe7e0e603c4a 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.custom.js @@ -29,7 +29,7 @@ export opaque type ModuleLoading = mixed; export opaque type SSRModuleMap = mixed; export opaque type ServerManifest = mixed; export opaque type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = mixed; +export type ClientReferenceMetadata = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export const resolveClientReference = $$$config.resolveClientReference; export const resolveServerReference = $$$config.resolveServerReference; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 50713ae8e8e68..1a9de5405167b 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -16,7 +16,7 @@ export opaque type ModuleLoading = mixed; export opaque type SSRModuleMap = mixed; export opaque type ServerManifest = mixed; export opaque type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = mixed; +export type ClientReferenceMetadata = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export const resolveClientReference: any = null; export const resolveServerReference: any = null; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index 017dc33081d5f..51178f1bc53b2 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -16,7 +16,7 @@ export opaque type ModuleLoading = mixed; export opaque type SSRModuleMap = mixed; export opaque type ServerManifest = mixed; export opaque type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = mixed; +export type ClientReferenceMetadata = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export const resolveClientReference: any = null; export const resolveServerReference: any = null; diff --git a/packages/react-server-dom-esm/src/ReactFlightClientConfigBundlerESM.js b/packages/react-server-dom-esm/src/ReactFlightClientConfigBundlerESM.js index 5db99dc8fff05..1e90b9b759d78 100644 --- a/packages/react-server-dom-esm/src/ReactFlightClientConfigBundlerESM.js +++ b/packages/react-server-dom-esm/src/ReactFlightClientConfigBundlerESM.js @@ -22,7 +22,7 @@ export type ServerReferenceId = string; import {prepareDestinationForModuleImpl} from 'react-client/src/ReactFlightClientConfig'; -export opaque type ClientReferenceMetadata = [ +export type ClientReferenceMetadata = [ string, // module path string, // export name ]; diff --git a/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode.js b/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode.js index b2bf9b8ae0b53..c11cd049089ac 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode.js @@ -34,7 +34,7 @@ export type ServerManifest = void; export type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = ImportMetadata; +export type ClientReferenceMetadata = ImportMetadata; // eslint-disable-next-line no-unused-vars export opaque type ClientReference = { diff --git a/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack.js b/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack.js index b20ad69a408d6..e29654096fb12 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack.js @@ -43,10 +43,10 @@ export type ServerManifest = { export type ServerReferenceId = string; export opaque type ClientReferenceManifestEntry = ImportManifestEntry; -export opaque type ClientReferenceMetadata = ImportMetadata; +export type ClientReferenceMetadata = ImportMetadata; // eslint-disable-next-line no-unused-vars -export opaque type ClientReference = ClientReferenceMetadata; +export type ClientReference = ClientReferenceMetadata; // The reason this function needs to defined here in this file instead of just // being exported directly from the TurbopackDestination... file is because the diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode.js index b2bf9b8ae0b53..c11cd049089ac 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode.js @@ -34,7 +34,7 @@ export type ServerManifest = void; export type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = ImportMetadata; +export type ClientReferenceMetadata = ImportMetadata; // eslint-disable-next-line no-unused-vars export opaque type ClientReference = { diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js index 754578e8e7b7f..9c5327289b62d 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack.js @@ -43,7 +43,7 @@ export type ServerManifest = { export type ServerReferenceId = string; export opaque type ClientReferenceManifestEntry = ImportManifestEntry; -export opaque type ClientReferenceMetadata = ImportMetadata; +export type ClientReferenceMetadata = ImportMetadata; // eslint-disable-next-line no-unused-vars export opaque type ClientReference = ClientReferenceMetadata; 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 938937dba2afb..fef4f7e740bb8 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -15,6 +15,8 @@ global.ReadableStream = global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; +let clientExports; +let webpackMap; // let serverExports; let webpackServerMap; let React; @@ -30,6 +32,8 @@ describe('ReactFlightDOMReply', () => { require('react-server-dom-webpack/server.browser'), ); const WebpackMock = require('./utils/WebpackMock'); + clientExports = WebpackMock.clientExports; + webpackMap = WebpackMock.webpackMap; // serverExports = WebpackMock.serverExports; webpackServerMap = WebpackMock.webpackServerMap; React = require('react'); @@ -298,24 +302,6 @@ describe('ReactFlightDOMReply', () => { 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
; @@ -346,4 +332,55 @@ describe('ReactFlightDOMReply', () => { // This should've been the same reference that we already saw. expect(response.children).toBe(children); }); + + it('can pass a client reference send by the server back again', async () => { + function Component() { + return
; + } + + const ClientComponent = clientExports(Component); + + const stream1 = ReactServerDOMServer.renderToReadableStream( + {component: ClientComponent}, + webpackMap, + ); + const response1 = + await ReactServerDOMClient.createFromReadableStream(stream1); + expect(response1.component).toBe(Component); + + const body = await ReactServerDOMClient.encodeReply({ + replied: response1.component, + }); + const serverPayload = await ReactServerDOMServer.decodeReply(body); + + const stream2 = ReactServerDOMServer.renderToReadableStream(serverPayload); + const response2 = + await ReactServerDOMClient.createFromReadableStream(stream2); + + expect(response2.replied).toBe(Component); + }); + + it('can pass a client JSX sent by the server back again', async () => { + function Component() { + return
; + } + + const ClientComponent = clientExports(Component); + + const stream1 = ReactServerDOMServer.renderToReadableStream( + , + webpackMap, + ); + const response1 = + await ReactServerDOMClient.createFromReadableStream(stream1); + + const body = await ReactServerDOMClient.encodeReply(response1); + const serverPayload = await ReactServerDOMServer.decodeReply(body); + + const stream2 = ReactServerDOMServer.renderToReadableStream(serverPayload); + const response2 = + await ReactServerDOMClient.createFromReadableStream(stream2); + + expect(response2.type).toBe(Component); + }); }); diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index cf308ba5204ea..38aa20a31228d 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -16,6 +16,7 @@ import type { ServerReferenceId, ServerManifest, ClientReference as ServerReference, + ClientReferenceMetadata, } from 'react-client/src/ReactFlightClientConfig'; import { @@ -24,7 +25,10 @@ import { requireModule, } from 'react-client/src/ReactFlightClientConfig'; -import {createTemporaryReference} from './ReactFlightServerTemporaryReferences'; +import { + createTemporaryReference, + createReplyClientReference, +} from './ReactFlightServerTemporaryReferences'; export type JSONValue = | number @@ -419,6 +423,16 @@ function parseModelString( // Temporary Reference return createTemporaryReference(value.slice(2)); } + case 'C': { + // Client Reference + const id = parseInt(value.slice(2), 16); + // TODO: Just encode this in the reference inline instead of as a model. + const metaData: ClientReferenceMetadata = getOutlinedModel( + response, + id, + ); + return createReplyClientReference(metaData); + } 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 d253db44c4a15..a18ea3a331971 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -59,7 +59,10 @@ import type { ReactAsyncInfo, } from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; -import type {TemporaryReference} from './ReactFlightServerTemporaryReferences'; +import type { + TemporaryReference, + ReplyClientReference, +} from './ReactFlightServerTemporaryReferences'; import { resolveClientReferenceMetadata, @@ -77,6 +80,8 @@ import { import { isTemporaryReference, resolveTemporaryReferenceID, + isReplyClientReference, + resolveReplyClientReferenceMetadata, } from './ReactFlightServerTemporaryReferences'; import { @@ -794,7 +799,11 @@ function renderElement( } } if (typeof type === 'function') { - if (isClientReference(type) || isTemporaryReference(type)) { + if ( + isClientReference(type) || + isTemporaryReference(type) || + isReplyClientReference(type) + ) { // This is a reference to a Client Component. return renderClientElement(task, type, key, props); } @@ -1057,6 +1066,19 @@ function serializeClientReference( } } +function serializeReplyClientReference( + request: Request, + replyClientReference: ReplyClientReference, +): string { + // TODO: Consider deduping these. + const clientReferenceMetadata: ClientReferenceMetadata = + resolveReplyClientReferenceMetadata(replyClientReference); + request.pendingChunks++; + const importId = request.nextChunkId++; + emitImportChunk(request, importId, clientReferenceMetadata); + return serializeByValueID(importId); +} + function outlineModel(request: Request, value: ReactClientValue): number { const newTask = createTask( request, @@ -1656,6 +1678,9 @@ function renderModelDestructive( if (isTemporaryReference(value)) { return serializeTemporaryReference(request, (value: any)); } + if (isReplyClientReference(value)) { + return serializeReplyClientReference(request, (value: any)); + } if (enableTaint) { const tainted = TaintRegistryObjects.get(value); @@ -2127,6 +2152,9 @@ function renderConsoleValue( if (isTemporaryReference(value)) { return serializeTemporaryReference(request, (value: any)); } + if (isReplyClientReference(value)) { + return serializeReplyClientReference(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/ReactFlightServerConfigBundlerCustom.js b/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js index e11c154d05f32..884e95ef6675e 100644 --- a/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js +++ b/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js @@ -12,7 +12,7 @@ declare var $$$config: any; export opaque type ClientManifest = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export opaque type ServerReference = mixed; // eslint-disable-line no-unused-vars -export opaque type ClientReferenceMetadata: any = mixed; +export type ClientReferenceMetadata = mixed; export opaque type ServerReferenceId: any = mixed; export opaque type ClientReferenceKey: any = mixed; export const isClientReference = $$$config.isClientReference; diff --git a/packages/react-server/src/ReactFlightServerTemporaryReferences.js b/packages/react-server/src/ReactFlightServerTemporaryReferences.js index c133f9030431d..8cde1962a5256 100644 --- a/packages/react-server/src/ReactFlightServerTemporaryReferences.js +++ b/packages/react-server/src/ReactFlightServerTemporaryReferences.js @@ -7,6 +7,26 @@ * @flow */ +import type {ClientReferenceMetadata} from './ReactFlightServerConfig'; + +const REPLY_REFERENCE_TAG = Symbol.for('react.reply.reference'); + +// eslint-disable-next-line no-unused-vars +export opaque type ReplyClientReference = { + $$typeof: symbol, + $$metadata: ClientReferenceMetadata, +}; + +export function isReplyClientReference(reference: Object): boolean { + return reference.$$typeof === REPLY_REFERENCE_TAG; +} + +export function resolveReplyClientReferenceMetadata( + replyClientReference: ReplyClientReference, +): ClientReferenceMetadata { + return replyClientReference.$$metadata; +} + const TEMPORARY_REFERENCE_TAG = Symbol.for('react.temporary.reference'); // eslint-disable-next-line no-unused-vars @@ -39,8 +59,8 @@ const proxyHandlers = { return target.$$typeof; case '$$id': return target.$$id; - case '$$async': - return target.$$async; + case '$$metadata': + return target.$$metadata; case 'name': return undefined; case 'displayName': @@ -97,3 +117,23 @@ export function createTemporaryReference(id: string): TemporaryReference { return new Proxy(reference, proxyHandlers); } + +export function createReplyClientReference( + metadata: ClientReferenceMetadata, +): ReplyClientReference { + const reference: ReplyClientReference = 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: REPLY_REFERENCE_TAG}, + $$metadata: {value: metadata}, + }, + ); + return new Proxy(reference, proxyHandlers); +}