From 6c409acefde29d6ef87dfb208716ec3272bd3c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Thu, 9 May 2024 20:00:56 -0400 Subject: [PATCH] [Flight Reply] Encode Objects Returned to the Client by Reference (#29010) Stacked on #28997. We can use the technique of referencing an object by its row + property name path for temporary references - like we do for deduping. That way we don't need to generate an ID for temporary references. Instead, they can just be an opaque marker in the slot and it has the implicit ID of the row + path. Then we can stash all objects, even the ones that are actually available to read on the server, as temporary references. Without adding anything to the payload since the IDs are implicit. If the same object is returned to the client, it can be referenced by reference instead of serializing it back to the client. This also helps preserve object identity. We assume that the objects are immutable when they pass the boundary. I'm not sure if this is worth it but with this mechanism, if you return the `FormData` payload from a `useActionState` it doesn't have to be serialized on the way back to the client. This is a common pattern for having access to the last submission as "default value" to the form fields. However you can still control it by replacing it with another object if you want. In MPA mode, the temporary references are not configured and so it needs to be serialized in that case. That's required anyway for hydration purposes. I'm not sure if people will actually use this in practice though or if FormData will always be destructured into some other object like with a library that turns it into typed data, and back. If so, the object identity is lost. --- .../react-client/src/ReactFlightClient.js | 4 +- .../src/ReactFlightReplyClient.js | 92 ++++++++----- .../src/ReactFlightTemporaryReferences.js | 24 +--- .../src/ReactFlightDOMServerNode.js | 36 ++++- .../src/ReactFlightDOMServerBrowser.js | 29 +++- .../src/ReactFlightDOMServerEdge.js | 29 +++- .../src/ReactFlightDOMServerNode.js | 42 +++++- .../src/ReactFlightDOMServerBrowser.js | 16 ++- .../src/ReactFlightDOMServerEdge.js | 16 ++- .../src/ReactFlightDOMServerNode.js | 16 ++- .../src/__tests__/ReactFlightDOMReply-test.js | 54 +++++++- .../src/ReactFlightActionServer.js | 7 +- .../src/ReactFlightReplyServer.js | 130 +++++++++++++++--- .../react-server/src/ReactFlightServer.js | 68 ++++++--- .../ReactFlightServerTemporaryReferences.js | 44 ++++-- scripts/error-codes/codes.json | 3 +- 16 files changed, 492 insertions(+), 118 deletions(-) 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." }