Skip to content

Commit 6c409ac

Browse files
authored
[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.
1 parent 38d9f15 commit 6c409ac

16 files changed

+492
-118
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -915,15 +915,15 @@ function parseModelString(
915915
}
916916
case 'T': {
917917
// Temporary Reference
918-
const id = parseInt(value.slice(2), 16);
918+
const reference = '$' + value.slice(2);
919919
const temporaryReferences = response._tempRefs;
920920
if (temporaryReferences == null) {
921921
throw new Error(
922922
'Missing a temporary reference set but the RSC response returned a temporary reference. ' +
923923
'Pass a temporaryReference option with the set that was used with the reply.',
924924
);
925925
}
926-
return readTemporaryReference(temporaryReferences, id);
926+
return readTemporaryReference(temporaryReferences, reference);
927927
}
928928
case 'Q': {
929929
// Map

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ function serializeServerReferenceID(id: number): string {
109109
return '$F' + id.toString(16);
110110
}
111111

112-
function serializeTemporaryReferenceID(id: number): string {
113-
return '$T' + id.toString(16);
112+
function serializeTemporaryReferenceMarker(): string {
113+
return '$T';
114114
}
115115

116116
function serializeFormDataReference(id: number): string {
@@ -405,15 +405,22 @@ export function processReply(
405405
if (typeof value === 'object') {
406406
switch ((value: any).$$typeof) {
407407
case REACT_ELEMENT_TYPE: {
408-
if (temporaryReferences === undefined) {
409-
throw new Error(
410-
'React Element cannot be passed to Server Functions from the Client without a ' +
411-
'temporary reference set. Pass a TemporaryReferenceSet to the options.' +
412-
(__DEV__ ? describeObjectForErrorMessage(parent, key) : ''),
413-
);
408+
if (temporaryReferences !== undefined && key.indexOf(':') === -1) {
409+
// TODO: If the property name contains a colon, we don't dedupe. Escape instead.
410+
const parentReference = writtenObjects.get(parent);
411+
if (parentReference !== undefined) {
412+
// If the parent has a reference, we can refer to this object indirectly
413+
// through the property name inside that parent.
414+
const reference = parentReference + ':' + key;
415+
// Store this object so that the server can refer to it later in responses.
416+
writeTemporaryReference(temporaryReferences, reference, value);
417+
return serializeTemporaryReferenceMarker();
418+
}
414419
}
415-
return serializeTemporaryReferenceID(
416-
writeTemporaryReference(temporaryReferences, value),
420+
throw new Error(
421+
'React Element cannot be passed to Server Functions from the Client without a ' +
422+
'temporary reference set. Pass a TemporaryReferenceSet to the options.' +
423+
(__DEV__ ? describeObjectForErrorMessage(parent, key) : ''),
417424
);
418425
}
419426
case REACT_LAZY_TYPE: {
@@ -529,7 +536,12 @@ export function processReply(
529536
if (parentReference !== undefined) {
530537
// If the parent has a reference, we can refer to this object indirectly
531538
// through the property name inside that parent.
532-
writtenObjects.set(value, parentReference + ':' + key);
539+
const reference = parentReference + ':' + key;
540+
writtenObjects.set(value, reference);
541+
if (temporaryReferences !== undefined) {
542+
// Store this object so that the server can refer to it later in responses.
543+
writeTemporaryReference(temporaryReferences, reference, value);
544+
}
533545
}
534546
}
535547

@@ -693,10 +705,9 @@ export function processReply(
693705
'Classes or null prototypes are not supported.',
694706
);
695707
}
696-
// We can serialize class instances as temporary references.
697-
return serializeTemporaryReferenceID(
698-
writeTemporaryReference(temporaryReferences, value),
699-
);
708+
// We will have written this object to the temporary reference set above
709+
// so we can replace it with a marker to refer to this slot later.
710+
return serializeTemporaryReferenceMarker();
700711
}
701712
if (__DEV__) {
702713
if (
@@ -777,27 +788,41 @@ export function processReply(
777788
formData.set(formFieldPrefix + refId, metaDataJSON);
778789
return serializeServerReferenceID(refId);
779790
}
780-
if (temporaryReferences === undefined) {
781-
throw new Error(
782-
'Client Functions cannot be passed directly to Server Functions. ' +
783-
'Only Functions passed from the Server can be passed back again.',
784-
);
791+
if (temporaryReferences !== undefined && key.indexOf(':') === -1) {
792+
// TODO: If the property name contains a colon, we don't dedupe. Escape instead.
793+
const parentReference = writtenObjects.get(parent);
794+
if (parentReference !== undefined) {
795+
// If the parent has a reference, we can refer to this object indirectly
796+
// through the property name inside that parent.
797+
const reference = parentReference + ':' + key;
798+
// Store this object so that the server can refer to it later in responses.
799+
writeTemporaryReference(temporaryReferences, reference, value);
800+
return serializeTemporaryReferenceMarker();
801+
}
785802
}
786-
return serializeTemporaryReferenceID(
787-
writeTemporaryReference(temporaryReferences, value),
803+
throw new Error(
804+
'Client Functions cannot be passed directly to Server Functions. ' +
805+
'Only Functions passed from the Server can be passed back again.',
788806
);
789807
}
790808

791809
if (typeof value === 'symbol') {
792-
if (temporaryReferences === undefined) {
793-
throw new Error(
794-
'Symbols cannot be passed to a Server Function without a ' +
795-
'temporary reference set. Pass a TemporaryReferenceSet to the options.' +
796-
(__DEV__ ? describeObjectForErrorMessage(parent, key) : ''),
797-
);
810+
if (temporaryReferences !== undefined && key.indexOf(':') === -1) {
811+
// TODO: If the property name contains a colon, we don't dedupe. Escape instead.
812+
const parentReference = writtenObjects.get(parent);
813+
if (parentReference !== undefined) {
814+
// If the parent has a reference, we can refer to this object indirectly
815+
// through the property name inside that parent.
816+
const reference = parentReference + ':' + key;
817+
// Store this object so that the server can refer to it later in responses.
818+
writeTemporaryReference(temporaryReferences, reference, value);
819+
return serializeTemporaryReferenceMarker();
820+
}
798821
}
799-
return serializeTemporaryReferenceID(
800-
writeTemporaryReference(temporaryReferences, value),
822+
throw new Error(
823+
'Symbols cannot be passed to a Server Function without a ' +
824+
'temporary reference set. Pass a TemporaryReferenceSet to the options.' +
825+
(__DEV__ ? describeObjectForErrorMessage(parent, key) : ''),
801826
);
802827
}
803828

@@ -812,7 +837,12 @@ export function processReply(
812837

813838
function serializeModel(model: ReactServerValue, id: number): string {
814839
if (typeof model === 'object' && model !== null) {
815-
writtenObjects.set(model, serializeByValueID(id));
840+
const reference = serializeByValueID(id);
841+
writtenObjects.set(model, reference);
842+
if (temporaryReferences !== undefined) {
843+
// Store this object so that the server can refer to it later in responses.
844+
writeTemporaryReference(temporaryReferences, reference, model);
845+
}
816846
}
817847
modelRoot = model;
818848
// $FlowFixMe[incompatible-return] it's not going to be undefined because we'll encode it.

packages/react-client/src/ReactFlightTemporaryReferences.js

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,23 @@
99

1010
interface Reference {}
1111

12-
export opaque type TemporaryReferenceSet = Array<Reference | symbol>;
12+
export opaque type TemporaryReferenceSet = Map<string, Reference | symbol>;
1313

1414
export function createTemporaryReferenceSet(): TemporaryReferenceSet {
15-
return [];
15+
return new Map();
1616
}
1717

1818
export function writeTemporaryReference(
1919
set: TemporaryReferenceSet,
20+
reference: string,
2021
object: Reference | symbol,
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;
22+
): void {
23+
set.set(reference, object);
2824
}
2925

3026
export function readTemporaryReference<T>(
3127
set: TemporaryReferenceSet,
32-
id: number,
28+
reference: string,
3329
): T {
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]: any);
30+
return (set.get(reference): any);
4131
}

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,30 @@ export {
4747
registerClientReference,
4848
} from './ReactFlightESMReferences';
4949

50+
import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
51+
52+
export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
53+
54+
export type {TemporaryReferenceSet};
55+
5056
function createDrainHandler(destination: Destination, request: Request) {
5157
return () => startFlowing(request, destination);
5258
}
5359

60+
function createCancelHandler(request: Request, reason: string) {
61+
return () => {
62+
stopFlowing(request);
63+
// eslint-disable-next-line react-internal/prod-error-codes
64+
abort(request, new Error(reason));
65+
};
66+
}
67+
5468
type Options = {
5569
environmentName?: string,
5670
onError?: (error: mixed) => void,
5771
onPostpone?: (reason: string) => void,
5872
identifierPrefix?: string,
73+
temporaryReferences?: TemporaryReferenceSet,
5974
};
6075

6176
type PipeableStream = {
@@ -75,6 +90,7 @@ function renderToPipeableStream(
7590
options ? options.identifierPrefix : undefined,
7691
options ? options.onPostpone : undefined,
7792
options ? options.environmentName : undefined,
93+
options ? options.temporaryReferences : undefined,
7894
);
7995
let hasStartedFlowing = false;
8096
startWork(request);
@@ -88,10 +104,20 @@ function renderToPipeableStream(
88104
hasStartedFlowing = true;
89105
startFlowing(request, destination);
90106
destination.on('drain', createDrainHandler(destination, request));
107+
destination.on(
108+
'error',
109+
createCancelHandler(
110+
request,
111+
'The destination stream errored while writing data.',
112+
),
113+
);
114+
destination.on(
115+
'close',
116+
createCancelHandler(request, 'The destination stream closed early.'),
117+
);
91118
return destination;
92119
},
93120
abort(reason: mixed) {
94-
stopFlowing(request);
95121
abort(request, reason);
96122
},
97123
};
@@ -155,13 +181,19 @@ function decodeReplyFromBusboy<T>(
155181
function decodeReply<T>(
156182
body: string | FormData,
157183
moduleBasePath: ServerManifest,
184+
options?: {temporaryReferences?: TemporaryReferenceSet},
158185
): Thenable<T> {
159186
if (typeof body === 'string') {
160187
const form = new FormData();
161188
form.append('0', body);
162189
body = form;
163190
}
164-
const response = createResponse(moduleBasePath, '', body);
191+
const response = createResponse(
192+
moduleBasePath,
193+
'',
194+
options ? options.temporaryReferences : undefined,
195+
body,
196+
);
165197
const root = getRoot<T>(response);
166198
close(response);
167199
return root;

packages/react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
createRequest,
1717
startWork,
1818
startFlowing,
19+
stopFlowing,
1920
abort,
2021
} from 'react-server/src/ReactFlightServer';
2122

@@ -25,18 +26,28 @@ import {
2526
getRoot,
2627
} from 'react-server/src/ReactFlightReplyServer';
2728

28-
import {decodeAction} from 'react-server/src/ReactFlightActionServer';
29+
import {
30+
decodeAction,
31+
decodeFormState,
32+
} from 'react-server/src/ReactFlightActionServer';
2933

3034
export {
3135
registerServerReference,
3236
registerClientReference,
3337
createClientModuleProxy,
3438
} from './ReactFlightTurbopackReferences';
3539

40+
import type {TemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
41+
42+
export {createTemporaryReferenceSet} from 'react-server/src/ReactFlightServerTemporaryReferences';
43+
44+
export type {TemporaryReferenceSet};
45+
3646
type Options = {
3747
environmentName?: string,
3848
identifierPrefix?: string,
3949
signal?: AbortSignal,
50+
temporaryReferences?: TemporaryReferenceSet,
4051
onError?: (error: mixed) => void,
4152
onPostpone?: (reason: string) => void,
4253
};
@@ -53,6 +64,7 @@ function renderToReadableStream(
5364
options ? options.identifierPrefix : undefined,
5465
options ? options.onPostpone : undefined,
5566
options ? options.environmentName : undefined,
67+
options ? options.temporaryReferences : undefined,
5668
);
5769
if (options && options.signal) {
5870
const signal = options.signal;
@@ -75,7 +87,10 @@ function renderToReadableStream(
7587
pull: (controller): ?Promise<void> => {
7688
startFlowing(request, controller);
7789
},
78-
cancel: (reason): ?Promise<void> => {},
90+
cancel: (reason): ?Promise<void> => {
91+
stopFlowing(request);
92+
abort(request, reason);
93+
},
7994
},
8095
// $FlowFixMe[prop-missing] size() methods are not allowed on byte streams.
8196
{highWaterMark: 0},
@@ -86,16 +101,22 @@ function renderToReadableStream(
86101
function decodeReply<T>(
87102
body: string | FormData,
88103
turbopackMap: ServerManifest,
104+
options?: {temporaryReferences?: TemporaryReferenceSet},
89105
): Thenable<T> {
90106
if (typeof body === 'string') {
91107
const form = new FormData();
92108
form.append('0', body);
93109
body = form;
94110
}
95-
const response = createResponse(turbopackMap, '', body);
111+
const response = createResponse(
112+
turbopackMap,
113+
'',
114+
options ? options.temporaryReferences : undefined,
115+
body,
116+
);
96117
const root = getRoot<T>(response);
97118
close(response);
98119
return root;
99120
}
100121

101-
export {renderToReadableStream, decodeReply, decodeAction};
122+
export {renderToReadableStream, decodeReply, decodeAction, decodeFormState};

0 commit comments

Comments
 (0)