[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.
Sebastian Markbåge committed
May 9, 2024 at 19:24 UTC
38d9f156b898c9b0f7e73eeec7c2073927fd71d8
3 files changed
+110
-26
packages/react-client/src/ReactFlightReplyClient.js
+47
-12
@@ -176,6 +176,8 @@ function escapeStringValue(value: string): string {
176
}
177
}
178
179
+interface Reference {}
180
+
181
export function processReply(
182
root: ReactServerValue,
183
formFieldPrefix: string,
@@ -186,6 +188,8 @@ export function processReply(
188
let nextPartId = 1;
189
let pendingParts = 0;
190
let formData: null | FormData = null;
191
+ const writtenObjects: WeakMap<Reference, string> = new WeakMap();
192
+ let modelRoot: null | ReactServerValue = root;
193
194
function serializeTypedArray(
195
tag: string,
@@ -427,7 +431,7 @@ export function processReply(
431
// We always outline this as a separate part even though we could inline it
432
// because it ensures a more deterministic encoding.
433
const lazyId = nextPartId++;
430
- const partJSON = JSON.stringify(resolvedModel, resolveToJSON);
434
+ const partJSON = serializeModel(resolvedModel, lazyId);
435
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
436
const data: FormData = formData;
437
// eslint-disable-next-line react-internal/safe-string-coercion
@@ -447,7 +451,7 @@ export function processReply(
451
// While the first promise resolved, its value isn't necessarily what we'll
452
// resolve into because we might suspend again.
453
try {
450
- const partJSON = JSON.stringify(value, resolveToJSON);
454
+ const partJSON = serializeModel(value, lazyId);
455
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
456
const data: FormData = formData;
457
// eslint-disable-next-line react-internal/safe-string-coercion
@@ -488,7 +492,7 @@ export function processReply(
492
thenable.then(
493
partValue => {
494
try {
491
- const partJSON = JSON.stringify(partValue, resolveToJSON);
495
+ const partJSON = serializeModel(partValue, promiseId);
496
// $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
497
const data: FormData = formData;
498
// eslint-disable-next-line react-internal/safe-string-coercion
@@ -507,6 +511,28 @@ export function processReply(
511
);
512
return serializePromiseID(promiseId);
513
}
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
+
536
if (isArray(value)) {
537
// $FlowFixMe[incompatible-return]
538
return value;
@@ -530,20 +556,20 @@ export function processReply(
556
return serializeFormDataReference(refId);
557
}
558
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);
561
if (formData === null) {
562
formData = new FormData();
563
}
537
- const mapId = nextPartId++;
564
formData.append(formFieldPrefix + mapId, partJSON);
565
return serializeMapID(mapId);
566
}
567
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);
570
if (formData === null) {
571
formData = new FormData();
572
}
546
- const setId = nextPartId++;
573
formData.append(formFieldPrefix + setId, partJSON);
574
return serializeSetID(setId);
575
}
@@ -622,14 +648,14 @@ export function processReply(
648
const iterator = iteratorFn.call(value);
649
if (iterator === value) {
650
// Iterator, not Iterable
625
- const partJSON = JSON.stringify(
651
+ const iteratorId = nextPartId++;
652
+ const partJSON = serializeModel(
653
Array.from((iterator: any)),
627
- resolveToJSON,
654
+ iteratorId,
655
);
656
if (formData === null) {
657
formData = new FormData();
658
}
632
- const iteratorId = nextPartId++;
659
formData.append(formFieldPrefix + iteratorId, partJSON);
660
return serializeIteratorID(iteratorId);
661
}
@@ -784,8 +810,17 @@ export function processReply(
810
);
811
}
812
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
+
824
if (formData === null) {
825
// If it's a simple data structure, we just use plain JSON.
826
resolve(json);
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReply-test.js
+9
@@ -537,4 +537,13 @@ describe('ReactFlightDOMReply', () => {
537
'Values cannot be passed to next() of AsyncIterables passed to Client Components.',
538
);
539
});
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
+ });
549
});
packages/react-server/src/ReactFlightReplyServer.js
+54
-14
@@ -47,6 +47,7 @@ export type JSONValue =
47
48
const PENDING = 'pending';
49
const BLOCKED = 'blocked';
50
+const CYCLIC = 'cyclic';
51
const RESOLVED_MODEL = 'resolved_model';
52
const INITIALIZED = 'fulfilled';
53
const ERRORED = 'rejected';
@@ -65,6 +66,13 @@ type BlockedChunk<T> = {
66
_response: Response,
67
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
68
};
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
+};
76
type ResolvedModelChunk<T> = {
77
status: 'resolved_model',
78
value: string,
@@ -98,6 +106,7 @@ type ErroredChunk<T> = {
106
type SomeChunk<T> =
107
| PendingChunk<T>
108
| BlockedChunk<T>
109
+ | CyclicChunk<T>
110
| ResolvedModelChunk<T>
111
| InitializedChunk<T>
112
| ErroredChunk<T>;
@@ -132,6 +141,7 @@ Chunk.prototype.then = function <T>(
141
break;
142
case PENDING:
143
case BLOCKED:
144
+ case CYCLIC:
145
if (resolve) {
146
if (chunk.value === null) {
147
chunk.value = ([]: Array<(T) => mixed>);
@@ -187,6 +197,7 @@ function wakeChunkIfInitialized<T>(
197
break;
198
case PENDING:
199
case BLOCKED:
200
+ case CYCLIC:
201
chunk.value = resolveListeners;
202
chunk.reason = rejectListeners;
203
break;
@@ -334,6 +345,7 @@ function loadServerReference<T>(
345
false,
346
response,
347
createModel,
348
+ [],
349
),
350
createModelReject(parentChunk),
351
);
@@ -348,8 +360,19 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
360
const prevBlocked = initializingChunkBlockedModel;
361
initializingChunk = chunk;
362
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
+
374
try {
352
- const value: T = JSON.parse(chunk.value, chunk._response._fromJSON);
375
+ const value: T = JSON.parse(resolvedModel, chunk._response._fromJSON);
376
if (
377
initializingChunkBlockedModel !== null &&
378
initializingChunkBlockedModel.deps > 0
@@ -362,9 +385,13 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
385
blockedChunk.value = null;
386
blockedChunk.reason = null;
387
} else {
388
+ const resolveListeners = cyclicChunk.value;
389
const initializedChunk: InitializedChunk<T> = (chunk: any);
390
initializedChunk.status = INITIALIZED;
391
initializedChunk.value = value;
392
+ if (resolveListeners !== null) {
393
+ wakeChunk(resolveListeners, value);
394
+ }
395
}
396
} catch (error) {
397
const erroredChunk: ErroredChunk<T> = (chunk: any);
@@ -416,6 +443,7 @@ function createModelResolver<T>(
443
cyclic: boolean,
444
response: Response,
445
map: (response: Response, model: any) => T,
446
+ path: Array<string>,
447
): (value: any) => void {
448
let blocked;
449
if (initializingChunkBlockedModel) {
@@ -430,6 +458,9 @@ function createModelResolver<T>(
458
};
459
}
460
return value => {
461
+ for (let i = 1; i < path.length; i++) {
462
+ value = value[path[i]];
463
+ }
464
parentObject[key] = map(response, value);
465
466
// 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 {
491
492
function getOutlinedModel<T>(
493
response: Response,
463
- id: number,
494
+ reference: string,
495
parentObject: Object,
496
key: string,
497
map: (response: Response, model: any) => T,
498
): T {
499
+ const path = reference.split(':');
500
+ const id = parseInt(path[0], 16);
501
const chunk = getChunk(response, id);
502
switch (chunk.status) {
503
case RESOLVED_MODEL:
@@ -474,18 +507,24 @@ function getOutlinedModel<T>(
507
// The status might have changed after initialization.
508
switch (chunk.status) {
509
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);
515
case PENDING:
516
case BLOCKED:
517
+ case CYCLIC:
518
const parentChunk = initializingChunk;
519
chunk.then(
520
createModelResolver(
521
parentChunk,
522
parentObject,
523
key,
486
- false,
524
+ chunk.status === CYCLIC,
525
response,
526
map,
527
+ path,
528
),
529
createModelReject(parentChunk),
530
);
@@ -548,6 +587,7 @@ function parseTypedArray(
587
false,
588
response,
589
createModel,
590
+ [],
591
),
592
createModelReject(parentChunk),
593
);
@@ -789,10 +829,10 @@ function parseModelString(
829
}
830
case 'F': {
831
// Server Reference
792
- const id = parseInt(value.slice(2), 16);
832
+ const ref = value.slice(2);
833
// TODO: Just encode this in the reference inline instead of as a model.
834
const metaData: {id: ServerReferenceId, bound: Thenable<Array<any>>} =
795
- getOutlinedModel(response, id, obj, key, createModel);
835
+ getOutlinedModel(response, ref, obj, key, createModel);
836
return loadServerReference(
837
response,
838
metaData.id,
@@ -808,13 +848,13 @@ function parseModelString(
848
}
849
case 'Q': {
850
// 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);
853
}
854
case 'W': {
855
// 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);
858
}
859
case 'K': {
860
// FormData
@@ -835,8 +875,8 @@ function parseModelString(
875
}
876
case 'i': {
877
// 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);
880
}
881
case 'I': {
882
// $Infinity
@@ -933,8 +973,8 @@ function parseModelString(
973
}
974
975
// 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);
978
}
979
return value;
980
}