@samitouri / QOS-React / commits / 38d9f156b8

[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 }