diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 6b4a86181035..a31d4a39554c 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -915,7 +915,7 @@ function parseModelString( } case 'T': { // Temporary Reference - const id = parseInt(value.slice(2), 16); + const reference = '$' + value.slice(2); const temporaryReferences = response._tempRefs; if (temporaryReferences == null) { throw new Error( @@ -923,7 +923,7 @@ function parseModelString( 'Pass a temporaryReference option with the set that was used with the reply.', ); } - return readTemporaryReference(temporaryReferences, id); + return readTemporaryReference(temporaryReferences, reference); } case 'Q': { // Map diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index dbce2aa3a7b3..e5f7a559e7a4 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -109,8 +109,8 @@ function serializeServerReferenceID(id: number): string { return '$F' + id.toString(16); } -function serializeTemporaryReferenceID(id: number): string { - return '$T' + id.toString(16); +function serializeTemporaryReferenceMarker(): string { + return '$T'; } function serializeFormDataReference(id: number): string { @@ -405,15 +405,22 @@ 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) : ''), - ); + if (temporaryReferences !== undefined && key.indexOf(':') === -1) { + // TODO: If the property name contains a colon, we don't dedupe. Escape instead. + const parentReference = writtenObjects.get(parent); + if (parentReference !== undefined) { + // If the parent has a reference, we can refer to this object indirectly + // through the property name inside that parent. + const reference = parentReference + ':' + key; + // Store this object so that the server can refer to it later in responses. + writeTemporaryReference(temporaryReferences, reference, value); + return serializeTemporaryReferenceMarker(); + } } - return serializeTemporaryReferenceID( - writeTemporaryReference(temporaryReferences, value), + 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) : ''), ); } case REACT_LAZY_TYPE: { @@ -529,7 +536,12 @@ export function processReply( if (parentReference !== undefined) { // If the parent has a reference, we can refer to this object indirectly // through the property name inside that parent. - writtenObjects.set(value, parentReference + ':' + key); + const reference = parentReference + ':' + key; + writtenObjects.set(value, reference); + if (temporaryReferences !== undefined) { + // Store this object so that the server can refer to it later in responses. + writeTemporaryReference(temporaryReferences, reference, value); + } } } @@ -693,10 +705,9 @@ export function processReply( 'Classes or null prototypes are not supported.', ); } - // We can serialize class instances as temporary references. - return serializeTemporaryReferenceID( - writeTemporaryReference(temporaryReferences, value), - ); + // We will have written this object to the temporary reference set above + // so we can replace it with a marker to refer to this slot later. + return serializeTemporaryReferenceMarker(); } if (__DEV__) { if ( @@ -777,27 +788,41 @@ export function processReply( formData.set(formFieldPrefix + refId, metaDataJSON); return serializeServerReferenceID(refId); } - 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.', - ); + if (temporaryReferences !== undefined && key.indexOf(':') === -1) { + // TODO: If the property name contains a colon, we don't dedupe. Escape instead. + const parentReference = writtenObjects.get(parent); + if (parentReference !== undefined) { + // If the parent has a reference, we can refer to this object indirectly + // through the property name inside that parent. + const reference = parentReference + ':' + key; + // Store this object so that the server can refer to it later in responses. + writeTemporaryReference(temporaryReferences, reference, value); + return serializeTemporaryReferenceMarker(); + } } - return serializeTemporaryReferenceID( - writeTemporaryReference(temporaryReferences, value), + throw new Error( + 'Client Functions cannot be passed directly to Server Functions. ' + + 'Only Functions passed from the Server can be passed back again.', ); } if (typeof value === 'symbol') { - if (temporaryReferences === undefined) { - throw new Error( - 'Symbols cannot be passed to a Server Function without a ' + - 'temporary reference set. Pass a TemporaryReferenceSet to the options.' + - (__DEV__ ? describeObjectForErrorMessage(parent, key) : ''), - ); + if (temporaryReferences !== undefined && key.indexOf(':') === -1) { + // TODO: If the property name contains a colon, we don't dedupe. Escape instead. + const parentReference = writtenObjects.get(parent); + if (parentReference !== undefined) { + // If the parent has a reference, we can refer to this object indirectly + // through the property name inside that parent. + const reference = parentReference + ':' + key; + // Store this object so that the server can refer to it later in responses. + writeTemporaryReference(temporaryReferences, reference, value); + return serializeTemporaryReferenceMarker(); + } } - return serializeTemporaryReferenceID( - writeTemporaryReference(temporaryReferences, value), + throw new Error( + 'Symbols cannot be passed to a Server Function without a ' + + 'temporary reference set. Pass a TemporaryReferenceSet to the options.' + + (__DEV__ ? describeObjectForErrorMessage(parent, key) : ''), ); } @@ -812,7 +837,12 @@ export function processReply( function serializeModel(model: ReactServerValue, id: number): string { if (typeof model === 'object' && model !== null) { - writtenObjects.set(model, serializeByValueID(id)); + const reference = serializeByValueID(id); + writtenObjects.set(model, reference); + if (temporaryReferences !== undefined) { + // Store this object so that the server can refer to it later in responses. + writeTemporaryReference(temporaryReferences, reference, model); + } } modelRoot = model; // $FlowFixMe[incompatible-return] it's not going to be undefined because we'll encode it. diff --git a/packages/react-client/src/ReactFlightTemporaryReferences.js b/packages/react-client/src/ReactFlightTemporaryReferences.js index 7060562eb229..5ad92b3a1063 100644 --- a/packages/react-client/src/ReactFlightTemporaryReferences.js +++ b/packages/react-client/src/ReactFlightTemporaryReferences.js @@ -9,33 +9,23 @@ interface Reference {} -export opaque type TemporaryReferenceSet = Array; +export opaque type TemporaryReferenceSet = Map; export function createTemporaryReferenceSet(): TemporaryReferenceSet { - return []; + return new Map(); } export function writeTemporaryReference( set: TemporaryReferenceSet, + reference: string, object: Reference | symbol, -): 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; +): void { + set.set(reference, object); } export function readTemporaryReference( set: TemporaryReferenceSet, - id: number, + reference: string, ): T { - 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]: any); + return (set.get(reference): any); } diff --git a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js index df14751b7434..33546c36cb04 100644 --- a/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-esm/src/ReactFlightDOMServerNode.js @@ -47,15 +47,30 @@ export { registerClientReference, } from './ReactFlightESMReferences'; +import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export type {TemporaryReferenceSet}; + function createDrainHandler(destination: Destination, request: Request) { return () => startFlowing(request, destination); } +function createCancelHandler(request: Request, reason: string) { + return () => { + stopFlowing(request); + // eslint-disable-next-line react-internal/prod-error-codes + abort(request, new Error(reason)); + }; +} + type Options = { environmentName?: string, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, identifierPrefix?: string, + temporaryReferences?: TemporaryReferenceSet, }; type PipeableStream = { @@ -75,6 +90,7 @@ function renderToPipeableStream( options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, options ? options.environmentName : undefined, + options ? options.temporaryReferences : undefined, ); let hasStartedFlowing = false; startWork(request); @@ -88,10 +104,20 @@ function renderToPipeableStream( hasStartedFlowing = true; startFlowing(request, destination); destination.on('drain', createDrainHandler(destination, request)); + destination.on( + 'error', + createCancelHandler( + request, + 'The destination stream errored while writing data.', + ), + ); + destination.on( + 'close', + createCancelHandler(request, 'The destination stream closed early.'), + ); return destination; }, abort(reason: mixed) { - stopFlowing(request); abort(request, reason); }, }; @@ -155,13 +181,19 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, moduleBasePath: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Thenable { if (typeof body === 'string') { const form = new FormData(); form.append('0', body); body = form; } - const response = createResponse(moduleBasePath, '', body); + const response = createResponse( + moduleBasePath, + '', + options ? options.temporaryReferences : undefined, + body, + ); const root = getRoot(response); close(response); return root; diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js index da9094cc5212..e15ed19c074e 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js @@ -16,6 +16,7 @@ import { createRequest, startWork, startFlowing, + stopFlowing, abort, } from 'react-server/src/ReactFlightServer'; @@ -25,7 +26,10 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; -import {decodeAction} from 'react-server/src/ReactFlightActionServer'; +import { + decodeAction, + decodeFormState, +} from 'react-server/src/ReactFlightActionServer'; export { registerServerReference, @@ -33,10 +37,17 @@ export { createClientModuleProxy, } from './ReactFlightTurbopackReferences'; +import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export type {TemporaryReferenceSet}; + type Options = { environmentName?: string, identifierPrefix?: string, signal?: AbortSignal, + temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, }; @@ -53,6 +64,7 @@ function renderToReadableStream( options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, options ? options.environmentName : undefined, + options ? options.temporaryReferences : undefined, ); if (options && options.signal) { const signal = options.signal; @@ -75,7 +87,10 @@ function renderToReadableStream( pull: (controller): ?Promise => { startFlowing(request, controller); }, - cancel: (reason): ?Promise => {}, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, }, // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. {highWaterMark: 0}, @@ -86,16 +101,22 @@ function renderToReadableStream( function decodeReply( body: string | FormData, turbopackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Thenable { if (typeof body === 'string') { const form = new FormData(); form.append('0', body); body = form; } - const response = createResponse(turbopackMap, '', body); + const response = createResponse( + turbopackMap, + '', + options ? options.temporaryReferences : undefined, + body, + ); const root = getRoot(response); close(response); return root; } -export {renderToReadableStream, decodeReply, decodeAction}; +export {renderToReadableStream, decodeReply, decodeAction, decodeFormState}; diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js index da9094cc5212..e15ed19c074e 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerEdge.js @@ -16,6 +16,7 @@ import { createRequest, startWork, startFlowing, + stopFlowing, abort, } from 'react-server/src/ReactFlightServer'; @@ -25,7 +26,10 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; -import {decodeAction} from 'react-server/src/ReactFlightActionServer'; +import { + decodeAction, + decodeFormState, +} from 'react-server/src/ReactFlightActionServer'; export { registerServerReference, @@ -33,10 +37,17 @@ export { createClientModuleProxy, } from './ReactFlightTurbopackReferences'; +import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export type {TemporaryReferenceSet}; + type Options = { environmentName?: string, identifierPrefix?: string, signal?: AbortSignal, + temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, }; @@ -53,6 +64,7 @@ function renderToReadableStream( options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, options ? options.environmentName : undefined, + options ? options.temporaryReferences : undefined, ); if (options && options.signal) { const signal = options.signal; @@ -75,7 +87,10 @@ function renderToReadableStream( pull: (controller): ?Promise => { startFlowing(request, controller); }, - cancel: (reason): ?Promise => {}, + cancel: (reason): ?Promise => { + stopFlowing(request); + abort(request, reason); + }, }, // $FlowFixMe[prop-missing] size() methods are not allowed on byte streams. {highWaterMark: 0}, @@ -86,16 +101,22 @@ function renderToReadableStream( function decodeReply( body: string | FormData, turbopackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Thenable { if (typeof body === 'string') { const form = new FormData(); form.append('0', body); body = form; } - const response = createResponse(turbopackMap, '', body); + const response = createResponse( + turbopackMap, + '', + options ? options.temporaryReferences : undefined, + body, + ); const root = getRoot(response); close(response); return root; } -export {renderToReadableStream, decodeReply, decodeAction}; +export {renderToReadableStream, decodeReply, decodeAction, decodeFormState}; diff --git a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js index 5be4c4546544..0c44cb0c593f 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightDOMServerNode.js @@ -22,6 +22,7 @@ import { createRequest, startWork, startFlowing, + stopFlowing, abort, } from 'react-server/src/ReactFlightServer'; @@ -36,7 +37,10 @@ import { getRoot, } from 'react-server/src/ReactFlightReplyServer'; -import {decodeAction} from 'react-server/src/ReactFlightActionServer'; +import { + decodeAction, + decodeFormState, +} from 'react-server/src/ReactFlightActionServer'; export { registerServerReference, @@ -44,15 +48,30 @@ export { createClientModuleProxy, } from './ReactFlightTurbopackReferences'; +import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export type {TemporaryReferenceSet}; + function createDrainHandler(destination: Destination, request: Request) { return () => startFlowing(request, destination); } +function createCancelHandler(request: Request, reason: string) { + return () => { + stopFlowing(request); + // eslint-disable-next-line react-internal/prod-error-codes + abort(request, new Error(reason)); + }; +} + type Options = { environmentName?: string, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, identifierPrefix?: string, + temporaryReferences?: TemporaryReferenceSet, }; type PipeableStream = { @@ -72,6 +91,7 @@ function renderToPipeableStream( options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, options ? options.environmentName : undefined, + options ? options.temporaryReferences : undefined, ); let hasStartedFlowing = false; startWork(request); @@ -85,6 +105,17 @@ function renderToPipeableStream( hasStartedFlowing = true; startFlowing(request, destination); destination.on('drain', createDrainHandler(destination, request)); + destination.on( + 'error', + createCancelHandler( + request, + 'The destination stream errored while writing data.', + ), + ); + destination.on( + 'close', + createCancelHandler(request, 'The destination stream closed early.'), + ); return destination; }, abort(reason: mixed) { @@ -151,13 +182,19 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, turbopackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Thenable { if (typeof body === 'string') { const form = new FormData(); form.append('0', body); body = form; } - const response = createResponse(turbopackMap, '', body); + const response = createResponse( + turbopackMap, + '', + options ? options.temporaryReferences : undefined, + body, + ); const root = getRoot(response); close(response); return root; @@ -168,4 +205,5 @@ export { decodeReplyFromBusboy, decodeReply, decodeAction, + decodeFormState, }; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js index 2e389abd5a04..0a737903c291 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js @@ -37,10 +37,17 @@ export { createClientModuleProxy, } from './ReactFlightWebpackReferences'; +import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export type {TemporaryReferenceSet}; + type Options = { environmentName?: string, identifierPrefix?: string, signal?: AbortSignal, + temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, }; @@ -57,6 +64,7 @@ function renderToReadableStream( options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, options ? options.environmentName : undefined, + options ? options.temporaryReferences : undefined, ); if (options && options.signal) { const signal = options.signal; @@ -93,13 +101,19 @@ function renderToReadableStream( function decodeReply( body: string | FormData, webpackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Thenable { if (typeof body === 'string') { const form = new FormData(); form.append('0', body); body = form; } - const response = createResponse(webpackMap, '', body); + const response = createResponse( + webpackMap, + '', + options ? options.temporaryReferences : undefined, + body, + ); const root = getRoot(response); close(response); return root; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js index 2e389abd5a04..0a737903c291 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerEdge.js @@ -37,10 +37,17 @@ export { createClientModuleProxy, } from './ReactFlightWebpackReferences'; +import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export type {TemporaryReferenceSet}; + type Options = { environmentName?: string, identifierPrefix?: string, signal?: AbortSignal, + temporaryReferences?: TemporaryReferenceSet, onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, }; @@ -57,6 +64,7 @@ function renderToReadableStream( options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, options ? options.environmentName : undefined, + options ? options.temporaryReferences : undefined, ); if (options && options.signal) { const signal = options.signal; @@ -93,13 +101,19 @@ function renderToReadableStream( function decodeReply( body: string | FormData, webpackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Thenable { if (typeof body === 'string') { const form = new FormData(); form.append('0', body); body = form; } - const response = createResponse(webpackMap, '', body); + const response = createResponse( + webpackMap, + '', + options ? options.temporaryReferences : undefined, + body, + ); const root = getRoot(response); close(response); return root; diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js index 7546e1eac661..1f7f8190c5eb 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js @@ -48,6 +48,12 @@ export { createClientModuleProxy, } from './ReactFlightWebpackReferences'; +import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences'; + +export type {TemporaryReferenceSet}; + function createDrainHandler(destination: Destination, request: Request) { return () => startFlowing(request, destination); } @@ -65,6 +71,7 @@ type Options = { onError?: (error: mixed) => void, onPostpone?: (reason: string) => void, identifierPrefix?: string, + temporaryReferences?: TemporaryReferenceSet, }; type PipeableStream = { @@ -84,6 +91,7 @@ function renderToPipeableStream( options ? options.identifierPrefix : undefined, options ? options.onPostpone : undefined, options ? options.environmentName : undefined, + options ? options.temporaryReferences : undefined, ); let hasStartedFlowing = false; startWork(request); @@ -174,13 +182,19 @@ function decodeReplyFromBusboy( function decodeReply( body: string | FormData, webpackMap: ServerManifest, + options?: {temporaryReferences?: TemporaryReferenceSet}, ): Thenable { if (typeof body === 'string') { const form = new FormData(); form.append('0', body); body = form; } - const response = createResponse(webpackMap, '', body); + const response = createResponse( + webpackMap, + '', + options ? options.temporaryReferences : undefined, + body, + ); const root = getRoot(response); close(response); return root; 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 e4200e68a73b..bd92c88493fa 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js @@ -361,11 +361,21 @@ describe('ReactFlightDOMReply', () => { temporaryReferences, }, ); + + const temporaryReferencesServer = + ReactServerDOMServer.createTemporaryReferenceSet(); const serverPayload = await ReactServerDOMServer.decodeReply( body, webpackServerMap, + {temporaryReferences: temporaryReferencesServer}, + ); + const stream = ReactServerDOMServer.renderToReadableStream( + serverPayload, + null, + { + temporaryReferences: temporaryReferencesServer, + }, ); - const stream = ReactServerDOMServer.renderToReadableStream(serverPayload); const response = await ReactServerDOMClient.createFromReadableStream( stream, { @@ -377,6 +387,48 @@ describe('ReactFlightDOMReply', () => { expect(response.children).toBe(children); }); + it('can return the same object using temporary references', async () => { + const obj = { + this: {is: 'a large object'}, + with: {many: 'properties in it'}, + }; + + const root = {obj}; + + const temporaryReferences = + ReactServerDOMClient.createTemporaryReferenceSet(); + const body = await ReactServerDOMClient.encodeReply(root, { + temporaryReferences, + }); + + const temporaryReferencesServer = + ReactServerDOMServer.createTemporaryReferenceSet(); + const serverPayload = await ReactServerDOMServer.decodeReply( + body, + webpackServerMap, + {temporaryReferences: temporaryReferencesServer}, + ); + const stream = ReactServerDOMServer.renderToReadableStream( + { + root: serverPayload, + obj: serverPayload.obj, + }, + null, + {temporaryReferences: temporaryReferencesServer}, + ); + const response = await ReactServerDOMClient.createFromReadableStream( + stream, + { + temporaryReferences, + }, + ); + + // This should've been the same reference that we already saw because + // we returned it by reference. + expect(response.root).toBe(root); + expect(response.obj).toBe(obj); + }); + // @gate enableFlightReadableStream it('should supports streaming ReadableStream with objects', async () => { let controller1; diff --git a/packages/react-server/src/ReactFlightActionServer.js b/packages/react-server/src/ReactFlightActionServer.js index 54b5bef03726..1097d6f5db66 100644 --- a/packages/react-server/src/ReactFlightActionServer.js +++ b/packages/react-server/src/ReactFlightActionServer.js @@ -59,7 +59,12 @@ function decodeBoundActionMetaData( formFieldPrefix: string, ): {id: ServerReferenceId, bound: null | Promise>} { // The data for this reference is encoded in multiple fields under this prefix. - const actionResponse = createResponse(serverManifest, formFieldPrefix, body); + const actionResponse = createResponse( + serverManifest, + formFieldPrefix, + undefined, + body, + ); close(actionResponse); const refPromise = getRoot<{ id: ServerReferenceId, diff --git a/packages/react-server/src/ReactFlightReplyServer.js b/packages/react-server/src/ReactFlightReplyServer.js index 32a445267724..27ab7c080bda 100644 --- a/packages/react-server/src/ReactFlightReplyServer.js +++ b/packages/react-server/src/ReactFlightReplyServer.js @@ -18,19 +18,26 @@ import type { ClientReference as ServerReference, } from 'react-client/src/ReactFlightClientConfig'; +import type {TemporaryReferenceSet} from './ReactFlightServerTemporaryReferences'; + import { resolveServerReference, preloadModule, requireModule, } from 'react-client/src/ReactFlightClientConfig'; -import {createTemporaryReference} from './ReactFlightServerTemporaryReferences'; +import { + createTemporaryReference, + registerTemporaryReference, +} from './ReactFlightServerTemporaryReferences'; import { enableBinaryFlight, enableFlightReadableStream, } from 'shared/ReactFeatureFlags'; import {ASYNC_ITERATOR} from 'shared/ReactSymbols'; +import hasOwnProperty from 'shared/hasOwnProperty'; + interface FlightStreamController { enqueueModel(json: string): void; close(json: string): void; @@ -76,7 +83,7 @@ type CyclicChunk = { type ResolvedModelChunk = { status: 'resolved_model', value: string, - reason: null, + reason: number, _response: Response, then(resolve: (T) => mixed, reject?: (mixed) => mixed): void, }; @@ -166,7 +173,7 @@ export type Response = { _prefix: string, _formData: FormData, _chunks: Map>, - _fromJSON: (key: string, value: JSONValue) => any, + _temporaryReferences: void | TemporaryReferenceSet, }; export function getRoot(response: Response): Thenable { @@ -233,12 +240,17 @@ function triggerErrorOnChunk(chunk: SomeChunk, error: mixed): void { function createResolvedModelChunk( response: Response, value: string, + id: number, ): ResolvedModelChunk { // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk(RESOLVED_MODEL, value, null, response); + return new Chunk(RESOLVED_MODEL, value, id, response); } -function resolveModelChunk(chunk: SomeChunk, value: string): void { +function resolveModelChunk( + chunk: SomeChunk, + value: string, + id: number, +): void { if (chunk.status !== PENDING) { if (enableFlightReadableStream) { // If we get more data to an already resolved ID, we assume that it's @@ -258,6 +270,7 @@ function resolveModelChunk(chunk: SomeChunk, value: string): void { const resolvedChunk: ResolvedModelChunk = (chunk: any); resolvedChunk.status = RESOLVED_MODEL; resolvedChunk.value = value; + resolvedChunk.reason = id; if (resolveListeners !== null) { // This is unfortunate that we're reading this eagerly if // we already have listeners attached since they might no @@ -290,7 +303,7 @@ function createResolvedIteratorResultChunk( const iteratorResultJSON = (done ? '{"done":true,"value":' : '{"done":false,"value":') + value + '}'; // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors - return new Chunk(RESOLVED_MODEL, iteratorResultJSON, null, response); + return new Chunk(RESOLVED_MODEL, iteratorResultJSON, -1, response); } function resolveIteratorResultChunk( @@ -301,7 +314,7 @@ function resolveIteratorResultChunk( // To reuse code as much code as possible we add the wrapper element as part of the JSON. const iteratorResultJSON = (done ? '{"done":true,"value":' : '{"done":false,"value":') + value + '}'; - resolveModelChunk(chunk, iteratorResultJSON); + resolveModelChunk(chunk, iteratorResultJSON, -1); } function bindArgs(fn: any, args: any) { @@ -353,6 +366,64 @@ function loadServerReference( return (null: any); } +function reviveModel( + response: Response, + parentObj: any, + parentKey: string, + value: JSONValue, + reference: void | string, +): any { + if (typeof value === 'string') { + // We can't use .bind here because we need the "this" value. + return parseModelString(response, parentObj, parentKey, value, reference); + } + if (typeof value === 'object' && value !== null) { + if ( + reference !== undefined && + response._temporaryReferences !== undefined + ) { + // Store this object's reference in case it's returned later. + registerTemporaryReference( + response._temporaryReferences, + value, + reference, + ); + } + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const childRef = + reference !== undefined ? reference + ':' + i : undefined; + // $FlowFixMe[cannot-write] + value[i] = reviveModel(response, value, '' + i, value[i], childRef); + } + } else { + for (const key in value) { + if (hasOwnProperty.call(value, key)) { + const childRef = + reference !== undefined && key.indexOf(':') === -1 + ? reference + ':' + key + : undefined; + const newValue = reviveModel( + response, + value, + key, + value[key], + childRef, + ); + if (newValue !== undefined) { + // $FlowFixMe[cannot-write] + value[key] = newValue; + } else { + // $FlowFixMe[cannot-write] + delete value[key]; + } + } + } + } + } + return value; +} + let initializingChunk: ResolvedModelChunk = (null: any); let initializingChunkBlockedModel: null | {deps: number, value: any} = null; function initializeModelChunk(chunk: ResolvedModelChunk): void { @@ -361,6 +432,9 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { initializingChunk = chunk; initializingChunkBlockedModel = null; + const rootReference = + chunk.reason === -1 ? undefined : chunk.reason.toString(16); + const resolvedModel = chunk.value; // We go to the CYCLIC state until we've fully resolved this. @@ -372,7 +446,15 @@ function initializeModelChunk(chunk: ResolvedModelChunk): void { cyclicChunk.reason = null; try { - const value: T = JSON.parse(resolvedModel, chunk._response._fromJSON); + const rawModel = JSON.parse(resolvedModel); + + const value: T = reviveModel( + chunk._response, + {'': rawModel}, + '', + rawModel, + rootReference, + ); if ( initializingChunkBlockedModel !== null && initializingChunkBlockedModel.deps > 0 @@ -426,7 +508,7 @@ function getChunk(response: Response, id: number): SomeChunk { const backingEntry = response._formData.get(key); if (backingEntry != null) { // We assume that this is a string entry for now. - chunk = createResolvedModelChunk(response, (backingEntry: any)); + chunk = createResolvedModelChunk(response, (backingEntry: any), id); } else { // We're still waiting on this entry to stream in. chunk = createPendingChunk(response); @@ -643,6 +725,7 @@ function parseReadableStream( const chunk: ResolvedModelChunk = createResolvedModelChunk( response, json, + -1, ); initializeModelChunk(chunk); const initializedChunk: SomeChunk = chunk; @@ -670,7 +753,7 @@ function parseReadableStream( // to synchronous emitting. previousBlockedChunk = null; } - resolveModelChunk(chunk, json); + resolveModelChunk(chunk, json, -1); }); } }, @@ -814,6 +897,7 @@ function parseModelString( obj: Object, key: string, value: string, + reference: void | string, ): any { if (value[0] === '$') { switch (value[1]) { @@ -844,7 +928,20 @@ function parseModelString( } case 'T': { // Temporary Reference - return createTemporaryReference(value.slice(2)); + if ( + reference === undefined || + response._temporaryReferences === undefined + ) { + throw new Error( + 'Could not reference an opaque temporary reference. ' + + 'This is likely due to misconfiguring the temporaryReferences options ' + + 'on the server.', + ); + } + return createTemporaryReference( + response._temporaryReferences, + reference, + ); } case 'Q': { // Map @@ -982,6 +1079,7 @@ function parseModelString( export function createResponse( bundlerConfig: ServerManifest, formFieldPrefix: string, + temporaryReferences: void | TemporaryReferenceSet, backingFormData?: FormData = new FormData(), ): Response { const chunks: Map> = new Map(); @@ -990,13 +1088,7 @@ export function createResponse( _prefix: formFieldPrefix, _formData: backingFormData, _chunks: chunks, - _fromJSON: function (this: any, key: string, value: JSONValue) { - if (typeof value === 'string') { - // We can't use .bind here because we need the "this" value. - return parseModelString(response, this, key, value); - } - return value; - }, + _temporaryReferences: temporaryReferences, }; return response; } @@ -1015,7 +1107,7 @@ export function resolveField( const chunk = chunks.get(id); if (chunk) { // We were waiting on this key so now we can resolve it. - resolveModelChunk(chunk, value); + resolveModelChunk(chunk, value, id); } } } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 67d6dcbf3fc1..0fd4bc2533f6 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -11,6 +11,8 @@ import type {Chunk, BinaryChunk, Destination} from './ReactServerStreamConfig'; import type {Postpone} from 'react/src/ReactPostpone'; +import type {TemporaryReferenceSet} from './ReactFlightServerTemporaryReferences'; + import { enableBinaryFlight, enablePostpone, @@ -62,7 +64,6 @@ import type { } from 'shared/ReactTypes'; import type {ReactElement} from 'shared/ReactElementType'; import type {LazyComponent} from 'react/src/ReactLazy'; -import type {TemporaryReference} from './ReactFlightServerTemporaryReferences'; import { resolveClientReferenceMetadata, @@ -80,8 +81,8 @@ import { } from './ReactFlightServerConfig'; import { - isTemporaryReference, - resolveTemporaryReferenceID, + resolveTemporaryReference, + isOpaqueTemporaryReference, } from './ReactFlightServerTemporaryReferences'; import { @@ -389,6 +390,7 @@ export type Request = { writtenClientReferences: Map, writtenServerReferences: Map, number>, writtenObjects: WeakMap, + temporaryReferences: void | TemporaryReferenceSet, identifierPrefix: string, identifierCount: number, taintCleanupQueue: Array, @@ -447,6 +449,7 @@ export function createRequest( identifierPrefix?: string, onPostpone: void | ((reason: string) => void), environmentName: void | string, + temporaryReferences: void | TemporaryReferenceSet, ): Request { if ( ReactSharedInternals.A !== null && @@ -486,6 +489,7 @@ export function createRequest( writtenClientReferences: new Map(), writtenServerReferences: new Map(), writtenObjects: new WeakMap(), + temporaryReferences: temporaryReferences, identifierPrefix: identifierPrefix || '', identifierCount: 1, taintCleanupQueue: cleanupQueue, @@ -1305,7 +1309,7 @@ function renderElement( } } if (typeof type === 'function') { - if (isClientReference(type) || isTemporaryReference(type)) { + if (isClientReference(type) || isOpaqueTemporaryReference(type)) { // This is a reference to a Client Component. return renderClientElement(task, type, key, props, owner, stack); } @@ -1505,10 +1509,6 @@ 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; } @@ -1647,10 +1647,9 @@ function serializeServerReference( function serializeTemporaryReference( request: Request, - temporaryReference: TemporaryReference, + reference: string, ): string { - const id = resolveTemporaryReferenceID(temporaryReference); - return serializeTemporaryReferenceID(id); + return '$T' + reference; } function serializeLargeTextString(request: Request, text: string): string { @@ -2016,6 +2015,16 @@ function renderModelDestructive( ); } + if (request.temporaryReferences !== undefined) { + const tempRef = resolveTemporaryReference( + request.temporaryReferences, + value, + ); + if (tempRef !== undefined) { + return serializeTemporaryReference(request, tempRef); + } + } + if (enableTaint) { const tainted = TaintRegistryObjects.get(value); if (tainted !== undefined) { @@ -2284,8 +2293,14 @@ function renderModelDestructive( if (isServerReference(value)) { return serializeServerReference(request, (value: any)); } - if (isTemporaryReference(value)) { - return serializeTemporaryReference(request, (value: any)); + if (request.temporaryReferences !== undefined) { + const tempRef = resolveTemporaryReference( + request.temporaryReferences, + value, + ); + if (tempRef !== undefined) { + return serializeTemporaryReference(request, tempRef); + } } if (enableTaint) { @@ -2295,7 +2310,13 @@ function renderModelDestructive( } } - if (/^on[A-Z]/.test(parentPropertyName)) { + if (isOpaqueTemporaryReference(value)) { + throw new Error( + 'Could not reference an opaque temporary reference. ' + + 'This is likely due to misconfiguring the temporaryReferences options ' + + 'on the server.', + ); + } else if (/^on[A-Z]/.test(parentPropertyName)) { throw new Error( 'Event handlers cannot be passed to Client Component props.' + describeObjectForErrorMessage(parent, parentPropertyName) + @@ -2642,6 +2663,15 @@ function renderConsoleValue( (value: any), ); } + if (request.temporaryReferences !== undefined) { + const tempRef = resolveTemporaryReference( + request.temporaryReferences, + value, + ); + if (tempRef !== undefined) { + return serializeTemporaryReference(request, tempRef); + } + } if (counter.objectCount > 20) { // We've reached our max number of objects to serialize across the wire so we serialize this @@ -2818,8 +2848,14 @@ function renderConsoleValue( (value: any), ); } - if (isTemporaryReference(value)) { - return serializeTemporaryReference(request, (value: any)); + if (request.temporaryReferences !== undefined) { + const tempRef = resolveTemporaryReference( + request.temporaryReferences, + value, + ); + if (tempRef !== undefined) { + return serializeTemporaryReference(request, tempRef); + } } // Serialize the body of the function as an eval so it can be printed. diff --git a/packages/react-server/src/ReactFlightServerTemporaryReferences.js b/packages/react-server/src/ReactFlightServerTemporaryReferences.js index c133f9030431..ecb21a607af6 100644 --- a/packages/react-server/src/ReactFlightServerTemporaryReferences.js +++ b/packages/react-server/src/ReactFlightServerTemporaryReferences.js @@ -9,20 +9,27 @@ const TEMPORARY_REFERENCE_TAG = Symbol.for('react.temporary.reference'); +export opaque type TemporaryReferenceSet = WeakMap< + TemporaryReference, + string, +>; + // eslint-disable-next-line no-unused-vars -export opaque type TemporaryReference = { - $$typeof: symbol, - $$id: string, -}; +export interface TemporaryReference {} -export function isTemporaryReference(reference: Object): boolean { +export function createTemporaryReferenceSet(): TemporaryReferenceSet { + return new WeakMap(); +} + +export function isOpaqueTemporaryReference(reference: Object): boolean { return reference.$$typeof === TEMPORARY_REFERENCE_TAG; } -export function resolveTemporaryReferenceID( +export function resolveTemporaryReference( + temporaryReferences: TemporaryReferenceSet, temporaryReference: TemporaryReference, -): string { - return temporaryReference.$$id; +): void | string { + return temporaryReferences.get(temporaryReference); } const proxyHandlers = { @@ -37,10 +44,6 @@ const proxyHandlers = { // 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': @@ -79,7 +82,10 @@ const proxyHandlers = { }, }; -export function createTemporaryReference(id: string): TemporaryReference { +export function createTemporaryReference( + temporaryReferences: TemporaryReferenceSet, + id: string, +): TemporaryReference { const reference: TemporaryReference = Object.defineProperties( (function () { throw new Error( @@ -91,9 +97,17 @@ export function createTemporaryReference(id: string): TemporaryReference { }: any), { $$typeof: {value: TEMPORARY_REFERENCE_TAG}, - $$id: {value: id}, }, ); + const wrapper = new Proxy(reference, proxyHandlers); + registerTemporaryReference(temporaryReferences, wrapper, id); + return wrapper; +} - return new Proxy(reference, proxyHandlers); +export function registerTemporaryReference( + temporaryReferences: TemporaryReferenceSet, + object: TemporaryReference, + id: string, +): void { + temporaryReferences.set(object, id); } diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 4c1735a79cf9..f0e73bd6fb4f 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -510,5 +510,6 @@ "522": "Invalid form element. requestFormReset must be passed a form that was rendered by React.", "523": "The render was aborted due to being postponed.", "524": "Values cannot be passed to next() of AsyncIterables passed to Client Components.", - "525": "A React Element from an older version of React was rendered. This is not supported. It can happen if:\n- Multiple copies of the \"react\" package is used.\n- A library pre-bundled an old copy of \"react\" or \"react/jsx-runtime\".\n- A compiler tries to \"inline\" JSX instead of using the runtime." + "525": "A React Element from an older version of React was rendered. This is not supported. It can happen if:\n- Multiple copies of the \"react\" package is used.\n- A library pre-bundled an old copy of \"react\" or \"react/jsx-runtime\".\n- A compiler tries to \"inline\" JSX instead of using the runtime.", + "526": "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server." }