Skip to content

Commit 5868c0e

Browse files
sebmarkbageAndyPengc12
authored andcommitted
[Flight] Encode React Elements in Replies as Temporary References (facebook#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.
1 parent 56715d8 commit 5868c0e

16 files changed

+525
-35
lines changed

packages/react-client/src/ReactFlightClient.js

+19
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import type {
3535

3636
import type {Postpone} from 'react/src/ReactPostpone';
3737

38+
import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences';
39+
3840
import {
3941
enableBinaryFlight,
4042
enablePostpone,
@@ -55,6 +57,8 @@ import {
5557

5658
import {registerServerReference} from './ReactFlightReplyClient';
5759

60+
import {readTemporaryReference} from './ReactFlightTemporaryReferences';
61+
5862
import {
5963
REACT_LAZY_TYPE,
6064
REACT_ELEMENT_TYPE,
@@ -224,6 +228,7 @@ export type Response = {
224228
_rowTag: number, // 0 indicates that we're currently parsing the row ID
225229
_rowLength: number, // remaining bytes in the row. 0 indicates that we're looking for a newline.
226230
_buffer: Array<Uint8Array>, // chunks received so far as part of this row
231+
_tempRefs: void | TemporaryReferenceSet, // the set temporary references can be resolved from
227232
};
228233

229234
function readChunk<T>(chunk: SomeChunk<T>): T {
@@ -689,6 +694,18 @@ function parseModelString(
689694
const metadata = getOutlinedModel(response, id);
690695
return createServerReferenceProxy(response, metadata);
691696
}
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+
}
692709
case 'Q': {
693710
// Map
694711
const id = parseInt(value.slice(2), 16);
@@ -837,6 +854,7 @@ export function createResponse(
837854
callServer: void | CallServerCallback,
838855
encodeFormAction: void | EncodeFormActionCallback,
839856
nonce: void | string,
857+
temporaryReferences: void | TemporaryReferenceSet,
840858
): Response {
841859
const chunks: Map<number, SomeChunk<any>> = new Map();
842860
const response: Response = {
@@ -853,6 +871,7 @@ export function createResponse(
853871
_rowTag: 0,
854872
_rowLength: 0,
855873
_buffer: [],
874+
_tempRefs: temporaryReferences,
856875
};
857876
// Don't inline this call because it causes closure to outline the call above.
858877
response._fromJSON = createFromJSONCallback(response);

packages/react-client/src/ReactFlightReplyClient.js

+119-28
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import type {
1414
RejectedThenable,
1515
ReactCustomFormAction,
1616
} from 'shared/ReactTypes';
17+
import type {LazyComponent} from 'react/src/ReactLazy';
18+
import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences';
19+
1720
import {enableRenderableContext} from 'shared/ReactFeatureFlags';
1821

1922
import {
@@ -30,6 +33,8 @@ import {
3033
objectName,
3134
} from 'shared/ReactSerializationErrors';
3235

36+
import {writeTemporaryReference} from './ReactFlightTemporaryReferences';
37+
3338
import isArray from 'shared/isArray';
3439
import getPrototypeOf from 'shared/getPrototypeOf';
3540

@@ -84,9 +89,9 @@ export type ReactServerValue =
8489

8590
type ReactServerObject = {+[key: string]: ReactServerValue};
8691

87-
// function serializeByValueID(id: number): string {
88-
// return '$' + id.toString(16);
89-
// }
92+
function serializeByValueID(id: number): string {
93+
return '$' + id.toString(16);
94+
}
9095

9196
function serializePromiseID(id: number): string {
9297
return '$@' + id.toString(16);
@@ -96,6 +101,10 @@ function serializeServerReferenceID(id: number): string {
96101
return '$F' + id.toString(16);
97102
}
98103

104+
function serializeTemporaryReferenceID(id: number): string {
105+
return '$T' + id.toString(16);
106+
}
107+
99108
function serializeSymbolReference(name: string): string {
100109
return '$S' + name;
101110
}
@@ -158,6 +167,7 @@ function escapeStringValue(value: string): string {
158167
export function processReply(
159168
root: ReactServerValue,
160169
formFieldPrefix: string,
170+
temporaryReferences: void | TemporaryReferenceSet,
161171
resolve: (string | FormData) => void,
162172
reject: (error: mixed) => void,
163173
): void {
@@ -206,6 +216,81 @@ export function processReply(
206216
}
207217

208218
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+
209294
// $FlowFixMe[method-unbinding]
210295
if (typeof value.then === 'function') {
211296
// We assume that any object with a .then property is a "Thenable" type,
@@ -219,14 +304,18 @@ export function processReply(
219304
const thenable: Thenable<any> = (value: any);
220305
thenable.then(
221306
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);
230319
}
231320
},
232321
reason => {
@@ -288,23 +377,19 @@ export function processReply(
288377
proto !== ObjectPrototype &&
289378
(proto === null || getPrototypeOf(proto) !== null)
290379
) {
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),
294389
);
295390
}
296391
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 (
308393
(value: any).$$typeof ===
309394
(enableRenderableContext ? REACT_CONTEXT_TYPE : REACT_PROVIDER_TYPE)
310395
) {
@@ -382,9 +467,14 @@ export function processReply(
382467
formData.set(formFieldPrefix + refId, metaDataJSON);
383468
return serializeServerReferenceID(refId);
384469
}
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),
388478
);
389479
}
390480

@@ -443,6 +533,7 @@ function encodeFormData(reference: any): Thenable<FormData> {
443533
processReply(
444534
reference,
445535
'',
536+
undefined, // TODO: This means React Elements can't be used as state in progressive enhancement.
446537
(body: string | FormData) => {
447538
if (typeof body === 'string') {
448539
const data = new FormData();
Original file line numberDiff line numberDiff line change
@@ -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
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,18 @@ import {
2626
createServerReference,
2727
} from 'react-client/src/ReactFlightReplyClient';
2828

29+
import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
30+
31+
export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
32+
33+
export type {TemporaryReferenceSet};
34+
2935
type CallServerCallback = <A, T>(string, args: A) => Promise<T>;
3036

3137
export type Options = {
3238
moduleBaseURL?: string,
3339
callServer?: CallServerCallback,
40+
temporaryReferences?: TemporaryReferenceSet,
3441
};
3542

3643
function createResponseFromOptions(options: void | Options) {
@@ -40,6 +47,9 @@ function createResponseFromOptions(options: void | Options) {
4047
options && options.callServer ? options.callServer : undefined,
4148
undefined, // encodeFormAction
4249
undefined, // nonce
50+
options && options.temporaryReferences
51+
? options.temporaryReferences
52+
: undefined,
4353
);
4454
}
4555

@@ -97,11 +107,20 @@ function createFromFetch<T>(
97107

98108
function encodeReply(
99109
value: ReactServerValue,
110+
options?: {temporaryReferences?: TemporaryReferenceSet},
100111
): Promise<
101112
string | URLSearchParams | FormData,
102113
> /* We don't use URLSearchParams yet but maybe */ {
103114
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+
);
105124
});
106125
}
107126

packages/react-server-dom-esm/src/ReactFlightDOMClientNode.js

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function createFromNodeStream<T>(
6060
noServerCall,
6161
options ? options.encodeFormAction : undefined,
6262
options && typeof options.nonce === 'string' ? options.nonce : undefined,
63+
undefined, // TODO: If encodeReply is supported, this should support temporaryReferences
6364
);
6465
stream.on('data', chunk => {
6566
processBinaryChunk(response, chunk);

0 commit comments

Comments
 (0)