Skip to content

Commit 38d9f15

Browse files
authored
[Flight Reply] Dedupe Objects and Support Cyclic References (#28997)
Uses the same technique as in #28996 to encode references to already emitted objects. This now means that Reply can support cyclic objects too for parity.
1 parent 7a78d03 commit 38d9f15

File tree

3 files changed

+110
-26
lines changed

3 files changed

+110
-26
lines changed

packages/react-client/src/ReactFlightReplyClient.js

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ function escapeStringValue(value: string): string {
176176
}
177177
}
178178

179+
interface Reference {}
180+
179181
export function processReply(
180182
root: ReactServerValue,
181183
formFieldPrefix: string,
@@ -186,6 +188,8 @@ export function processReply(
186188
let nextPartId = 1;
187189
let pendingParts = 0;
188190
let formData: null | FormData = null;
191+
const writtenObjects: WeakMap<Reference, string> = new WeakMap();
192+
let modelRoot: null | ReactServerValue = root;
189193

190194
function serializeTypedArray(
191195
tag: string,
@@ -427,7 +431,7 @@ export function processReply(
427431
// We always outline this as a separate part even though we could inline it
428432
// because it ensures a more deterministic encoding.
429433
const lazyId = nextPartId++;
430-
const partJSON = JSON.stringify(resolvedModel, resolveToJSON);
434+
const partJSON = serializeModel(resolvedModel, lazyId);
431435
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
432436
const data: FormData = formData;
433437
// eslint-disable-next-line react-internal/safe-string-coercion
@@ -447,7 +451,7 @@ export function processReply(
447451
// While the first promise resolved, its value isn't necessarily what we'll
448452
// resolve into because we might suspend again.
449453
try {
450-
const partJSON = JSON.stringify(value, resolveToJSON);
454+
const partJSON = serializeModel(value, lazyId);
451455
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
452456
const data: FormData = formData;
453457
// eslint-disable-next-line react-internal/safe-string-coercion
@@ -488,7 +492,7 @@ export function processReply(
488492
thenable.then(
489493
partValue => {
490494
try {
491-
const partJSON = JSON.stringify(partValue, resolveToJSON);
495+
const partJSON = serializeModel(partValue, promiseId);
492496
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
493497
const data: FormData = formData;
494498
// eslint-disable-next-line react-internal/safe-string-coercion
@@ -507,6 +511,28 @@ export function processReply(
507511
);
508512
return serializePromiseID(promiseId);
509513
}
514+
515+
const existingReference = writtenObjects.get(value);
516+
if (existingReference !== undefined) {
517+
if (modelRoot === value) {
518+
// This is the ID we're currently emitting so we need to write it
519+
// once but if we discover it again, we refer to it by id.
520+
modelRoot = null;
521+
} else {
522+
// We've already emitted this as an outlined object, so we can
523+
// just refer to that by its existing ID.
524+
return existingReference;
525+
}
526+
} else if (key.indexOf(':') === -1) {
527+
// TODO: If the property name contains a colon, we don't dedupe. Escape instead.
528+
const parentReference = writtenObjects.get(parent);
529+
if (parentReference !== undefined) {
530+
// If the parent has a reference, we can refer to this object indirectly
531+
// through the property name inside that parent.
532+
writtenObjects.set(value, parentReference + ':' + key);
533+
}
534+
}
535+
510536
if (isArray(value)) {
511537
// $FlowFixMe[incompatible-return]
512538
return value;
@@ -530,20 +556,20 @@ export function processReply(
530556
return serializeFormDataReference(refId);
531557
}
532558
if (value instanceof Map) {
533-
const partJSON = JSON.stringify(Array.from(value), resolveToJSON);
559+
const mapId = nextPartId++;
560+
const partJSON = serializeModel(Array.from(value), mapId);
534561
if (formData === null) {
535562
formData = new FormData();
536563
}
537-
const mapId = nextPartId++;
538564
formData.append(formFieldPrefix + mapId, partJSON);
539565
return serializeMapID(mapId);
540566
}
541567
if (value instanceof Set) {
542-
const partJSON = JSON.stringify(Array.from(value), resolveToJSON);
568+
const setId = nextPartId++;
569+
const partJSON = serializeModel(Array.from(value), setId);
543570
if (formData === null) {
544571
formData = new FormData();
545572
}
546-
const setId = nextPartId++;
547573
formData.append(formFieldPrefix + setId, partJSON);
548574
return serializeSetID(setId);
549575
}
@@ -622,14 +648,14 @@ export function processReply(
622648
const iterator = iteratorFn.call(value);
623649
if (iterator === value) {
624650
// Iterator, not Iterable
625-
const partJSON = JSON.stringify(
651+
const iteratorId = nextPartId++;
652+
const partJSON = serializeModel(
626653
Array.from((iterator: any)),
627-
resolveToJSON,
654+
iteratorId,
628655
);
629656
if (formData === null) {
630657
formData = new FormData();
631658
}
632-
const iteratorId = nextPartId++;
633659
formData.append(formFieldPrefix + iteratorId, partJSON);
634660
return serializeIteratorID(iteratorId);
635661
}
@@ -784,8 +810,17 @@ export function processReply(
784810
);
785811
}
786812

787-
// $FlowFixMe[incompatible-type] it's not going to be undefined because we'll encode it.
788-
const json: string = JSON.stringify(root, resolveToJSON);
813+
function serializeModel(model: ReactServerValue, id: number): string {
814+
if (typeof model === 'object' && model !== null) {
815+
writtenObjects.set(model, serializeByValueID(id));
816+
}
817+
modelRoot = model;
818+
// $FlowFixMe[incompatible-return] it's not going to be undefined because we'll encode it.
819+
return JSON.stringify(model, resolveToJSON);
820+
}
821+
822+
const json = serializeModel(root, 0);
823+
789824
if (formData === null) {
790825
// If it's a simple data structure, we just use plain JSON.
791826
resolve(json);

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,13 @@ describe('ReactFlightDOMReply', () => {
537537
'Values cannot be passed to next() of AsyncIterables passed to Client Components.',
538538
);
539539
});
540+
541+
it('can transport cyclic objects', async () => {
542+
const cyclic = {obj: null};
543+
cyclic.obj = cyclic;
544+
545+
const body = await ReactServerDOMClient.encodeReply({prop: cyclic});
546+
const root = await ReactServerDOMServer.decodeReply(body, webpackServerMap);
547+
expect(root.prop.obj).toBe(root.prop);
548+
});
540549
});

packages/react-server/src/ReactFlightReplyServer.js

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export type JSONValue =
4747

4848
const PENDING = 'pending';
4949
const BLOCKED = 'blocked';
50+
const CYCLIC = 'cyclic';
5051
const RESOLVED_MODEL = 'resolved_model';
5152
const INITIALIZED = 'fulfilled';
5253
const ERRORED = 'rejected';
@@ -65,6 +66,13 @@ type BlockedChunk<T> = {
6566
_response: Response,
6667
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
6768
};
69+
type CyclicChunk<T> = {
70+
status: 'cyclic',
71+
value: null | Array<(T) => mixed>,
72+
reason: null | Array<(mixed) => mixed>,
73+
_response: Response,
74+
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
75+
};
6876
type ResolvedModelChunk<T> = {
6977
status: 'resolved_model',
7078
value: string,
@@ -98,6 +106,7 @@ type ErroredChunk<T> = {
98106
type SomeChunk<T> =
99107
| PendingChunk<T>
100108
| BlockedChunk<T>
109+
| CyclicChunk<T>
101110
| ResolvedModelChunk<T>
102111
| InitializedChunk<T>
103112
| ErroredChunk<T>;
@@ -132,6 +141,7 @@ Chunk.prototype.then = function <T>(
132141
break;
133142
case PENDING:
134143
case BLOCKED:
144+
case CYCLIC:
135145
if (resolve) {
136146
if (chunk.value === null) {
137147
chunk.value = ([]: Array<(T) => mixed>);
@@ -187,6 +197,7 @@ function wakeChunkIfInitialized<T>(
187197
break;
188198
case PENDING:
189199
case BLOCKED:
200+
case CYCLIC:
190201
chunk.value = resolveListeners;
191202
chunk.reason = rejectListeners;
192203
break;
@@ -334,6 +345,7 @@ function loadServerReference<T>(
334345
false,
335346
response,
336347
createModel,
348+
[],
337349
),
338350
createModelReject(parentChunk),
339351
);
@@ -348,8 +360,19 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
348360
const prevBlocked = initializingChunkBlockedModel;
349361
initializingChunk = chunk;
350362
initializingChunkBlockedModel = null;
363+
364+
const resolvedModel = chunk.value;
365+
366+
// We go to the CYCLIC state until we've fully resolved this.
367+
// We do this before parsing in case we try to initialize the same chunk
368+
// while parsing the model. Such as in a cyclic reference.
369+
const cyclicChunk: CyclicChunk<T> = (chunk: any);
370+
cyclicChunk.status = CYCLIC;
371+
cyclicChunk.value = null;
372+
cyclicChunk.reason = null;
373+
351374
try {
352-
const value: T = JSON.parse(chunk.value, chunk._response._fromJSON);
375+
const value: T = JSON.parse(resolvedModel, chunk._response._fromJSON);
353376
if (
354377
initializingChunkBlockedModel !== null &&
355378
initializingChunkBlockedModel.deps > 0
@@ -362,9 +385,13 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
362385
blockedChunk.value = null;
363386
blockedChunk.reason = null;
364387
} else {
388+
const resolveListeners = cyclicChunk.value;
365389
const initializedChunk: InitializedChunk<T> = (chunk: any);
366390
initializedChunk.status = INITIALIZED;
367391
initializedChunk.value = value;
392+
if (resolveListeners !== null) {
393+
wakeChunk(resolveListeners, value);
394+
}
368395
}
369396
} catch (error) {
370397
const erroredChunk: ErroredChunk<T> = (chunk: any);
@@ -416,6 +443,7 @@ function createModelResolver<T>(
416443
cyclic: boolean,
417444
response: Response,
418445
map: (response: Response, model: any) => T,
446+
path: Array<string>,
419447
): (value: any) => void {
420448
let blocked;
421449
if (initializingChunkBlockedModel) {
@@ -430,6 +458,9 @@ function createModelResolver<T>(
430458
};
431459
}
432460
return value => {
461+
for (let i = 1; i < path.length; i++) {
462+
value = value[path[i]];
463+
}
433464
parentObject[key] = map(response, value);
434465

435466
// If this is the root object for a model reference, where `blocked.value`
@@ -460,11 +491,13 @@ function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {
460491

461492
function getOutlinedModel<T>(
462493
response: Response,
463-
id: number,
494+
reference: string,
464495
parentObject: Object,
465496
key: string,
466497
map: (response: Response, model: any) => T,
467498
): T {
499+
const path = reference.split(':');
500+
const id = parseInt(path[0], 16);
468501
const chunk = getChunk(response, id);
469502
switch (chunk.status) {
470503
case RESOLVED_MODEL:
@@ -474,18 +507,24 @@ function getOutlinedModel<T>(
474507
// The status might have changed after initialization.
475508
switch (chunk.status) {
476509
case INITIALIZED:
477-
return map(response, chunk.value);
510+
let value = chunk.value;
511+
for (let i = 1; i < path.length; i++) {
512+
value = value[path[i]];
513+
}
514+
return map(response, value);
478515
case PENDING:
479516
case BLOCKED:
517+
case CYCLIC:
480518
const parentChunk = initializingChunk;
481519
chunk.then(
482520
createModelResolver(
483521
parentChunk,
484522
parentObject,
485523
key,
486-
false,
524+
chunk.status === CYCLIC,
487525
response,
488526
map,
527+
path,
489528
),
490529
createModelReject(parentChunk),
491530
);
@@ -548,6 +587,7 @@ function parseTypedArray(
548587
false,
549588
response,
550589
createModel,
590+
[],
551591
),
552592
createModelReject(parentChunk),
553593
);
@@ -789,10 +829,10 @@ function parseModelString(
789829
}
790830
case 'F': {
791831
// Server Reference
792-
const id = parseInt(value.slice(2), 16);
832+
const ref = value.slice(2);
793833
// TODO: Just encode this in the reference inline instead of as a model.
794834
const metaData: {id: ServerReferenceId, bound: Thenable<Array<any>>} =
795-
getOutlinedModel(response, id, obj, key, createModel);
835+
getOutlinedModel(response, ref, obj, key, createModel);
796836
return loadServerReference(
797837
response,
798838
metaData.id,
@@ -808,13 +848,13 @@ function parseModelString(
808848
}
809849
case 'Q': {
810850
// Map
811-
const id = parseInt(value.slice(2), 16);
812-
return getOutlinedModel(response, id, obj, key, createMap);
851+
const ref = value.slice(2);
852+
return getOutlinedModel(response, ref, obj, key, createMap);
813853
}
814854
case 'W': {
815855
// Set
816-
const id = parseInt(value.slice(2), 16);
817-
return getOutlinedModel(response, id, obj, key, createSet);
856+
const ref = value.slice(2);
857+
return getOutlinedModel(response, ref, obj, key, createSet);
818858
}
819859
case 'K': {
820860
// FormData
@@ -835,8 +875,8 @@ function parseModelString(
835875
}
836876
case 'i': {
837877
// Iterator
838-
const id = parseInt(value.slice(2), 16);
839-
return getOutlinedModel(response, id, obj, key, extractIterator);
878+
const ref = value.slice(2);
879+
return getOutlinedModel(response, ref, obj, key, extractIterator);
840880
}
841881
case 'I': {
842882
// $Infinity
@@ -933,8 +973,8 @@ function parseModelString(
933973
}
934974

935975
// We assume that anything else is a reference ID.
936-
const id = parseInt(value.slice(1), 16);
937-
return getOutlinedModel(response, id, obj, key, createModel);
976+
const ref = value.slice(1);
977+
return getOutlinedModel(response, ref, obj, key, createModel);
938978
}
939979
return value;
940980
}

0 commit comments

Comments
 (0)