@samitouri / QOS-React-1 / commits / 7a78d03028

[Flight] Encode references to existing objects by property path (#28996)

Instead of forcing an object to be outlined to be able to refer to it later we can refer to it by the property path inside another parent object. E.g. this encodes such a reference as `'$123:props:children:foo:bar'`. That way we don't have to preemptively outline object and we can dedupe after the first time we've found it. There's no cost on the client if it's not used because we're not storing any additional information preemptively. This works mainly because we only have simple JSON objects from the root reference. Complex objects like Map, FormData etc. are stored as their entries array in the look up and not the complex object. Other complex objects like TypedArrays or imports don't have deeply nested objects in them that can be referenced. This solves the problem that we only dedupe after the third instance. This dedupes at the second instance. It also solves the problem where all nested objects inside deduped instances also are outlined. The property paths can get pretty large. This is why a test on payload size increased. We could potentially outline the reference itself at the first dupe. That way we get a shorter ID to refer to in the third instance.

Sebastian Markbåge committed May 9, 2024 at 19:16 UTC 7a78d030281f2f0e02e7cdbed5c1fc5c4a1a1823
3 files changed +155 -96
packages/react-client/src/ReactFlightClient.js
+27 -16
@@ -680,6 +680,7 @@ function createModelResolver<T>(
680 cyclic: boolean,
681 response: Response,
682 map: (response: Response, model: any) => T,
683 + path: Array<string>,
684 ): (value: any) => void {
685 let blocked;
686 if (initializingChunkBlockedModel) {
@@ -694,6 +695,9 @@ function createModelResolver<T>(
695 };
696 }
697 return value => {
698 + for (let i = 1; i < path.length; i++) {
699 + value = value[path[i]];
700 + }
701 parentObject[key] = map(response, value);
702
703 // If this is the root object for a model reference, where `blocked.value`
@@ -752,11 +756,13 @@ function createServerReferenceProxy<A: Iterable<any>, T>(
756
757 function getOutlinedModel<T>(
758 response: Response,
755 - id: number,
759 + reference: string,
760 parentObject: Object,
761 key: string,
762 map: (response: Response, model: any) => T,
763 ): T {
764 + const path = reference.split(':');
765 + const id = parseInt(path[0], 16);
766 const chunk = getChunk(response, id);
767 switch (chunk.status) {
768 case RESOLVED_MODEL:
@@ -769,7 +775,11 @@ function getOutlinedModel<T>(
775 // The status might have changed after initialization.
776 switch (chunk.status) {
777 case INITIALIZED:
772 - const chunkValue = map(response, chunk.value);
778 + let value = chunk.value;
779 + for (let i = 1; i < path.length; i++) {
780 + value = value[path[i]];
781 + }
782 + const chunkValue = map(response, value);
783 if (__DEV__ && chunk._debugInfo) {
784 // If we have a direct reference to an object that was rendered by a synchronous
785 // server component, it might have some debug info about how it was rendered.
@@ -809,6 +819,7 @@ function getOutlinedModel<T>(
819 chunk.status === CYCLIC,
820 response,
821 map,
822 + path,
823 ),
824 createModelReject(parentChunk),
825 );
@@ -893,10 +904,10 @@ function parseModelString(
904 }
905 case 'F': {
906 // Server Reference
896 - const id = parseInt(value.slice(2), 16);
907 + const ref = value.slice(2);
908 return getOutlinedModel(
909 response,
899 - id,
910 + ref,
911 parentObject,
912 key,
913 createServerReferenceProxy,
@@ -916,28 +927,28 @@ function parseModelString(
927 }
928 case 'Q': {
929 // Map
919 - const id = parseInt(value.slice(2), 16);
920 - return getOutlinedModel(response, id, parentObject, key, createMap);
930 + const ref = value.slice(2);
931 + return getOutlinedModel(response, ref, parentObject, key, createMap);
932 }
933 case 'W': {
934 // Set
924 - const id = parseInt(value.slice(2), 16);
925 - return getOutlinedModel(response, id, parentObject, key, createSet);
935 + const ref = value.slice(2);
936 + return getOutlinedModel(response, ref, parentObject, key, createSet);
937 }
938 case 'B': {
939 // Blob
940 if (enableBinaryFlight) {
930 - const id = parseInt(value.slice(2), 16);
931 - return getOutlinedModel(response, id, parentObject, key, createBlob);
941 + const ref = value.slice(2);
942 + return getOutlinedModel(response, ref, parentObject, key, createBlob);
943 }
944 return undefined;
945 }
946 case 'K': {
947 // FormData
937 - const id = parseInt(value.slice(2), 16);
948 + const ref = value.slice(2);
949 return getOutlinedModel(
950 response,
940 - id,
951 + ref,
952 parentObject,
953 key,
954 createFormData,
@@ -945,10 +956,10 @@ function parseModelString(
956 }
957 case 'i': {
958 // Iterator
948 - const id = parseInt(value.slice(2), 16);
959 + const ref = value.slice(2);
960 return getOutlinedModel(
961 response,
951 - id,
962 + ref,
963 parentObject,
964 key,
965 extractIterator,
@@ -1000,8 +1011,8 @@ function parseModelString(
1011 }
1012 default: {
1013 // We assume that anything else is a reference ID.
1003 - const id = parseInt(value.slice(1), 16);
1004 - return getOutlinedModel(response, id, parentObject, key, createModel);
1014 + const ref = value.slice(1);
1015 + return getOutlinedModel(response, ref, parentObject, key, createModel);
1016 }
1017 }
1018 }
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+50 -1
@@ -231,7 +231,7 @@ describe('ReactFlightDOMEdge', () => {
231 const [stream1, stream2] = passThrough(stream).tee();
232
233 const serializedContent = await readResult(stream1);
234 - expect(serializedContent.length).toBeLessThan(400);
234 + expect(serializedContent.length).toBeLessThan(470);
235
236 const result = await ReactServerDOMClient.createFromReadableStream(
237 stream2,
@@ -543,6 +543,55 @@ describe('ReactFlightDOMEdge', () => {
543 expect(await iterator.next()).toEqual({value: undefined, done: true});
544 });
545
546 + // @gate enableFlightReadableStream
547 + it('should ideally dedupe objects inside async iterables but does not yet', async () => {
548 + const obj = {
549 + this: {is: 'a large objected'},
550 + with: {many: 'properties in it'},
551 + };
552 + const iterable = {
553 + async *[Symbol.asyncIterator]() {
554 + for (let i = 0; i < 30; i++) {
555 + yield obj;
556 + }
557 + },
558 + };
559 +
560 + const stream = ReactServerDOMServer.renderToReadableStream({
561 + iterable,
562 + });
563 + const [stream1, stream2] = passThrough(stream).tee();
564 +
565 + const serializedContent = await readResult(stream1);
566 + // TODO: Ideally streams should dedupe objects but because we never outline the objects
567 + // they end up not having a row to reference them nor any of its nested objects.
568 + // expect(serializedContent.length).toBeLessThan(400);
569 + expect(serializedContent.length).toBeGreaterThan(400);
570 +
571 + const result = await ReactServerDOMClient.createFromReadableStream(
572 + stream2,
573 + {
574 + ssrManifest: {
575 + moduleMap: null,
576 + moduleLoading: null,
577 + },
578 + },
579 + );
580 +
581 + const items = [];
582 + const iterator = result.iterable[Symbol.asyncIterator]();
583 + let entry;
584 + while (!(entry = await iterator.next()).done) {
585 + items.push(entry.value);
586 + }
587 +
588 + // Should still match the result when parsed
589 + expect(items.length).toBe(30);
590 + // TODO: These should be the same
591 + // expect(items[5]).toBe(items[10]); // two random items are the same instance
592 + expect(items[5]).toEqual(items[10]);
593 + });
594 +
595 it('warns if passing a this argument to bind() of a server reference', async () => {
596 const ServerModule = serverExports({
597 greet: function () {},
packages/react-server/src/ReactFlightServer.js
+78 -79
@@ -355,10 +355,6 @@ const COMPLETED = 1;
355 const ABORTED = 3;
356 const ERRORED = 4;
357
358 -// object reference status
359 -const SEEN_BUT_NOT_YET_OUTLINED = -1;
360 -const NEVER_OUTLINED = -2;
361 -
358 type Task = {
359 id: number,
360 status: 0 | 1 | 3 | 4,
@@ -392,7 +388,7 @@ export type Request = {
388 writtenSymbols: Map<symbol, number>,
389 writtenClientReferences: Map<ClientReferenceKey, number>,
390 writtenServerReferences: Map<ServerReference<any>, number>,
395 - writtenObjects: WeakMap<Reference, number>,
391 + writtenObjects: WeakMap<Reference, string>,
392 identifierPrefix: string,
393 identifierCount: number,
394 taintCleanupQueue: Array<string | bigint>,
@@ -1428,7 +1424,7 @@ function createTask(
1424 // If we're in some kind of context we can't necessarily reuse this object depending
1425 // what parent components are used.
1426 } else {
1431 - request.writtenObjects.set(model, id);
1427 + request.writtenObjects.set(model, serializeByValueID(id));
1428 }
1429 }
1430 const task: Task = {
@@ -1669,16 +1665,6 @@ function serializeMap(
1665 map: Map<ReactClientValue, ReactClientValue>,
1666 ): string {
1667 const entries = Array.from(map);
1672 - for (let i = 0; i < entries.length; i++) {
1673 - const key = entries[i][0];
1674 - if (typeof key === 'object' && key !== null) {
1675 - const writtenObjects = request.writtenObjects;
1676 - const existingId = writtenObjects.get(key);
1677 - if (existingId === undefined) {
1678 - writtenObjects.set(key, SEEN_BUT_NOT_YET_OUTLINED);
1679 - }
1680 - }
1681 - }
1668 const id = outlineModel(request, entries);
1669 return '$Q' + id.toString(16);
1670 }
@@ -1691,16 +1677,6 @@ function serializeFormData(request: Request, formData: FormData): string {
1677
1678 function serializeSet(request: Request, set: Set<ReactClientValue>): string {
1679 const entries = Array.from(set);
1694 - for (let i = 0; i < entries.length; i++) {
1695 - const key = entries[i];
1696 - if (typeof key === 'object' && key !== null) {
1697 - const writtenObjects = request.writtenObjects;
1698 - const existingId = writtenObjects.get(key);
1699 - if (existingId === undefined) {
1700 - writtenObjects.set(key, SEEN_BUT_NOT_YET_OUTLINED);
1701 - }
1702 - }
1703 - }
1680 const id = outlineModel(request, entries);
1681 return '$W' + id.toString(16);
1682 }
@@ -1912,39 +1888,39 @@ function renderModelDestructive(
1888 switch ((value: any).$$typeof) {
1889 case REACT_ELEMENT_TYPE: {
1890 const writtenObjects = request.writtenObjects;
1915 - const existingId = writtenObjects.get(value);
1916 - if (existingId !== undefined) {
1917 - if (task.keyPath !== null || task.implicitSlot) {
1918 - // If we're in some kind of context we can't reuse the result of this render or
1919 - // previous renders of this element. We only reuse elements if they're not wrapped
1920 - // by another Server Component.
1921 - } else if (modelRoot === value) {
1922 - // This is the ID we're currently emitting so we need to write it
1923 - // once but if we discover it again, we refer to it by id.
1924 - modelRoot = null;
1925 - } else if (existingId === SEEN_BUT_NOT_YET_OUTLINED) {
1926 - // TODO: If we throw here we can treat this as suspending which causes an outline
1927 - // but that is able to reuse the same task if we're already in one but then that
1928 - // will be a lazy future value rather than guaranteed to exist but maybe that's good.
1929 - const newId = outlineModel(request, (value: any));
1930 - return serializeByValueID(newId);
1931 - } else {
1932 - // We've already emitted this as an outlined object, so we can refer to that by its
1933 - // existing ID. TODO: We should use a lazy reference since, unlike plain objects,
1934 - // elements might suspend so it might not have emitted yet even if we have the ID for
1935 - // it. However, this creates an extra wrapper when it's not needed. We should really
1936 - // detect whether this already was emitted and synchronously available. In that
1937 - // case we can refer to it synchronously and only make it lazy otherwise.
1938 - // We currently don't have a data structure that lets us see that though.
1939 - return serializeByValueID(existingId);
1940 - }
1891 + if (task.keyPath !== null || task.implicitSlot) {
1892 + // If we're in some kind of context we can't reuse the result of this render or
1893 + // previous renders of this element. We only reuse elements if they're not wrapped
1894 + // by another Server Component.
1895 } else {
1942 - // This is the first time we've seen this object. We may never see it again
1943 - // so we'll inline it. Mark it as seen. If we see it again, we'll outline.
1944 - writtenObjects.set(value, SEEN_BUT_NOT_YET_OUTLINED);
1945 - // The element's props are marked as "never outlined" so that they are inlined into
1946 - // the same row as the element itself.
1947 - writtenObjects.set((value: any).props, NEVER_OUTLINED);
1896 + const existingReference = writtenObjects.get(value);
1897 + if (existingReference !== undefined) {
1898 + if (modelRoot === value) {
1899 + // This is the ID we're currently emitting so we need to write it
1900 + // once but if we discover it again, we refer to it by id.
1901 + modelRoot = null;
1902 + } else {
1903 + // We've already emitted this as an outlined object, so we can refer to that by its
1904 + // existing ID. TODO: We should use a lazy reference since, unlike plain objects,
1905 + // elements might suspend so it might not have emitted yet even if we have the ID for
1906 + // it. However, this creates an extra wrapper when it's not needed. We should really
1907 + // detect whether this already was emitted and synchronously available. In that
1908 + // case we can refer to it synchronously and only make it lazy otherwise.
1909 + // We currently don't have a data structure that lets us see that though.
1910 + return existingReference;
1911 + }
1912 + } else if (parentPropertyName.indexOf(':') === -1) {
1913 + // TODO: If the property name contains a colon, we don't dedupe. Escape instead.
1914 + const parentReference = writtenObjects.get(parent);
1915 + if (parentReference !== undefined) {
1916 + // If the parent has a reference, we can refer to this object indirectly
1917 + // through the property name inside that parent.
1918 + writtenObjects.set(
1919 + value,
1920 + parentReference + ':' + parentPropertyName,
1921 + );
1922 + }
1923 + }
1924 }
1925
1926 const element: ReactElement = (value: any);
@@ -2048,10 +2024,10 @@ function renderModelDestructive(
2024 }
2025
2026 const writtenObjects = request.writtenObjects;
2051 - const existingId = writtenObjects.get(value);
2027 + const existingReference = writtenObjects.get(value);
2028 // $FlowFixMe[method-unbinding]
2029 if (typeof value.then === 'function') {
2054 - if (existingId !== undefined) {
2030 + if (existingReference !== undefined) {
2031 if (task.keyPath !== null || task.implicitSlot) {
2032 // If we're in some kind of context we can't reuse the result of this render or
2033 // previous renders of this element. We only reuse Promises if they're not wrapped
@@ -2064,33 +2040,52 @@ function renderModelDestructive(
2040 modelRoot = null;
2041 } else {
2042 // We've seen this promise before, so we can just refer to the same result.
2067 - return serializePromiseID(existingId);
2043 + return existingReference;
2044 }
2045 }
2046 // We assume that any object with a .then property is a "Thenable" type,
2047 // or a Promise type. Either of which can be represented by a Promise.
2048 const promiseId = serializeThenable(request, task, (value: any));
2073 - writtenObjects.set(value, promiseId);
2074 - return serializePromiseID(promiseId);
2049 + const promiseReference = serializePromiseID(promiseId);
2050 + writtenObjects.set(value, promiseReference);
2051 + return promiseReference;
2052 }
2053
2077 - if (existingId !== undefined) {
2054 + if (existingReference !== undefined) {
2055 if (modelRoot === value) {
2056 // This is the ID we're currently emitting so we need to write it
2057 // once but if we discover it again, we refer to it by id.
2058 modelRoot = null;
2082 - } else if (existingId === SEEN_BUT_NOT_YET_OUTLINED) {
2083 - const newId = outlineModel(request, (value: any));
2084 - return serializeByValueID(newId);
2085 - } else if (existingId !== NEVER_OUTLINED) {
2059 + } else {
2060 // We've already emitted this as an outlined object, so we can
2061 // just refer to that by its existing ID.
2088 - return serializeByValueID(existingId);
2062 + return existingReference;
2063 + }
2064 + } else if (parentPropertyName.indexOf(':') === -1) {
2065 + // TODO: If the property name contains a colon, we don't dedupe. Escape instead.
2066 + const parentReference = writtenObjects.get(parent);
2067 + if (parentReference !== undefined) {
2068 + // If the parent has a reference, we can refer to this object indirectly
2069 + // through the property name inside that parent.
2070 + let propertyName = parentPropertyName;
2071 + if (isArray(parent) && parent[0] === REACT_ELEMENT_TYPE) {
2072 + // For elements, we've converted it to an array but we'll have converted
2073 + // it back to an element before we read the references so the property
2074 + // needs to be aliased.
2075 + switch (parentPropertyName) {
2076 + case '1':
2077 + propertyName = 'type';
2078 + break;
2079 + case '2':
2080 + propertyName = 'key';
2081 + break;
2082 + case '3':
2083 + propertyName = 'props';
2084 + break;
2085 + }
2086 + }
2087 + writtenObjects.set(value, parentReference + ':' + propertyName);
2088 }
2090 - } else {
2091 - // This is the first time we've seen this object. We may never see it again
2092 - // so we'll inline it. Mark it as seen. If we see it again, we'll outline.
2093 - writtenObjects.set(value, SEEN_BUT_NOT_YET_OUTLINED);
2089 }
2090
2091 if (isArray(value)) {
@@ -2657,12 +2652,12 @@ function renderConsoleValue(
2652 counter.objectCount++;
2653
2654 const writtenObjects = request.writtenObjects;
2660 - const existingId = writtenObjects.get(value);
2655 + const existingReference = writtenObjects.get(value);
2656 // $FlowFixMe[method-unbinding]
2657 if (typeof value.then === 'function') {
2663 - if (existingId !== undefined) {
2658 + if (existingReference !== undefined) {
2659 // We've seen this promise before, so we can just refer to the same result.
2665 - return serializePromiseID(existingId);
2660 + return existingReference;
2661 }
2662
2663 const thenable: Thenable<any> = (value: any);
@@ -2698,10 +2693,10 @@ function renderConsoleValue(
2693 return serializeInfinitePromise();
2694 }
2695
2701 - if (existingId !== undefined && existingId >= 0) {
2696 + if (existingReference !== undefined) {
2697 // We've already emitted this as a real object, so we can
2703 - // just refer to that by its existing ID.
2704 - return serializeByValueID(existingId);
2698 + // just refer to that by its existing reference.
2699 + return existingReference;
2700 }
2701
2702 if (isArray(value)) {
@@ -3093,6 +3088,10 @@ function retryTask(request: Request, task: Task): void {
3088 task.implicitSlot = false;
3089
3090 if (typeof resolvedModel === 'object' && resolvedModel !== null) {
3091 + // We're not in a contextual place here so we can refer to this object by this ID for
3092 + // any future references.
3093 + request.writtenObjects.set(resolvedModel, serializeByValueID(task.id));
3094 +
3095 // Object might contain unresolved values like additional elements.
3096 // This is simulating what the JSON loop would do if this was part of it.
3097 emitChunk(request, task, resolvedModel);