@samitouri / QOS-React-2 / commits / 83409a1fdd

[Flight] Encode React Elements in Replies as Temporary References (#28564)

Currently you can accidentally pass React Element to a Server Action. It warns but in prod it actually works because we can encode the symbol and otherwise it's mostly a plain object. It only works if you only pass host components and no function props etc. which makes it potentially error later. The first thing this does it just early hard error for elements. I made Lazy work by unwrapping though since that will be replaced by Promises later which works. Our protocol is not fully symmetric in that elements flow from Server -> Client. Only the Server can resolve Components and only the client should really be able to receive host components. It's not intended that a Server can actually do something with them other than passing them to the client. In the case of a Reply, we expect the client to be stateful. It's waiting for a response. So anything we can't serialize we can still pass by reference to an in memory object. So I introduce the concept of a TemporaryReferenceSet which is an opaque object that you create before encoding the reply. This then stashes any unserializable values in this set and encode the slot by id. When a new response from the Action then returns we pass the same temporary set into the parser which can then restore the objects. This lets you pass a value by reference to the server and back into another slot. For example it can be used to render children inside a parent tree from a server action: ``` export async function Component({ children }) { "use server"; return <div>{children}</div>; } ``` (You wouldn't normally do this due to the waterfalls but for advanced cases.) A common scenario where this comes up accidentally today is in `useActionState`. ``` export function action(state, formData) { "use server"; if (errored) { return <div>This action <strong>errored</strong></div>; } return null; } ``` ``` const [errors, formAction] = useActionState(action); return <div>{errors}<div>; ``` It feels like I'm just passing the JSX from server to client. However, because `useActionState` also sends the previous state *back* to the server this should not actually be valid. Before this PR this actually worked accidentally. You get a DEV warning but it used to work in prod. Once you do something like pass a client reference it won't work tho. We could perhaps make client references work by stashing where we got them from but it wouldn't work with all possible JSX. By adding temporary references to the action implementation this will work again - on the client. It'll also be more efficient since we don't send back the JSX content that you shouldn't introspect on the server anyway. However, a flaw here is that the progressive enhancement of this case won't work because we can't use temporary references for progressive enhancement since there's no in memory stash. What is worse is that it won't error if you hydrate. ~It also will error late in the example above because the first state is "undefined" so invoking the form once works - it errors on the second attempt when it tries to send the error state back again.~ It actually errors on the first invocation because we need to eagerly serialize "previous state" into the form. So at least that's better. I think maybe the solution to this particular pattern would be to allow JSX to serialize if you have no temporary reference set, and remember client references so that client references can be returned back to the server as client references. That way anything you could send from the server could also be returned to the server. But it would only deopt to serializing it for progressive enhancement. The consequence of that would be that there's a lot of JSX that might accidentally seem like it should work but it's only if you've gotten it from the server before that it works. This would have to have pair them somehow though since you can't take a client reference from one implementation of Flight and use it with another.

Sebastian Markbåge committed Mar 19, 2024 at 16:59 UTC 83409a1fdd14b2e5b33c587935a7ef552607780f
16 files changed +525 -35
packages/react-client/src/ReactFlightClient.js
+19
@@ -35,6 +35,8 @@ import type {
35
36 import type {Postpone} from 'react/src/ReactPostpone';
37
38 +import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences';
39 +
40 import {
41 enableBinaryFlight,
42 enablePostpone,
@@ -55,6 +57,8 @@ import {
57
58 import {registerServerReference} from './ReactFlightReplyClient';
59
60 +import {readTemporaryReference} from './ReactFlightTemporaryReferences';
61 +
62 import {
63 REACT_LAZY_TYPE,
64 REACT_ELEMENT_TYPE,
@@ -224,6 +228,7 @@ export type Response = {
228 _rowTag: number, // 0 indicates that we're currently parsing the row ID
229 _rowLength: number, // remaining bytes in the row. 0 indicates that we're looking for a newline.
230 _buffer: Array<Uint8Array>, // chunks received so far as part of this row
231 + _tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from
232 };
233
234 function readChunk<T>(chunk: SomeChunk<T>): T {
@@ -689,6 +694,18 @@ function parseModelString(
694 const metadata = getOutlinedModel(response, id);
695 return createServerReferenceProxy(response, metadata);
696 }
697 + case 'T': {
698 + // Temporary Reference
699 + const id = parseInt(value.slice(2), 16);
700 + const temporaryReferences = response._tempRefs;
701 + if (temporaryReferences == null) {
702 + throw new Error(
703 + 'Missing a temporary reference set but the RSC response returned a temporary reference. ' +
704 + 'Pass a temporaryReference option with the set that was used with the reply.',
705 + );
706 + }
707 + return readTemporaryReference(temporaryReferences, id);
708 + }
709 case 'Q': {
710 // Map
711 const id = parseInt(value.slice(2), 16);
@@ -837,6 +854,7 @@ export function createResponse(
854 callServer: void | CallServerCallback,
855 encodeFormAction: void | EncodeFormActionCallback,
856 nonce: void | string,
857 + temporaryReferences: void | TemporaryReferenceSet,
858 ): Response {
859 const chunks: Map<number, SomeChunk<any>> = new Map();
860 const response: Response = {
@@ -853,6 +871,7 @@ export function createResponse(
871 _rowTag: 0,
872 _rowLength: 0,
873 _buffer: [],
874 + _tempRefs: temporaryReferences,
875 };
876 // Don't inline this call because it causes closure to outline the call above.
877 response._fromJSON = createFromJSONCallback(response);
packages/react-client/src/ReactFlightReplyClient.js
+119 -28
@@ -14,6 +14,9 @@ import type {
14 RejectedThenable,
15 ReactCustomFormAction,
16 } from 'shared/ReactTypes';
17 +import type {LazyComponent} from 'react/src/ReactLazy';
18 +import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences';
19 +
20 import {enableRenderableContext} from 'shared/ReactFeatureFlags';
21
22 import {
@@ -30,6 +33,8 @@ import {
33 objectName,
34 } from 'shared/ReactSerializationErrors';
35
36 +import {writeTemporaryReference} from './ReactFlightTemporaryReferences';
37 +
38 import isArray from 'shared/isArray';
39 import getPrototypeOf from 'shared/getPrototypeOf';
40
@@ -84,9 +89,9 @@ export type ReactServerValue =
89
90 type ReactServerObject = {+[key: string]: ReactServerValue};
91
87 -// function serializeByValueID(id: number): string {
88 -// return '$' + id.toString(16);
89 -// }
92 +function serializeByValueID(id: number): string {
93 + return '$' + id.toString(16);
94 +}
95
96 function serializePromiseID(id: number): string {
97 return '$@' + id.toString(16);
@@ -96,6 +101,10 @@ function serializeServerReferenceID(id: number): string {
101 return '$F' + id.toString(16);
102 }
103
104 +function serializeTemporaryReferenceID(id: number): string {
105 + return '$T' + id.toString(16);
106 +}
107 +
108 function serializeSymbolReference(name: string): string {
109 return '$S' + name;
110 }
@@ -158,6 +167,7 @@ function escapeStringValue(value: string): string {
167 export function processReply(
168 root: ReactServerValue,
169 formFieldPrefix: string,
170 + temporaryReferences: void | TemporaryReferenceSet,
171 resolve: (string | FormData) => void,
172 reject: (error: mixed) => void,
173 ): void {
@@ -206,6 +216,81 @@ export function processReply(
216 }
217
218 if (typeof value === 'object') {
219 + switch ((value: any).$$typeof) {
220 + case REACT_ELEMENT_TYPE: {
221 + if (temporaryReferences === undefined) {
222 + throw new Error(
223 + 'React Element cannot be passed to Server Functions from the Client without a ' +
224 + 'temporary reference set. Pass a TemporaryReferenceSet to the options.' +
225 + (__DEV__ ? describeObjectForErrorMessage(parent, key) : ''),
226 + );
227 + }
228 + return serializeTemporaryReferenceID(
229 + writeTemporaryReference(temporaryReferences, value),
230 + );
231 + }
232 + case REACT_LAZY_TYPE: {
233 + // Resolve lazy as if it wasn't here. In the future this will be encoded as a Promise.
234 + const lazy: LazyComponent<any, any> = (value: any);
235 + const payload = lazy._payload;
236 + const init = lazy._init;
237 + if (formData === null) {
238 + // Upgrade to use FormData to allow us to stream this value.
239 + formData = new FormData();
240 + }
241 + pendingParts++;
242 + try {
243 + const resolvedModel = init(payload);
244 + // We always outline this as a separate part even though we could inline it
245 + // because it ensures a more deterministic encoding.
246 + const lazyId = nextPartId++;
247 + const partJSON = JSON.stringify(resolvedModel, resolveToJSON);
248 + // $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
249 + const data: FormData = formData;
250 + // eslint-disable-next-line react-internal/safe-string-coercion
251 + data.append(formFieldPrefix + lazyId, partJSON);
252 + return serializeByValueID(lazyId);
253 + } catch (x) {
254 + if (
255 + typeof x === 'object' &&
256 + x !== null &&
257 + typeof x.then === 'function'
258 + ) {
259 + // Suspended
260 + pendingParts++;
261 + const lazyId = nextPartId++;
262 + const thenable: Thenable<any> = (x: any);
263 + const retry = function () {
264 + // While the first promise resolved, its value isn't necessarily what we'll
265 + // resolve into because we might suspend again.
266 + try {
267 + const partJSON = JSON.stringify(value, resolveToJSON);
268 + // $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
269 + const data: FormData = formData;
270 + // eslint-disable-next-line react-internal/safe-string-coercion
271 + data.append(formFieldPrefix + lazyId, partJSON);
272 + pendingParts--;
273 + if (pendingParts === 0) {
274 + resolve(data);
275 + }
276 + } catch (reason) {
277 + reject(reason);
278 + }
279 + };
280 + thenable.then(retry, retry);
281 + return serializeByValueID(lazyId);
282 + } else {
283 + // In the future we could consider serializing this as an error
284 + // that throws on the server instead.
285 + reject(x);
286 + return null;
287 + }
288 + } finally {
289 + pendingParts--;
290 + }
291 + }
292 + }
293 +
294 // $FlowFixMe[method-unbinding]
295 if (typeof value.then === 'function') {
296 // We assume that any object with a .then property is a "Thenable" type,
@@ -219,14 +304,18 @@ export function processReply(
304 const thenable: Thenable<any> = (value: any);
305 thenable.then(
306 partValue => {
222 - const partJSON = JSON.stringify(partValue, resolveToJSON);
223 - // $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
224 - const data: FormData = formData;
225 - // eslint-disable-next-line react-internal/safe-string-coercion
226 - data.append(formFieldPrefix + promiseId, partJSON);
227 - pendingParts--;
228 - if (pendingParts === 0) {
229 - resolve(data);
307 + try {
308 + const partJSON = JSON.stringify(partValue, resolveToJSON);
309 + // $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
310 + const data: FormData = formData;
311 + // eslint-disable-next-line react-internal/safe-string-coercion
312 + data.append(formFieldPrefix + promiseId, partJSON);
313 + pendingParts--;
314 + if (pendingParts === 0) {
315 + resolve(data);
316 + }
317 + } catch (reason) {
318 + reject(reason);
319 }
320 },
321 reason => {
@@ -288,23 +377,19 @@ export function processReply(
377 proto !== ObjectPrototype &&
378 (proto === null || getPrototypeOf(proto) !== null)
379 ) {
291 - throw new Error(
292 - 'Only plain objects, and a few built-ins, can be passed to Server Actions. ' +
293 - 'Classes or null prototypes are not supported.',
380 + if (temporaryReferences === undefined) {
381 + throw new Error(
382 + 'Only plain objects, and a few built-ins, can be passed to Server Actions. ' +
383 + 'Classes or null prototypes are not supported.',
384 + );
385 + }
386 + // We can serialize class instances as temporary references.
387 + return serializeTemporaryReferenceID(
388 + writeTemporaryReference(temporaryReferences, value),
389 );
390 }
391 if (__DEV__) {
297 - if ((value: any).$$typeof === REACT_ELEMENT_TYPE) {
298 - console.error(
299 - 'React Element cannot be passed to Server Functions from the Client.%s',
300 - describeObjectForErrorMessage(parent, key),
301 - );
302 - } else if ((value: any).$$typeof === REACT_LAZY_TYPE) {
303 - console.error(
304 - 'React Lazy cannot be passed to Server Functions from the Client.%s',
305 - describeObjectForErrorMessage(parent, key),
306 - );
307 - } else if (
392 + if (
393 (value: any).$$typeof ===
394 (enableRenderableContext ? REACT_CONTEXT_TYPE : REACT_PROVIDER_TYPE)
395 ) {
@@ -382,9 +467,14 @@ export function processReply(
467 formData.set(formFieldPrefix + refId, metaDataJSON);
468 return serializeServerReferenceID(refId);
469 }
385 - throw new Error(
386 - 'Client Functions cannot be passed directly to Server Functions. ' +
387 - 'Only Functions passed from the Server can be passed back again.',
470 + if (temporaryReferences === undefined) {
471 + throw new Error(
472 + 'Client Functions cannot be passed directly to Server Functions. ' +
473 + 'Only Functions passed from the Server can be passed back again.',
474 + );
475 + }
476 + return serializeTemporaryReferenceID(
477 + writeTemporaryReference(temporaryReferences, value),
478 );
479 }
480
@@ -443,6 +533,7 @@ function encodeFormData(reference: any): Thenable<FormData> {
533 processReply(
534 reference,
535 '',
536 + undefined, // TODO: This means React Elements can't be used as state in progressive enhancement.
537 (body: string | FormData) => {
538 if (typeof body === 'string') {
539 const data = new FormData();
packages/react-client/src/ReactFlightTemporaryReferences.js new
+41
@@ -0,0 +1,41 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +interface Reference {}
11 +
12 +export opaque type TemporaryReferenceSet = Array<Reference>;
13 +
14 +export function createTemporaryReferenceSet(): TemporaryReferenceSet {
15 + return [];
16 +}
17 +
18 +export function writeTemporaryReference(
19 + set: TemporaryReferenceSet,
20 + object: Reference,
21 +): number {
22 + // We always create a new entry regardless if we've already written the same
23 + // object. This ensures that we always generate a deterministic encoding of
24 + // each slot in the reply for cacheability.
25 + const newId = set.length;
26 + set.push(object);
27 + return newId;
28 +}
29 +
30 +export function readTemporaryReference(
31 + set: TemporaryReferenceSet,
32 + id: number,
33 +): Reference {
34 + if (id < 0 || id >= set.length) {
35 + throw new Error(
36 + "The RSC response contained a reference that doesn't exist in the temporary reference set. " +
37 + 'Always pass the matching set that was used to create the reply when parsing its response.',
38 + );
39 + }
40 + return set[id];
41 +}
packages/react-server-dom-esm/src/ReactFlightDOMClientBrowser.js
+20 -1
@@ -26,11 +26,18 @@ import {
26 createServerReference,
27 } from 'react-client/src/ReactFlightReplyClient';
28
29 +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
30 +
31 +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
32 +
33 +export type {TemporaryReferenceSet};
34 +
35 type CallServerCallback = <A, T>(string, args: A) => Promise<T>;
36
37 export type Options = {
38 moduleBaseURL?: string,
39 callServer?: CallServerCallback,
40 + temporaryReferences?: TemporaryReferenceSet,
41 };
42
43 function createResponseFromOptions(options: void | Options) {
@@ -40,6 +47,9 @@ function createResponseFromOptions(options: void | Options) {
47 options && options.callServer ? options.callServer : undefined,
48 undefined, // encodeFormAction
49 undefined, // nonce
50 + options && options.temporaryReferences
51 + ? options.temporaryReferences
52 + : undefined,
53 );
54 }
55
@@ -97,11 +107,20 @@ function createFromFetch<T>(
107
108 function encodeReply(
109 value: ReactServerValue,
110 + options?: {temporaryReferences?: TemporaryReferenceSet},
111 ): Promise<
112 string | URLSearchParams | FormData,
113 > /* We don't use URLSearchParams yet but maybe */ {
114 return new Promise((resolve, reject) => {
104 - processReply(value, '', resolve, reject);
115 + processReply(
116 + value,
117 + '',
118 + options && options.temporaryReferences
119 + ? options.temporaryReferences
120 + : undefined,
121 + resolve,
122 + reject,
123 + );
124 });
125 }
126
packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js
+1
@@ -60,6 +60,7 @@ function createFromNodeStream<T>(
60 noServerCall,
61 options ? options.encodeFormAction : undefined,
62 options && typeof options.nonce === 'string' ? options.nonce : undefined,
63 + undefined, // TODO: If encodeReply is supported, this should support temporaryReferences
64 );
65 stream.on('data', chunk => {
66 processBinaryChunk(response, chunk);
packages/react-server-dom-turbopack/src/ReactFlightDOMClientBrowser.js
+20 -1
@@ -26,10 +26,17 @@ import {
26 createServerReference,
27 } from 'react-client/src/ReactFlightReplyClient';
28
29 +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
30 +
31 +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
32 +
33 +export type {TemporaryReferenceSet};
34 +
35 type CallServerCallback = <A, T>(string, args: A) => Promise<T>;
36
37 export type Options = {
38 callServer?: CallServerCallback,
39 + temporaryReferences?: TemporaryReferenceSet,
40 };
41
42 function createResponseFromOptions(options: void | Options) {
@@ -39,6 +46,9 @@ function createResponseFromOptions(options: void | Options) {
46 options && options.callServer ? options.callServer : undefined,
47 undefined, // encodeFormAction
48 undefined, // nonce
49 + options && options.temporaryReferences
50 + ? options.temporaryReferences
51 + : undefined,
52 );
53 }
54
@@ -96,11 +106,20 @@ function createFromFetch<T>(
106
107 function encodeReply(
108 value: ReactServerValue,
109 + options?: {temporaryReferences?: TemporaryReferenceSet},
110 ): Promise<
111 string | URLSearchParams | FormData,
112 > /* We don't use URLSearchParams yet but maybe */ {
113 return new Promise((resolve, reject) => {
103 - processReply(value, '', resolve, reject);
114 + processReply(
115 + value,
116 + '',
117 + options && options.temporaryReferences
118 + ? options.temporaryReferences
119 + : undefined,
120 + resolve,
121 + reject,
122 + );
123 });
124 }
125
packages/react-server-dom-turbopack/src/ReactFlightDOMClientEdge.js
+20 -1
@@ -36,6 +36,12 @@ import {
36 createServerReference as createServerReferenceImpl,
37 } from 'react-client/src/ReactFlightReplyClient';
38
39 +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
40 +
41 +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
42 +
43 +export type {TemporaryReferenceSet};
44 +
45 function noServerCall() {
46 throw new Error(
47 'Server Functions cannot be called during initial render. ' +
@@ -60,6 +66,7 @@ export type Options = {
66 ssrManifest: SSRManifest,
67 nonce?: string,
68 encodeFormAction?: EncodeFormActionCallback,
69 + temporaryReferences?: TemporaryReferenceSet,
70 };
71
72 function createResponseFromOptions(options: Options) {
@@ -69,6 +76,9 @@ function createResponseFromOptions(options: Options) {
76 noServerCall,
77 options.encodeFormAction,
78 typeof options.nonce === 'string' ? options.nonce : undefined,
79 + options && options.temporaryReferences
80 + ? options.temporaryReferences
81 + : undefined,
82 );
83 }
84
@@ -126,11 +136,20 @@ function createFromFetch<T>(
136
137 function encodeReply(
138 value: ReactServerValue,
139 + options?: {temporaryReferences?: TemporaryReferenceSet},
140 ): Promise<
141 string | URLSearchParams | FormData,
142 > /* We don't use URLSearchParams yet but maybe */ {
143 return new Promise((resolve, reject) => {
133 - processReply(value, '', resolve, reject);
144 + processReply(
145 + value,
146 + '',
147 + options && options.temporaryReferences
148 + ? options.temporaryReferences
149 + : undefined,
150 + resolve,
151 + reject,
152 + );
153 });
154 }
155
packages/react-server-dom-turbopack/src/ReactFlightDOMClientNode.js
+1
@@ -69,6 +69,7 @@ function createFromNodeStream<T>(
69 noServerCall,
70 options ? options.encodeFormAction : undefined,
71 options && typeof options.nonce === 'string' ? options.nonce : undefined,
72 + undefined, // TODO: If encodeReply is supported, this should support temporaryReferences
73 );
74 stream.on('data', chunk => {
75 processBinaryChunk(response, chunk);
packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js
+20 -1
@@ -26,10 +26,17 @@ import {
26 createServerReference,
27 } from 'react-client/src/ReactFlightReplyClient';
28
29 +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
30 +
31 +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
32 +
33 +export type {TemporaryReferenceSet};
34 +
35 type CallServerCallback = <A, T>(string, args: A) => Promise<T>;
36
37 export type Options = {
38 callServer?: CallServerCallback,
39 + temporaryReferences?: TemporaryReferenceSet,
40 };
41
42 function createResponseFromOptions(options: void | Options) {
@@ -39,6 +46,9 @@ function createResponseFromOptions(options: void | Options) {
46 options && options.callServer ? options.callServer : undefined,
47 undefined, // encodeFormAction
48 undefined, // nonce
49 + options && options.temporaryReferences
50 + ? options.temporaryReferences
51 + : undefined,
52 );
53 }
54
@@ -96,11 +106,20 @@ function createFromFetch<T>(
106
107 function encodeReply(
108 value: ReactServerValue,
109 + options?: {temporaryReferences?: TemporaryReferenceSet},
110 ): Promise<
111 string | URLSearchParams | FormData,
112 > /* We don't use URLSearchParams yet but maybe */ {
113 return new Promise((resolve, reject) => {
103 - processReply(value, '', resolve, reject);
114 + processReply(
115 + value,
116 + '',
117 + options && options.temporaryReferences
118 + ? options.temporaryReferences
119 + : undefined,
120 + resolve,
121 + reject,
122 + );
123 });
124 }
125
packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js
+20 -1
@@ -36,6 +36,12 @@ import {
36 createServerReference as createServerReferenceImpl,
37 } from 'react-client/src/ReactFlightReplyClient';
38
39 +import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
40 +
41 +export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
42 +
43 +export type {TemporaryReferenceSet};
44 +
45 function noServerCall() {
46 throw new Error(
47 'Server Functions cannot be called during initial render. ' +
@@ -60,6 +66,7 @@ export type Options = {
66 ssrManifest: SSRManifest,
67 nonce?: string,
68 encodeFormAction?: EncodeFormActionCallback,
69 + temporaryReferences?: TemporaryReferenceSet,
70 };
71
72 function createResponseFromOptions(options: Options) {
@@ -69,6 +76,9 @@ function createResponseFromOptions(options: Options) {
76 noServerCall,
77 options.encodeFormAction,
78 typeof options.nonce === 'string' ? options.nonce : undefined,
79 + options && options.temporaryReferences
80 + ? options.temporaryReferences
81 + : undefined,
82 );
83 }
84
@@ -126,11 +136,20 @@ function createFromFetch<T>(
136
137 function encodeReply(
138 value: ReactServerValue,
139 + options?: {temporaryReferences?: TemporaryReferenceSet},
140 ): Promise<
141 string | URLSearchParams | FormData,
142 > /* We don't use URLSearchParams yet but maybe */ {
143 return new Promise((resolve, reject) => {
133 - processReply(value, '', resolve, reject);
144 + processReply(
145 + value,
146 + '',
147 + options && options.temporaryReferences
148 + ? options.temporaryReferences
149 + : undefined,
150 + resolve,
151 + reject,
152 + );
153 });
154 }
155
packages/react-server-dom-webpack/src/ReactFlightDOMClientNode.js
+1
@@ -69,6 +69,7 @@ function createFromNodeStream<T>(
69 noServerCall,
70 options ? options.encodeFormAction : undefined,
71 options && typeof options.nonce === 'string' ? options.nonce : undefined,
72 + undefined, // TODO: If encodeReply is supported, this should support temporaryReferences
73 );
74 stream.on('data', chunk => {
75 processBinaryChunk(response, chunk);
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+105
@@ -17,6 +17,7 @@ global.TextDecoder = require('util').TextDecoder;
17
18 // let serverExports;
19 let webpackServerMap;
20 +let React;
21 let ReactServerDOMServer;
22 let ReactServerDOMClient;
23
@@ -31,6 +32,7 @@ describe('ReactFlightDOMReply', () => {
32 const WebpackMock = require('./utils/WebpackMock');
33 // serverExports = WebpackMock.serverExports;
34 webpackServerMap = WebpackMock.webpackServerMap;
35 + React = require('react');
36 ReactServerDOMServer = require('react-server-dom-webpack/server.browser');
37 jest.resetModules();
38 ReactServerDOMClient = require('react-server-dom-webpack/client');
@@ -241,4 +243,107 @@ describe('ReactFlightDOMReply', () => {
243 }
244 expect(error.message).toBe('Connection closed.');
245 });
246 +
247 + it('resolves a promise and includes its value', async () => {
248 + let resolve;
249 + const promise = new Promise(r => (resolve = r));
250 + const bodyPromise = ReactServerDOMClient.encodeReply({promise: promise});
251 + resolve('Hi');
252 + const result = await ReactServerDOMServer.decodeReply(await bodyPromise);
253 + expect(await result.promise).toBe('Hi');
254 + });
255 +
256 + it('resolves a React.lazy and includes its value', async () => {
257 + let resolve;
258 + const lazy = React.lazy(() => new Promise(r => (resolve = r)));
259 + const bodyPromise = ReactServerDOMClient.encodeReply({lazy: lazy});
260 + resolve({default: 'Hi'});
261 + const result = await ReactServerDOMServer.decodeReply(await bodyPromise);
262 + expect(result.lazy).toBe('Hi');
263 + });
264 +
265 + it('resolves a proxy throwing a promise inside React.lazy', async () => {
266 + let resolve1;
267 + let resolve2;
268 + const lazy = React.lazy(() => new Promise(r => (resolve1 = r)));
269 + const promise = new Promise(r => (resolve2 = r));
270 + const bodyPromise1 = ReactServerDOMClient.encodeReply({lazy: lazy});
271 + const target = {value: ''};
272 + let loaded = false;
273 + const proxy = new Proxy(target, {
274 + get(targetObj, prop, receiver) {
275 + if (prop === 'value') {
276 + if (!loaded) {
277 + throw promise;
278 + }
279 + return 'Hello';
280 + }
281 + return targetObj[prop];
282 + },
283 + });
284 + await resolve1({default: proxy});
285 +
286 + // Encode it again so that we have an already initialized lazy
287 + // This is now already resolved but the proxy inside isn't. This ensures
288 + // we trigger the retry code path.
289 + const bodyPromise2 = ReactServerDOMClient.encodeReply({lazy: lazy});
290 +
291 + // Then resolve the inner thrown promise.
292 + loaded = true;
293 + await resolve2('Hello');
294 +
295 + const result1 = await ReactServerDOMServer.decodeReply(await bodyPromise1);
296 + expect(await result1.lazy.value).toBe('Hello');
297 + const result2 = await ReactServerDOMServer.decodeReply(await bodyPromise2);
298 + expect(await result2.lazy.value).toBe('Hello');
299 + });
300 +
301 + it('errors when called with JSX by default', async () => {
302 + let error;
303 + try {
304 + await ReactServerDOMClient.encodeReply(<div />);
305 + } catch (x) {
306 + error = x;
307 + }
308 + expect(error).toEqual(
309 + expect.objectContaining({
310 + message: __DEV__
311 + ? expect.stringContaining(
312 + 'React Element cannot be passed to Server Functions from the Client without a temporary reference set.',
313 + )
314 + : expect.stringContaining(''),
315 + }),
316 + );
317 + });
318 +
319 + it('can pass JSX through a round trip using temporary references', async () => {
320 + function Component() {
321 + return <div />;
322 + }
323 +
324 + const children = <Component />;
325 +
326 + const temporaryReferences =
327 + ReactServerDOMClient.createTemporaryReferenceSet();
328 + const body = await ReactServerDOMClient.encodeReply(
329 + {children},
330 + {
331 + temporaryReferences,
332 + },
333 + );
334 + const serverPayload = await ReactServerDOMServer.decodeReply(
335 + body,
336 + webpackServerMap,
337 + );
338 + const stream = ReactServerDOMServer.renderToReadableStream(serverPayload);
339 + const response = await ReactServerDOMClient.createFromReadableStream(
340 + stream,
341 + {
342 + temporaryReferences,
343 + },
344 + );
345 +
346 + // This should've been the same reference that we already saw.
347 + expect(response.children).toBe(children);
348 + });
349 });
packages/react-server/src/ReactFlightReplyServer.js
+6
@@ -24,6 +24,8 @@ import {
24 requireModule,
25 } from 'react-client/src/ReactFlightClientConfig';
26
27 +import {createTemporaryReference} from './ReactFlightServerTemporaryReferences';
28 +
29 export type JSONValue =
30 | number
31 | null
@@ -413,6 +415,10 @@ function parseModelString(
415 key,
416 );
417 }
418 + case 'T': {
419 + // Temporary Reference
420 + return createTemporaryReference(value.slice(2));
421 + }
422 case 'Q': {
423 // Map
424 const id = parseInt(value.slice(2), 16);
packages/react-server/src/ReactFlightServer.js
+25 -1
@@ -59,6 +59,7 @@ import type {
59 ReactAsyncInfo,
60 } from 'shared/ReactTypes';
61 import type {LazyComponent} from 'react/src/ReactLazy';
62 +import type {TemporaryReference} from './ReactFlightServerTemporaryReferences';
63
64 import {
65 resolveClientReferenceMetadata,
@@ -73,6 +74,11 @@ import {
74 initAsyncDebugInfo,
75 } from './ReactFlightServerConfig';
76
77 +import {
78 + isTemporaryReference,
79 + resolveTemporaryReferenceID,
80 +} from './ReactFlightServerTemporaryReferences';
81 +
82 import {
83 HooksDispatcher,
84 prepareToUseHooksForRequest,
@@ -788,7 +794,7 @@ function renderElement(
794 }
795 }
796 if (typeof type === 'function') {
791 - if (isClientReference(type)) {
797 + if (isClientReference(type) || isTemporaryReference(type)) {
798 // This is a reference to a Client Component.
799 return renderClientElement(task, type, key, props);
800 }
@@ -949,6 +955,10 @@ function serializeServerReferenceID(id: number): string {
955 return '$F' + id.toString(16);
956 }
957
958 +function serializeTemporaryReferenceID(id: string): string {
959 + return '$T' + id;
960 +}
961 +
962 function serializeSymbolReference(name: string): string {
963 return '$S' + name;
964 }
@@ -1085,6 +1095,14 @@ function serializeServerReference(
1095 return serializeServerReferenceID(metadataId);
1096 }
1097
1098 +function serializeTemporaryReference(
1099 + request: Request,
1100 + temporaryReference: TemporaryReference<any>,
1101 +): string {
1102 + const id = resolveTemporaryReferenceID(temporaryReference);
1103 + return serializeTemporaryReferenceID(id);
1104 +}
1105 +
1106 function serializeLargeTextString(request: Request, text: string): string {
1107 request.pendingChunks += 2;
1108 const textId = request.nextChunkId++;
@@ -1635,6 +1653,9 @@ function renderModelDestructive(
1653 if (isServerReference(value)) {
1654 return serializeServerReference(request, (value: any));
1655 }
1656 + if (isTemporaryReference(value)) {
1657 + return serializeTemporaryReference(request, (value: any));
1658 + }
1659
1660 if (enableTaint) {
1661 const tainted = TaintRegistryObjects.get(value);
@@ -2103,6 +2124,9 @@ function renderConsoleValue(
2124 (value: any),
2125 );
2126 }
2127 + if (isTemporaryReference(value)) {
2128 + return serializeTemporaryReference(request, (value: any));
2129 + }
2130
2131 // Serialize the body of the function as an eval so it can be printed.
2132 // $FlowFixMe[method-unbinding]
packages/react-server/src/ReactFlightServerTemporaryReferences.js new
+99
@@ -0,0 +1,99 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +const TEMPORARY_REFERENCE_TAG = Symbol.for('react.temporary.reference');
11 +
12 +// eslint-disable-next-line no-unused-vars
13 +export opaque type TemporaryReference<T> = {
14 + $$typeof: symbol,
15 + $$id: string,
16 +};
17 +
18 +export function isTemporaryReference(reference: Object): boolean {
19 + return reference.$$typeof === TEMPORARY_REFERENCE_TAG;
20 +}
21 +
22 +export function resolveTemporaryReferenceID<T>(
23 + temporaryReference: TemporaryReference<T>,
24 +): string {
25 + return temporaryReference.$$id;
26 +}
27 +
28 +const proxyHandlers = {
29 + get: function (
30 + target: Function,
31 + name: string | symbol,
32 + receiver: Proxy<Function>,
33 + ) {
34 + switch (name) {
35 + // These names are read by the Flight runtime if you end up using the exports object.
36 + case '$$typeof':
37 + // These names are a little too common. We should probably have a way to
38 + // have the Flight runtime extract the inner target instead.
39 + return target.$$typeof;
40 + case '$$id':
41 + return target.$$id;
42 + case '$$async':
43 + return target.$$async;
44 + case 'name':
45 + return undefined;
46 + case 'displayName':
47 + return undefined;
48 + // We need to special case this because createElement reads it if we pass this
49 + // reference.
50 + case 'defaultProps':
51 + return undefined;
52 + // Avoid this attempting to be serialized.
53 + case 'toJSON':
54 + return undefined;
55 + case Symbol.toPrimitive:
56 + // $FlowFixMe[prop-missing]
57 + return Object.prototype[Symbol.toPrimitive];
58 + case Symbol.toStringTag:
59 + // $FlowFixMe[prop-missing]
60 + return Object.prototype[Symbol.toStringTag];
61 + case 'Provider':
62 + throw new Error(
63 + `Cannot render a Client Context Provider on the Server. ` +
64 + `Instead, you can export a Client Component wrapper ` +
65 + `that itself renders a Client Context Provider.`,
66 + );
67 + }
68 + throw new Error(
69 + // eslint-disable-next-line react-internal/safe-string-coercion
70 + `Cannot access ${String(name)} on the server. ` +
71 + 'You cannot dot into a temporary client reference from a server component. ' +
72 + 'You can only pass the value through to the client.',
73 + );
74 + },
75 + set: function () {
76 + throw new Error(
77 + 'Cannot assign to a temporary client reference from a server module.',
78 + );
79 + },
80 +};
81 +
82 +export function createTemporaryReference<T>(id: string): TemporaryReference<T> {
83 + const reference: TemporaryReference<any> = Object.defineProperties(
84 + (function () {
85 + throw new Error(
86 + // eslint-disable-next-line react-internal/safe-string-coercion
87 + `Attempted to call a temporary Client Reference from the server but it is on the client. ` +
88 + `It's not possible to invoke a client function from the server, it can ` +
89 + `only be rendered as a Component or passed to props of a Client Component.`,
90 + );
91 + }: any),
92 + {
93 + $$typeof: {value: TEMPORARY_REFERENCE_TAG},
94 + $$id: {value: id},
95 + },
96 + );
97 +
98 + return new Proxy(reference, proxyHandlers);
99 +}
scripts/error-codes/codes.json
+8 -1
@@ -494,5 +494,12 @@
494 "506": "Functions are not valid as a child of Client Components. This may happen if you return %s instead of <%s /> from render. Or maybe you meant to call this function rather than return it.%s",
495 "507": "Expected the last optional `callback` argument to be a function. Instead received: %s.",
496 "508": "The first argument must be a React class instance. Instead received: %s.",
497 - "509": "ReactDOM: Unsupported Legacy Mode API."
497 + "509": "ReactDOM: Unsupported Legacy Mode API.",
498 + "510": "React Element cannot be passed to Server Functions from the Client without a temporary reference set. Pass a TemporaryReferenceSet to the options.%s",
499 + "511": "Missing a temporary reference set but the RSC response returned a temporary reference. Pass a temporaryReference option with the set that was used with the reply.",
500 + "512": "The RSC response contained a reference that doesn't exist in the temporary reference set. Always pass the matching set that was used to create the reply when parsing its response.",
501 + "513": "Cannot render a Client Context Provider on the Server. Instead, you can export a Client Component wrapper that itself renders a Client Context Provider.",
502 + "514": "Cannot access %s on the server. You cannot dot into a temporary client reference from a server component. You can only pass the value through to the client.",
503 + "515": "Cannot assign to a temporary client reference from a server module.",
504 + "516": "Attempted to call a temporary Client Reference from the server but it is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component."
505 }