@samitouri / QOS-React-1 / commits / f172fa7461

[Flight] Detriplicate Objects (#27537)

Now that we no longer support Server Context, we can now deduplicate objects. It's not completely safe for useId but only in the same way as it's not safe if you reuse elements on the client, so it's not a new issue. This also solves cyclic object references. The issue is that we prefer to inline objects into a plain JSON format when an object is not going to get reused. In this case the object doesn't have an id. We could potentially serialize a reference to an existing model + a path to it but it bloats the format and complicates the client. In a smarter flush phase like we have in Fizz we could choose to inline or outline depending on what we've discovered so far before a flush. We can't do that here since we use native stringify. However, even in that solution you might not know that you're going to discover the same object later so it's not perfect deduping anyway. Instead, I use a heuristic where I mark previously seen objects and if I ever see that object again, then I'll outline it. The idea is that most objects are just going to be emitted once and if it's more than once it's fairly likely you have a shared reference to it somewhere and it might be more than two. The third object gets deduplicated (or "detriplicated"). It's not a perfect heuristic because when we write the second object we will have already visited all the nested objects inside of it, which causes us to outline every nested object too even those weren't reference more than by that parent. Not sure how to solve for that. If we for some other reason outline an object such as if it suspends, then it's truly deduplicated since it already has an id.

Sebastian Markbåge committed Oct 19, 2023 at 13:41 UTC f172fa74610df623f1e82997ba66337452deeaa1
4 files changed +282 -55
packages/react-client/src/ReactFlightClient.js
+41 -5
@@ -72,6 +72,7 @@ type RowParserState = 0 | 1 | 2 | 3 | 4;
72
73 const PENDING = 'pending';
74 const BLOCKED = 'blocked';
75 +const CYCLIC = 'cyclic';
76 const RESOLVED_MODEL = 'resolved_model';
77 const RESOLVED_MODULE = 'resolved_module';
78 const INITIALIZED = 'fulfilled';
@@ -91,6 +92,13 @@ type BlockedChunk<T> = {
92 _response: Response,
93 then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
94 };
95 +type CyclicChunk<T> = {
96 + status: 'cyclic',
97 + value: null | Array<(T) => mixed>,
98 + reason: null | Array<(mixed) => mixed>,
99 + _response: Response,
100 + then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
101 +};
102 type ResolvedModelChunk<T> = {
103 status: 'resolved_model',
104 value: UninitializedModel,
@@ -122,6 +130,7 @@ type ErroredChunk<T> = {
130 type SomeChunk<T> =
131 | PendingChunk<T>
132 | BlockedChunk<T>
133 + | CyclicChunk<T>
134 | ResolvedModelChunk<T>
135 | ResolvedModuleChunk<T>
136 | InitializedChunk<T>
@@ -160,6 +169,7 @@ Chunk.prototype.then = function <T>(
169 break;
170 case PENDING:
171 case BLOCKED:
172 + case CYCLIC:
173 if (resolve) {
174 if (chunk.value === null) {
175 chunk.value = ([]: Array<(T) => mixed>);
@@ -211,6 +221,7 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
221 return chunk.value;
222 case PENDING:
223 case BLOCKED:
224 + case CYCLIC:
225 // eslint-disable-next-line no-throw-literal
226 throw ((chunk: any): Thenable<T>);
227 default:
@@ -259,6 +270,7 @@ function wakeChunkIfInitialized<T>(
270 break;
271 case PENDING:
272 case BLOCKED:
273 + case CYCLIC:
274 chunk.value = resolveListeners;
275 chunk.reason = rejectListeners;
276 break;
@@ -365,8 +377,19 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
377 const prevBlocked = initializingChunkBlockedModel;
378 initializingChunk = chunk;
379 initializingChunkBlockedModel = null;
380 +
381 + const resolvedModel = chunk.value;
382 +
383 + // We go to the CYCLIC state until we've fully resolved this.
384 + // We do this before parsing in case we try to initialize the same chunk
385 + // while parsing the model. Such as in a cyclic reference.
386 + const cyclicChunk: CyclicChunk<T> = (chunk: any);
387 + cyclicChunk.status = CYCLIC;
388 + cyclicChunk.value = null;
389 + cyclicChunk.reason = null;
390 +
391 try {
369 - const value: T = parseModel(chunk._response, chunk.value);
392 + const value: T = parseModel(chunk._response, resolvedModel);
393 if (
394 initializingChunkBlockedModel !== null &&
395 initializingChunkBlockedModel.deps > 0
@@ -379,9 +402,13 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
402 blockedChunk.value = null;
403 blockedChunk.reason = null;
404 } else {
405 + const resolveListeners = cyclicChunk.value;
406 const initializedChunk: InitializedChunk<T> = (chunk: any);
407 initializedChunk.status = INITIALIZED;
408 initializedChunk.value = value;
409 + if (resolveListeners !== null) {
410 + wakeChunk(resolveListeners, value);
411 + }
412 }
413 } catch (error) {
414 const erroredChunk: ErroredChunk<T> = (chunk: any);
@@ -491,15 +518,18 @@ function createModelResolver<T>(
518 chunk: SomeChunk<T>,
519 parentObject: Object,
520 key: string,
521 + cyclic: boolean,
522 ): (value: any) => void {
523 let blocked;
524 if (initializingChunkBlockedModel) {
525 blocked = initializingChunkBlockedModel;
498 - blocked.deps++;
526 + if (!cyclic) {
527 + blocked.deps++;
528 + }
529 } else {
530 blocked = initializingChunkBlockedModel = {
501 - deps: 1,
502 - value: null,
531 + deps: cyclic ? 0 : 1,
532 + value: (null: any),
533 };
534 }
535 return value => {
@@ -673,9 +703,15 @@ function parseModelString(
703 return chunk.value;
704 case PENDING:
705 case BLOCKED:
706 + case CYCLIC:
707 const parentChunk = initializingChunk;
708 chunk.then(
678 - createModelResolver(parentChunk, parentObject, key),
709 + createModelResolver(
710 + parentChunk,
711 + parentObject,
712 + key,
713 + chunk.status === CYCLIC,
714 + ),
715 createModelReject(parentChunk),
716 );
717 return null;
packages/react-client/src/__tests__/ReactFlight-test.js
+25 -4
@@ -374,12 +374,13 @@ describe('ReactFlight', () => {
374 });
375
376 it('can transport Map', async () => {
377 - function ComponentClient({prop}) {
377 + function ComponentClient({prop, selected}) {
378 return `
379 map: ${prop instanceof Map}
380 size: ${prop.size}
381 greet: ${prop.get('hi').greet}
382 content: ${JSON.stringify(Array.from(prop))}
383 + selected: ${prop.get(selected)}
384 `;
385 }
386 const Component = clientReference(ComponentClient);
@@ -389,7 +390,7 @@ describe('ReactFlight', () => {
390 ['hi', {greet: 'world'}],
391 [objKey, 123],
392 ]);
392 - const model = <Component prop={map} />;
393 + const model = <Component prop={map} selected={objKey} />;
394
395 const transport = ReactNoopFlightServer.render(model);
396
@@ -402,23 +403,25 @@ describe('ReactFlight', () => {
403 size: 2
404 greet: world
405 content: [["hi",{"greet":"world"}],[{"obj":"key"},123]]
406 + selected: 123
407 `);
408 });
409
410 it('can transport Set', async () => {
409 - function ComponentClient({prop}) {
411 + function ComponentClient({prop, selected}) {
412 return `
413 set: ${prop instanceof Set}
414 size: ${prop.size}
415 hi: ${prop.has('hi')}
416 content: ${JSON.stringify(Array.from(prop))}
417 + selected: ${prop.has(selected)}
418 `;
419 }
420 const Component = clientReference(ComponentClient);
421
422 const objKey = {obj: 'key'};
423 const set = new Set(['hi', objKey]);
421 - const model = <Component prop={set} />;
424 + const model = <Component prop={set} selected={objKey} />;
425
426 const transport = ReactNoopFlightServer.render(model);
427
@@ -431,9 +434,27 @@ describe('ReactFlight', () => {
434 size: 2
435 hi: true
436 content: ["hi",{"obj":"key"}]
437 + selected: true
438 `);
439 });
440
441 + it('can transport cyclic objects', async () => {
442 + function ComponentClient({prop}) {
443 + expect(prop.obj.obj.obj).toBe(prop.obj.obj);
444 + }
445 + const Component = clientReference(ComponentClient);
446 +
447 + const cyclic = {obj: null};
448 + cyclic.obj = cyclic;
449 + const model = <Component prop={cyclic} />;
450 +
451 + const transport = ReactNoopFlightServer.render(model);
452 +
453 + await act(async () => {
454 + ReactNoop.render(await ReactNoopFlightClient.read(transport));
455 + });
456 + });
457 +
458 it('can render a lazy component as a shared component on the server', async () => {
459 function SharedComponent({text}) {
460 return (
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+59
@@ -183,6 +183,65 @@ describe('ReactFlightDOMEdge', () => {
183 expect(result.text2).toBe(testString2);
184 });
185
186 + it('should encode repeated objects in a compact format by deduping', async () => {
187 + const obj = {
188 + this: {is: 'a large objected'},
189 + with: {many: 'properties in it'},
190 + };
191 + const props = {
192 + items: new Array(30).fill(obj),
193 + };
194 + const stream = ReactServerDOMServer.renderToReadableStream(props);
195 + const [stream1, stream2] = passThrough(stream).tee();
196 +
197 + const serializedContent = await readResult(stream1);
198 + expect(serializedContent.length).toBeLessThan(400);
199 +
200 + const result = await ReactServerDOMClient.createFromReadableStream(
201 + stream2,
202 + {
203 + ssrManifest: {
204 + moduleMap: null,
205 + moduleLoading: null,
206 + },
207 + },
208 + );
209 + // Should still match the result when parsed
210 + expect(result).toEqual(props);
211 + expect(result.items[5]).toBe(result.items[10]); // two random items are the same instance
212 + // TODO: items[0] is not the same as the others in this case
213 + });
214 +
215 + it('should execute repeated server components only once', async () => {
216 + const str = 'this is a long return value';
217 + let timesRendered = 0;
218 + function ServerComponent() {
219 + timesRendered++;
220 + return str;
221 + }
222 + const element = <ServerComponent />;
223 + const children = new Array(30).fill(element);
224 + const resolvedChildren = new Array(30).fill(str);
225 + const stream = ReactServerDOMServer.renderToReadableStream(children);
226 + const [stream1, stream2] = passThrough(stream).tee();
227 +
228 + const serializedContent = await readResult(stream1);
229 + expect(serializedContent.length).toBeLessThan(400);
230 + expect(timesRendered).toBeLessThan(5);
231 +
232 + const result = await ReactServerDOMClient.createFromReadableStream(
233 + stream2,
234 + {
235 + ssrManifest: {
236 + moduleMap: null,
237 + moduleLoading: null,
238 + },
239 + },
240 + );
241 + // Should still match the result when parsed
242 + expect(result).toEqual(resolvedChildren);
243 + });
244 +
245 // @gate enableBinaryFlight
246 it('should be able to serialize any kind of typed array', async () => {
247 const buffer = new Uint8Array([
packages/react-server/src/ReactFlightServer.js
+157 -46
@@ -15,6 +15,7 @@ import {
15 enableBinaryFlight,
16 enablePostpone,
17 enableTaint,
18 + enableServerContext,
19 } from 'shared/ReactFeatureFlags';
20
21 import {
@@ -180,6 +181,8 @@ type Task = {
181 thenableState: ThenableState | null,
182 };
183
184 +interface Reference {}
185 +
186 export type Request = {
187 status: 0 | 1 | 2,
188 flushScheduled: boolean,
@@ -200,6 +203,7 @@ export type Request = {
203 writtenClientReferences: Map<ClientReferenceKey, number>,
204 writtenServerReferences: Map<ServerReference<any>, number>,
205 writtenProviders: Map<string, number>,
206 + writtenObjects: WeakMap<Reference, number>, // -1 means "seen" but not outlined.
207 identifierPrefix: string,
208 identifierCount: number,
209 taintCleanupQueue: Array<string | bigint>,
@@ -298,6 +302,7 @@ export function createRequest(
302 writtenClientReferences: new Map(),
303 writtenServerReferences: new Map(),
304 writtenProviders: new Map(),
305 + writtenObjects: new WeakMap(),
306 identifierPrefix: identifierPrefix || '',
307 identifierCount: 1,
308 taintCleanupQueue: cleanupQueue,
@@ -581,28 +586,31 @@ function attemptResolveElement(
586 );
587 }
588 case REACT_PROVIDER_TYPE: {
584 - pushProvider(type._context, props.value);
585 - if (__DEV__) {
586 - const extraKeys = Object.keys(props).filter(value => {
587 - if (value === 'children' || value === 'value') {
588 - return false;
589 + if (enableServerContext) {
590 + pushProvider(type._context, props.value);
591 + if (__DEV__) {
592 + const extraKeys = Object.keys(props).filter(value => {
593 + if (value === 'children' || value === 'value') {
594 + return false;
595 + }
596 + return true;
597 + });
598 + if (extraKeys.length !== 0) {
599 + console.error(
600 + 'ServerContext can only have a value prop and children. Found: %s',
601 + JSON.stringify(extraKeys),
602 + );
603 }
590 - return true;
591 - });
592 - if (extraKeys.length !== 0) {
593 - console.error(
594 - 'ServerContext can only have a value prop and children. Found: %s',
595 - JSON.stringify(extraKeys),
596 - );
604 }
605 + return [
606 + REACT_ELEMENT_TYPE,
607 + type,
608 + key,
609 + // Rely on __popProvider being serialized last to pop the provider.
610 + {value: props.value, children: props.children, __pop: POP},
611 + ];
612 }
599 - return [
600 - REACT_ELEMENT_TYPE,
601 - type,
602 - key,
603 - // Rely on __popProvider being serialized last to pop the provider.
604 - {value: props.value, children: props.children, __pop: POP},
605 - ];
613 + // Fallthrough
614 }
615 }
616 }
@@ -759,10 +767,14 @@ function serializeClientReference(
767
768 function outlineModel(request: Request, value: any): number {
769 request.pendingChunks++;
762 - const outlinedId = request.nextChunkId++;
763 - // We assume that this object doesn't suspend, but a child might.
764 - emitModelChunk(request, outlinedId, value);
765 - return outlinedId;
770 + const newTask = createTask(
771 + request,
772 + value,
773 + getActiveContext(),
774 + request.abortableTasks,
775 + );
776 + retryTask(request, newTask);
777 + return newTask.id;
778 }
779
780 function serializeServerReference(
@@ -810,12 +822,36 @@ function serializeMap(
822 request: Request,
823 map: Map<ReactClientValue, ReactClientValue>,
824 ): string {
813 - const id = outlineModel(request, Array.from(map));
825 + const entries = Array.from(map);
826 + for (let i = 0; i < entries.length; i++) {
827 + const key = entries[i][0];
828 + if (typeof key === 'object' && key !== null) {
829 + const writtenObjects = request.writtenObjects;
830 + const existingId = writtenObjects.get(key);
831 + if (existingId === undefined) {
832 + // Mark all object keys as seen so that they're always outlined.
833 + writtenObjects.set(key, -1);
834 + }
835 + }
836 + }
837 + const id = outlineModel(request, entries);
838 return '$Q' + id.toString(16);
839 }
840
841 function serializeSet(request: Request, set: Set<ReactClientValue>): string {
818 - const id = outlineModel(request, Array.from(set));
842 + const entries = Array.from(set);
843 + for (let i = 0; i < entries.length; i++) {
844 + const key = entries[i];
845 + if (typeof key === 'object' && key !== null) {
846 + const writtenObjects = request.writtenObjects;
847 + const existingId = writtenObjects.get(key);
848 + if (existingId === undefined) {
849 + // Mark all object keys as seen so that they're always outlined.
850 + writtenObjects.set(key, -1);
851 + }
852 + }
853 + }
854 + const id = outlineModel(request, entries);
855 return '$W' + id.toString(16);
856 }
857
@@ -860,6 +896,7 @@ function escapeStringValue(value: string): string {
896
897 let insideContextProps = null;
898 let isInsideContextValue = false;
899 +let modelRoot: null | ReactClientValue = false;
900
901 function resolveModelToJSON(
902 request: Request,
@@ -913,6 +950,7 @@ function resolveModelToJSON(
950
951 if (__DEV__) {
952 if (
953 + enableServerContext &&
954 parent[0] === REACT_ELEMENT_TYPE &&
955 parent[1] &&
956 (parent[1]: any).$$typeof === REACT_PROVIDER_TYPE &&
@@ -934,7 +972,7 @@ function resolveModelToJSON(
972 (value: any).$$typeof === REACT_LAZY_TYPE)
973 ) {
974 if (__DEV__) {
937 - if (isInsideContextValue) {
975 + if (enableServerContext && isInsideContextValue) {
976 console.error('React elements are not allowed in ServerContext');
977 }
978 }
@@ -942,6 +980,28 @@ function resolveModelToJSON(
980 try {
981 switch ((value: any).$$typeof) {
982 case REACT_ELEMENT_TYPE: {
983 + const writtenObjects = request.writtenObjects;
984 + const existingId = writtenObjects.get(value);
985 + if (existingId !== undefined) {
986 + if (existingId === -1) {
987 + // Seen but not yet outlined.
988 + const newId = outlineModel(request, value);
989 + return serializeByValueID(newId);
990 + } else if (modelRoot === value) {
991 + // This is the ID we're currently emitting so we need to write it
992 + // once but if we discover it again, we refer to it by id.
993 + modelRoot = null;
994 + } else {
995 + // We've already emitted this as an outlined object, so we can
996 + // just refer to that by its existing ID.
997 + return serializeByValueID(existingId);
998 + }
999 + } else {
1000 + // This is the first time we've seen this object. We may never see it again
1001 + // so we'll inline it. Mark it as seen. If we see it again, we'll outline.
1002 + writtenObjects.set(value, -1);
1003 + }
1004 +
1005 // TODO: Concatenate keys of parents onto children.
1006 const element: React$Element<any> = (value: any);
1007 // Attempt to render the Server Component.
@@ -1022,31 +1082,70 @@ function resolveModelToJSON(
1082 }
1083 if (isClientReference(value)) {
1084 return serializeClientReference(request, parent, key, (value: any));
1025 - // $FlowFixMe[method-unbinding]
1026 - } else if (typeof value.then === 'function') {
1085 + }
1086 +
1087 + const writtenObjects = request.writtenObjects;
1088 + const existingId = writtenObjects.get(value);
1089 + // $FlowFixMe[method-unbinding]
1090 + if (typeof value.then === 'function') {
1091 + if (existingId !== undefined) {
1092 + if (modelRoot === value) {
1093 + // This is the ID we're currently emitting so we need to write it
1094 + // once but if we discover it again, we refer to it by id.
1095 + modelRoot = null;
1096 + } else {
1097 + // We've seen this promise before, so we can just refer to the same result.
1098 + return serializePromiseID(existingId);
1099 + }
1100 + }
1101 // We assume that any object with a .then property is a "Thenable" type,
1102 // or a Promise type. Either of which can be represented by a Promise.
1103 const promiseId = serializeThenable(request, (value: any));
1104 + writtenObjects.set(value, promiseId);
1105 return serializePromiseID(promiseId);
1031 - } else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) {
1032 - const providerKey = ((value: any): ReactProviderType<any>)._context
1033 - ._globalName;
1034 - const writtenProviders = request.writtenProviders;
1035 - let providerId = writtenProviders.get(key);
1036 - if (providerId === undefined) {
1037 - request.pendingChunks++;
1038 - providerId = request.nextChunkId++;
1039 - writtenProviders.set(providerKey, providerId);
1040 - emitProviderChunk(request, providerId, providerKey);
1106 + }
1107 +
1108 + if (enableServerContext) {
1109 + if ((value: any).$$typeof === REACT_PROVIDER_TYPE) {
1110 + const providerKey = ((value: any): ReactProviderType<any>)._context
1111 + ._globalName;
1112 + const writtenProviders = request.writtenProviders;
1113 + let providerId = writtenProviders.get(key);
1114 + if (providerId === undefined) {
1115 + request.pendingChunks++;
1116 + providerId = request.nextChunkId++;
1117 + writtenProviders.set(providerKey, providerId);
1118 + emitProviderChunk(request, providerId, providerKey);
1119 + }
1120 + return serializeByValueID(providerId);
1121 + } else if (value === POP) {
1122 + popProvider();
1123 + if (__DEV__) {
1124 + insideContextProps = null;
1125 + isInsideContextValue = false;
1126 + }
1127 + return (undefined: any);
1128 }
1042 - return serializeByValueID(providerId);
1043 - } else if (value === POP) {
1044 - popProvider();
1045 - if (__DEV__) {
1046 - insideContextProps = null;
1047 - isInsideContextValue = false;
1129 + }
1130 +
1131 + if (existingId !== undefined) {
1132 + if (existingId === -1) {
1133 + // Seen but not yet outlined.
1134 + const newId = outlineModel(request, value);
1135 + return serializeByValueID(newId);
1136 + } else if (modelRoot === value) {
1137 + // This is the ID we're currently emitting so we need to write it
1138 + // once but if we discover it again, we refer to it by id.
1139 + modelRoot = null;
1140 + } else {
1141 + // We've already emitted this as an outlined object, so we can
1142 + // just refer to that by its existing ID.
1143 + return serializeByValueID(existingId);
1144 }
1049 - return (undefined: any);
1145 + } else {
1146 + // This is the first time we've seen this object. We may never see it again
1147 + // so we'll inline it. Mark it as seen. If we see it again, we'll outline.
1148 + writtenObjects.set(value, -1);
1149 }
1150
1151 if (isArray(value)) {
@@ -1401,6 +1500,10 @@ function emitModelChunk(
1500 id: number,
1501 model: ReactClientValue,
1502 ): void {
1503 + // Track the root so we know that we have to emit this object even though it
1504 + // already has an ID. This is needed because we might see this object twice
1505 + // in the same toJSON if it is cyclic.
1506 + modelRoot = model;
1507 // $FlowFixMe[incompatible-type] stringify can return null
1508 const json: string = stringify(model, request.toJSON);
1509 const row = id.toString(16) + ':' + json + '\n';
@@ -1422,6 +1525,8 @@ function retryTask(request: Request, task: Task): void {
1525 value !== null &&
1526 (value: any).$$typeof === REACT_ELEMENT_TYPE
1527 ) {
1528 + request.writtenObjects.set(value, task.id);
1529 +
1530 // TODO: Concatenate keys of parents onto children.
1531 const element: React$Element<any> = (value: any);
1532
@@ -1454,6 +1559,7 @@ function retryTask(request: Request, task: Task): void {
1559 value !== null &&
1560 (value: any).$$typeof === REACT_ELEMENT_TYPE
1561 ) {
1562 + request.writtenObjects.set(value, task.id);
1563 // TODO: Concatenate keys of parents onto children.
1564 const nextElement: React$Element<any> = (value: any);
1565 task.model = value;
@@ -1468,6 +1574,11 @@ function retryTask(request: Request, task: Task): void {
1574 }
1575 }
1576
1577 + // Track that this object is outlined and has an id.
1578 + if (typeof value === 'object' && value !== null) {
1579 + request.writtenObjects.set(value, task.id);
1580 + }
1581 +
1582 emitModelChunk(request, task.id, value);
1583 request.abortableTasks.delete(task);
1584 task.status = COMPLETED;
@@ -1703,7 +1814,7 @@ export function abort(request: Request, reason: mixed): void {
1814 function importServerContexts(
1815 contexts?: Array<[string, ServerContextJSONValue]>,
1816 ) {
1706 - if (contexts) {
1817 + if (enableServerContext && contexts) {
1818 const prevContext = getActiveContext();
1819 switchContext(rootContextSnapshot);
1820 for (let i = 0; i < contexts.length; i++) {