@samitouri / QOS-React / commits / fb57fc5a8a

[Flight] Let Errored/Blocked Direct References Turn Nearest Element Lazy (#29823)

Stacked on #29807. This lets the nearest Suspense/Error Boundary handle it even if that boundary is defined by the model itself. It also ensures that when we have an error during serialization of properties, those can be associated with the nearest JSX element and since we have a stack/owner for that element we can use it to point to the source code of that line. We can't track the source of any nested arbitrary objects deeper inside since objects don’t track their stacks but close enough. Ideally we have the property path but we don’t have that right now. We have a partial in the message itself. <img width="813" alt="Screenshot 2024-06-09 at 10 08 27 PM" src="https://github.com/facebook/react/assets/63648/917fbe0c-053c-4204-93db-d68a66e3e874"> Note: The component name (Counter) is lost in the first message because we don't print it in the Task. We use `"use client"` instead because we expect the next stack frame to have the name. We also don't include it in the actual error message because the Server doesn't know the component name yet. Ideally Client References should be able to have a name. If the nearest is a Host Component then we do use the name though. However, it's not actually inside that Component that the error happens it's in App and that points to the right line number. An interesting case is that if something that's actually going to be consumed by the props to a Suspense/Error Boundary or the Client Component that wraps them fails, then it can't be handled by the boundary. However, a counter intuitive case might be when that's on the `children` props. E.g. `<ErrorBoundary>{clientReferenceOrInvalidSerialization}</ErrorBoundary>`. This value can be inspected by the boundary so it's not safe to pass it so if it's errored it is not caught. ## Implementation The first insight is that this is best solved on the Client rather than in the Server because that way it also covers Client References that end up erroring. The key insight is that while we don't have a true stack when using `JSON.parse` and therefore no begin/complete we can still infer these phases for Elements because the first child of an Element is always `'$'` which is also a leaf. In depth first that's our begin phase. When the Element itself completes, we have the complete phase. Anything in between is within the Element. Using this idea I was able to refactor the blocking tracking mechanism to stash the blocked information on `initializingHandler` and then on the way up do we let whatever is nearest handle it - whether that's an Element or the root Chunk. It's kind of like an Algebraic Effect. cc @unstubbable This is something you might want to deep dive into to find more edge cases. I'm sure I've missed something. --------- Co-authored-by: eps1lon <sebastian.silbermann@vercel.com>

Sebastian Markbåge committed Jun 11, 2024 at 19:12 UTC fb57fc5a8a66f38d65e3bc9f83213a0003da5702
8 files changed +353 -120
.eslintrc.js
+1
@@ -571,6 +571,7 @@ module.exports = {
571 TimeoutID: 'readonly',
572 WheelEventHandler: 'readonly',
573 FinalizationRegistry: 'readonly',
574 + Omit: 'readonly',
575
576 spyOnDev: 'readonly',
577 spyOnDevAndProd: 'readonly',
packages/react-client/src/ReactFlightClient.js
+222 -90
@@ -72,6 +72,8 @@ import {
72
73 import getComponentNameFromType from 'shared/getComponentNameFromType';
74
75 +import isArray from 'shared/isArray';
76 +
77 export type {CallServerCallback, EncodeFormActionCallback};
78
79 interface FlightStreamController {
@@ -101,7 +103,6 @@ type RowParserState = 0 | 1 | 2 | 3 | 4;
103
104 const PENDING = 'pending';
105 const BLOCKED = 'blocked';
104 -const CYCLIC = 'cyclic';
106 const RESOLVED_MODEL = 'resolved_model';
107 const RESOLVED_MODULE = 'resolved_module';
108 const INITIALIZED = 'fulfilled';
@@ -123,14 +124,6 @@ type BlockedChunk<T> = {
124 _debugInfo?: null | ReactDebugInfo,
125 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
126 };
126 -type CyclicChunk<T> = {
127 - status: 'cyclic',
128 - value: null | Array<(T) => mixed>,
129 - reason: null | Array<(mixed) => mixed>,
130 - _response: Response,
131 - _debugInfo?: null | ReactDebugInfo,
132 - then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
133 -};
127 type ResolvedModelChunk<T> = {
128 status: 'resolved_model',
129 value: UninitializedModel,
@@ -176,7 +169,6 @@ type ErroredChunk<T> = {
169 type SomeChunk<T> =
170 | PendingChunk<T>
171 | BlockedChunk<T>
179 - | CyclicChunk<T>
172 | ResolvedModelChunk<T>
173 | ResolvedModuleChunk<T>
174 | InitializedChunk<T>
@@ -218,7 +210,6 @@ Chunk.prototype.then = function <T>(
210 break;
211 case PENDING:
212 case BLOCKED:
221 - case CYCLIC:
213 if (resolve) {
214 if (chunk.value === null) {
215 chunk.value = ([]: Array<(T) => mixed>);
@@ -278,7 +269,6 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
269 return chunk.value;
270 case PENDING:
271 case BLOCKED:
281 - case CYCLIC:
272 // eslint-disable-next-line no-throw-literal
273 throw ((chunk: any): Thenable<T>);
274 default:
@@ -327,7 +317,6 @@ function wakeChunkIfInitialized<T>(
317 break;
318 case PENDING:
319 case BLOCKED:
330 - case CYCLIC:
320 if (chunk.value) {
321 for (let i = 0; i < resolveListeners.length; i++) {
322 chunk.value.push(resolveListeners[i]);
@@ -501,51 +490,61 @@ function resolveModuleChunk<T>(
490 }
491 }
492
504 -let initializingChunk: ResolvedModelChunk<any> = (null: any);
505 -let initializingChunkBlockedModel: null | {deps: number, value: any} = null;
493 +type InitializationHandler = {
494 + parent: null | InitializationHandler,
495 + chunk: null | BlockedChunk<any>,
496 + value: any,
497 + deps: number,
498 + errored: boolean,
499 +};
500 +let initializingHandler: null | InitializationHandler = null;
501 +
502 function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
507 - const prevChunk = initializingChunk;
508 - const prevBlocked = initializingChunkBlockedModel;
509 - initializingChunk = chunk;
510 - initializingChunkBlockedModel = null;
503 + const prevHandler = initializingHandler;
504 + initializingHandler = null;
505
506 const resolvedModel = chunk.value;
507
514 - // We go to the CYCLIC state until we've fully resolved this.
508 + // We go to the BLOCKED state until we've fully resolved this.
509 // We do this before parsing in case we try to initialize the same chunk
510 // while parsing the model. Such as in a cyclic reference.
517 - const cyclicChunk: CyclicChunk<T> = (chunk: any);
518 - cyclicChunk.status = CYCLIC;
511 + const cyclicChunk: BlockedChunk<T> = (chunk: any);
512 + cyclicChunk.status = BLOCKED;
513 cyclicChunk.value = null;
514 cyclicChunk.reason = null;
515
516 try {
517 const value: T = parseModel(chunk._response, resolvedModel);
524 - if (
525 - initializingChunkBlockedModel !== null &&
526 - initializingChunkBlockedModel.deps > 0
527 - ) {
528 - initializingChunkBlockedModel.value = value;
529 - // We discovered new dependencies on modules that are not yet resolved.
530 - // We have to go the BLOCKED state until they're resolved.
531 - const blockedChunk: BlockedChunk<T> = (chunk: any);
532 - blockedChunk.status = BLOCKED;
533 - } else {
534 - const resolveListeners = cyclicChunk.value;
535 - const initializedChunk: InitializedChunk<T> = (chunk: any);
536 - initializedChunk.status = INITIALIZED;
537 - initializedChunk.value = value;
538 - if (resolveListeners !== null) {
539 - wakeChunk(resolveListeners, value);
518 + // Invoke any listeners added while resolving this model. I.e. cyclic
519 + // references. This may or may not fully resolve the model depending on
520 + // if they were blocked.
521 + const resolveListeners = cyclicChunk.value;
522 + if (resolveListeners !== null) {
523 + cyclicChunk.value = null;
524 + cyclicChunk.reason = null;
525 + wakeChunk(resolveListeners, value);
526 + }
527 + if (initializingHandler !== null) {
528 + if (initializingHandler.errored) {
529 + throw initializingHandler.value;
530 + }
531 + if (initializingHandler.deps > 0) {
532 + // We discovered new dependencies on modules that are not yet resolved.
533 + // We have to keep the BLOCKED state until they're resolved.
534 + initializingHandler.value = value;
535 + initializingHandler.chunk = cyclicChunk;
536 + return;
537 }
538 }
539 + const initializedChunk: InitializedChunk<T> = (chunk: any);
540 + initializedChunk.status = INITIALIZED;
541 + initializedChunk.value = value;
542 } catch (error) {
543 const erroredChunk: ErroredChunk<T> = (chunk: any);
544 erroredChunk.status = ERRORED;
545 erroredChunk.reason = error;
546 } finally {
547 - initializingChunk = prevChunk;
548 - initializingChunkBlockedModel = prevBlocked;
547 + initializingHandler = prevHandler;
548 }
549 }
550
@@ -626,7 +625,9 @@ function createElement(
625 owner: null | ReactComponentInfo, // DEV-only
626 stack: null | string, // DEV-only
627 validated: number, // DEV-only
629 -): React$Element<any> {
628 +):
629 + | React$Element<any>
630 + | LazyComponent<React$Element<any>, SomeChunk<React$Element<any>>> {
631 let element: any;
632 if (__DEV__ && enableRefAsProp) {
633 // `ref` is non-enumerable in dev
@@ -723,15 +724,60 @@ function createElement(
724 value: task,
725 });
726 }
727 + }
728 +
729 + if (initializingHandler !== null) {
730 + const handler = initializingHandler;
731 + // We pop the stack to the previous outer handler before leaving the Element.
732 + // This is effectively the complete phase.
733 + initializingHandler = handler.parent;
734 + if (handler.errored) {
735 + // Something errored inside this Element's props. We can turn this Element
736 + // into a Lazy so that we can still render up until that Lazy is rendered.
737 + const erroredChunk: ErroredChunk<React$Element<any>> = createErrorChunk(
738 + response,
739 + handler.value,
740 + );
741 + if (__DEV__) {
742 + // Conceptually the error happened inside this Element but right before
743 + // it was rendered. We don't have a client side component to render but
744 + // we can add some DebugInfo to explain that this was conceptually a
745 + // Server side error that errored inside this element. That way any stack
746 + // traces will point to the nearest JSX that errored - e.g. during
747 + // serialization.
748 + const erroredComponent: ReactComponentInfo = {
749 + name: getComponentNameFromType(element.type) || '',
750 + owner: element._owner,
751 + };
752 + if (enableOwnerStacks) {
753 + // $FlowFixMe[cannot-write]
754 + erroredComponent.stack = element._debugStack;
755 + // $FlowFixMe[cannot-write]
756 + erroredComponent.task = element._debugTask;
757 + }
758 + erroredChunk._debugInfo = [erroredComponent];
759 + }
760 + return createLazyChunkWrapper(erroredChunk);
761 + }
762 + if (handler.deps > 0) {
763 + // We have blocked references inside this Element but we can turn this into
764 + // a Lazy node referencing this Element to let everything around it proceed.
765 + const blockedChunk: BlockedChunk<React$Element<any>> =
766 + createBlockedChunk(response);
767 + handler.value = element;
768 + handler.chunk = blockedChunk;
769 + if (__DEV__) {
770 + const freeze = Object.freeze.bind(Object, element.props);
771 + blockedChunk.then(freeze, freeze);
772 + }
773 + return createLazyChunkWrapper(blockedChunk);
774 + }
775 + } else if (__DEV__) {
776 // TODO: We should be freezing the element but currently, we might write into
777 // _debugInfo later. We could move it into _store which remains mutable.
728 - if (initializingChunkBlockedModel !== null) {
729 - const freeze = Object.freeze.bind(Object, element.props);
730 - initializingChunk.then(freeze, freeze);
731 - } else {
732 - Object.freeze(element.props);
733 - }
778 + Object.freeze(element.props);
779 }
780 +
781 return element;
782 }
783
@@ -762,57 +808,129 @@ function getChunk(response: Response, id: number): SomeChunk<any> {
808 return chunk;
809 }
810
765 -function createModelResolver<T>(
766 - chunk: SomeChunk<T>,
811 +function waitForReference<T>(
812 + referencedChunk: PendingChunk<T> | BlockedChunk<T>,
813 parentObject: Object,
814 key: string,
769 - cyclic: boolean,
815 response: Response,
816 map: (response: Response, model: any) => T,
817 path: Array<string>,
773 -): (value: any) => void {
774 - let blocked;
775 - if (initializingChunkBlockedModel) {
776 - blocked = initializingChunkBlockedModel;
777 - if (!cyclic) {
778 - blocked.deps++;
779 - }
818 +): T {
819 + let handler: InitializationHandler;
820 + if (initializingHandler) {
821 + handler = initializingHandler;
822 + handler.deps++;
823 } else {
781 - blocked = initializingChunkBlockedModel = {
782 - deps: cyclic ? 0 : 1,
783 - value: (null: any),
824 + handler = initializingHandler = {
825 + parent: null,
826 + chunk: null,
827 + value: null,
828 + deps: 1,
829 + errored: false,
830 };
831 }
786 - return value => {
832 +
833 + function fulfill(value: any): void {
834 for (let i = 1; i < path.length; i++) {
835 + while (value.$$typeof === REACT_LAZY_TYPE) {
836 + // We never expect to see a Lazy node on this path because we encode those as
837 + // separate models. This must mean that we have inserted an extra lazy node
838 + // e.g. to replace a blocked element. We must instead look for it inside.
839 + const chunk: SomeChunk<any> = value._payload;
840 + if (chunk === handler.chunk) {
841 + // This is a reference to the thing we're currently blocking. We can peak
842 + // inside of it to get the value.
843 + value = handler.value;
844 + continue;
845 + } else if (chunk.status === INITIALIZED) {
846 + value = chunk.value;
847 + continue;
848 + } else {
849 + // If we're not yet initialized we need to skip what we've already drilled
850 + // through and then wait for the next value to become available.
851 + path.splice(0, i - 1);
852 + chunk.then(fulfill, reject);
853 + return;
854 + }
855 + }
856 value = value[path[i]];
857 }
858 parentObject[key] = map(response, value);
859
792 - // If this is the root object for a model reference, where `blocked.value`
860 + // If this is the root object for a model reference, where `handler.value`
861 // is a stale `null`, the resolved value can be used directly.
794 - if (key === '' && blocked.value === null) {
795 - blocked.value = parentObject[key];
862 + if (key === '' && handler.value === null) {
863 + handler.value = parentObject[key];
864 }
865
798 - blocked.deps--;
799 - if (blocked.deps === 0) {
800 - if (chunk.status !== BLOCKED) {
866 + handler.deps--;
867 +
868 + if (handler.deps === 0) {
869 + const chunk = handler.chunk;
870 + if (chunk === null || chunk.status !== BLOCKED) {
871 return;
872 }
873 const resolveListeners = chunk.value;
874 const initializedChunk: InitializedChunk<T> = (chunk: any);
875 initializedChunk.status = INITIALIZED;
806 - initializedChunk.value = blocked.value;
876 + initializedChunk.value = handler.value;
877 if (resolveListeners !== null) {
808 - wakeChunk(resolveListeners, blocked.value);
878 + wakeChunk(resolveListeners, handler.value);
879 }
880 }
811 - };
812 -}
881 + }
882 +
883 + function reject(error: mixed): void {
884 + if (handler.errored) {
885 + // We've already errored. We could instead build up an AggregateError
886 + // but if there are multiple errors we just take the first one like
887 + // Promise.all.
888 + return;
889 + }
890 + const blockedValue = handler.value;
891 + handler.errored = true;
892 + handler.value = error;
893 + const chunk = handler.chunk;
894 + if (chunk === null || chunk.status !== BLOCKED) {
895 + return;
896 + }
897
814 -function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {
815 - return (error: mixed) => triggerErrorOnChunk(chunk, error);
898 + if (__DEV__) {
899 + if (
900 + typeof blockedValue === 'object' &&
901 + blockedValue !== null &&
902 + blockedValue.$$typeof === REACT_ELEMENT_TYPE
903 + ) {
904 + const element = blockedValue;
905 + // Conceptually the error happened inside this Element but right before
906 + // it was rendered. We don't have a client side component to render but
907 + // we can add some DebugInfo to explain that this was conceptually a
908 + // Server side error that errored inside this element. That way any stack
909 + // traces will point to the nearest JSX that errored - e.g. during
910 + // serialization.
911 + const erroredComponent: ReactComponentInfo = {
912 + name: getComponentNameFromType(element.type) || '',
913 + owner: element._owner,
914 + };
915 + if (enableOwnerStacks) {
916 + // $FlowFixMe[cannot-write]
917 + erroredComponent.stack = element._debugStack;
918 + // $FlowFixMe[cannot-write]
919 + erroredComponent.task = element._debugTask;
920 + }
921 + const chunkDebugInfo: ReactDebugInfo =
922 + chunk._debugInfo || (chunk._debugInfo = []);
923 + chunkDebugInfo.push(erroredComponent);
924 + }
925 + }
926 +
927 + triggerErrorOnChunk(chunk, error);
928 + }
929 +
930 + referencedChunk.then(fulfill, reject);
931 +
932 + // Return a place holder value for now.
933 + return (null: any);
934 }
935
936 function createServerReferenceProxy<A: Iterable<any>, T>(
@@ -880,7 +998,7 @@ function getOutlinedModel<T>(
998 if (
999 typeof chunkValue === 'object' &&
1000 chunkValue !== null &&
883 - (Array.isArray(chunkValue) ||
1001 + (isArray(chunkValue) ||
1002 typeof chunkValue[ASYNC_ITERATOR] === 'function' ||
1003 chunkValue.$$typeof === REACT_ELEMENT_TYPE) &&
1004 !chunkValue._debugInfo
@@ -898,23 +1016,24 @@ function getOutlinedModel<T>(
1016 return chunkValue;
1017 case PENDING:
1018 case BLOCKED:
901 - case CYCLIC:
902 - const parentChunk = initializingChunk;
903 - chunk.then(
904 - createModelResolver(
905 - parentChunk,
906 - parentObject,
907 - key,
908 - chunk.status === CYCLIC,
909 - response,
910 - map,
911 - path,
912 - ),
913 - createModelReject(parentChunk),
914 - );
915 - return (null: any);
1019 + return waitForReference(chunk, parentObject, key, response, map, path);
1020 default:
917 - throw chunk.reason;
1021 + // This is an error. Instead of erroring directly, we're going to encode this on
1022 + // an initialization handler so that we can catch it at the nearest Element.
1023 + if (initializingHandler) {
1024 + initializingHandler.errored = true;
1025 + initializingHandler.value = chunk.reason;
1026 + } else {
1027 + initializingHandler = {
1028 + parent: null,
1029 + chunk: null,
1030 + value: chunk.reason,
1031 + deps: 0,
1032 + errored: true,
1033 + };
1034 + }
1035 + // Placeholder
1036 + return (null: any);
1037 }
1038 }
1039
@@ -962,6 +1081,19 @@ function parseModelString(
1081 if (value[0] === '$') {
1082 if (value === '$') {
1083 // A very common symbol.
1084 + if (initializingHandler !== null && key === '0') {
1085 + // We we already have an initializing handler and we're abound to enter
1086 + // a new element, we need to shadow it because we're now in a new scope.
1087 + // This is effectively the "begin" or "push" phase of Element parsing.
1088 + // We'll pop later when we parse the array itself.
1089 + initializingHandler = {
1090 + parent: initializingHandler,
1091 + chunk: null,
1092 + value: null,
1093 + deps: 0,
1094 + errored: false,
1095 + };
1096 + }
1097 return REACT_ELEMENT_TYPE;
1098 }
1099 switch (value[1]) {
packages/react-client/src/__tests__/ReactFlight-test.js
+40
@@ -1104,6 +1104,46 @@ describe('ReactFlight', () => {
1104 });
1105 });
1106
1107 + it('should handle serialization errors in element inside error boundary', async () => {
1108 + const ClientErrorBoundary = clientReference(ErrorBoundary);
1109 +
1110 + const expectedStack = __DEV__
1111 + ? '\n in div' + '\n in ErrorBoundary (at **)' + '\n in App'
1112 + : '\n in ErrorBoundary (at **)';
1113 +
1114 + function App() {
1115 + return (
1116 + <ClientErrorBoundary
1117 + expectedMessage="Event handlers cannot be passed to Client Component props."
1118 + expectedStack={expectedStack}>
1119 + <div onClick={function () {}} />
1120 + </ClientErrorBoundary>
1121 + );
1122 + }
1123 +
1124 + const transport = ReactNoopFlightServer.render(<App />, {
1125 + onError(x) {
1126 + if (__DEV__) {
1127 + return 'a dev digest';
1128 + }
1129 + if (x instanceof Error) {
1130 + return `digest("${x.message}")`;
1131 + } else if (Array.isArray(x)) {
1132 + return `digest([])`;
1133 + } else if (typeof x === 'object' && x !== null) {
1134 + return `digest({})`;
1135 + }
1136 + return `digest(${String(x)})`;
1137 + },
1138 + });
1139 +
1140 + await act(() => {
1141 + startTransition(() => {
1142 + ReactNoop.render(ReactNoopFlightClient.read(transport));
1143 + });
1144 + });
1145 + });
1146 +
1147 it('should include server components in warning stacks', async () => {
1148 function Component() {
1149 // Trigger key warning
packages/react-reconciler/src/ReactFiberBeginWork.js
+1 -1
@@ -4129,7 +4129,7 @@ function beginWork(
4129 }
4130 case Throw: {
4131 // This represents a Component that threw in the reconciliation phase.
4132 - // So we'll rethrow here. This might be
4132 + // So we'll rethrow here. This might be a Thenable.
4133 throw workInProgress.pendingProps;
4134 }
4135 }
packages/react-reconciler/src/getComponentNameFromFiber.js
+1 -1
@@ -164,7 +164,7 @@ export default function getComponentNameFromFiber(fiber: Fiber): string | null {
164 break;
165 case Throw: {
166 if (__DEV__) {
167 - // For an error in child position we use the of the inner most parent component.
167 + // For an error in child position we use the name of the inner most parent component.
168 // Whether a Server Component or the parent Fiber.
169 const debugInfo = fiber._debugInfo;
170 if (debugInfo != null) {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+47
@@ -345,6 +345,53 @@ describe('ReactFlightDOMBrowser', () => {
345 expect(container.innerHTML).toBe('<pre>[[1,2,3],[1,2,3]]</pre>');
346 });
347
348 + it('should resolve deduped objects that are themselves blocked', async () => {
349 + let resolveClientComponentChunk;
350 +
351 + const Client = clientExports(
352 + [4, 5],
353 + '42',
354 + '/test.js',
355 + new Promise(resolve => (resolveClientComponentChunk = resolve)),
356 + );
357 +
358 + const shared = [1, 2, 3, Client];
359 +
360 + const stream = await serverAct(() =>
361 + ReactServerDOMServer.renderToReadableStream(
362 + <div>
363 + <Suspense fallback="Loading">
364 + <span>
365 + {shared /* this will serialize first and block nearest element */}
366 + </span>
367 + </Suspense>
368 + {shared /* this will be referenced inside the blocked element */}
369 + </div>,
370 + webpackMap,
371 + ),
372 + );
373 +
374 + function ClientRoot({response}) {
375 + return use(response);
376 + }
377 +
378 + const response = ReactServerDOMClient.createFromReadableStream(stream);
379 + const container = document.createElement('div');
380 + const root = ReactDOMClient.createRoot(container);
381 +
382 + await act(() => {
383 + root.render(<ClientRoot response={response} />);
384 + });
385 +
386 + expect(container.innerHTML).toBe('');
387 +
388 + await act(() => {
389 + resolveClientComponentChunk();
390 + });
391 +
392 + expect(container.innerHTML).toBe('<div><span>12345</span>12345</div>');
393 + });
394 +
395 it('should progressively reveal server components', async () => {
396 let reportedErrors = [];
397
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+8 -7
@@ -224,14 +224,12 @@ describe('ReactFlightDOMEdge', () => {
224 this: {is: 'a large objected'},
225 with: {many: 'properties in it'},
226 };
227 - const props = {
228 - items: new Array(30).fill(obj),
229 - };
227 + const props = {root: <div>{new Array(30).fill(obj)}</div>};
228 const stream = ReactServerDOMServer.renderToReadableStream(props);
229 const [stream1, stream2] = passThrough(stream).tee();
230
231 const serializedContent = await readResult(stream1);
234 - expect(serializedContent.length).toBeLessThan(470);
232 + expect(serializedContent.length).toBeLessThan(1100);
233
234 const result = await ReactServerDOMClient.createFromReadableStream(
235 stream2,
@@ -242,10 +240,13 @@ describe('ReactFlightDOMEdge', () => {
240 },
241 },
242 );
243 + // TODO: Cyclic references currently cause a Lazy wrapper which is not ideal.
244 + const resultElement = result.root._init(result.root._payload);
245 // Should still match the result when parsed
246 - expect(result).toEqual(props);
247 - expect(result.items[5]).toBe(result.items[10]); // two random items are the same instance
248 - // TODO: items[0] is not the same as the others in this case
246 + expect(resultElement).toEqual(props.root);
247 + expect(resultElement.props.children[5]).toBe(
248 + resultElement.props.children[10],
249 + ); // two random items are the same instance
250 });
251
252 it('should execute repeated server components only once', async () => {
packages/react-server/src/ReactFlightServer.js
+33 -21
@@ -1054,13 +1054,14 @@ function renderFunctionComponent<Props>(
1054 request.pendingChunks++;
1055
1056 const componentDebugID = debugID;
1057 - componentDebugInfo = {
1057 + componentDebugInfo = ({
1058 name: componentName,
1059 env: request.environmentName,
1060 owner: owner,
1061 - };
1061 + }: ReactComponentInfo);
1062 if (enableOwnerStacks) {
1063 - (componentDebugInfo: any).stack = stack;
1063 + // $FlowFixMe[cannot-write]
1064 + componentDebugInfo.stack = stack;
1065 }
1066 // We outline this model eagerly so that we can refer to by reference as an owner.
1067 // If we had a smarter way to dedupe we might not have to do this if there ends up
@@ -2076,20 +2077,19 @@ function renderModel(
2077 task.keyPath = prevKeyPath;
2078 task.implicitSlot = prevImplicitSlot;
2079
2080 + // Something errored. We'll still send everything we have up until this point.
2081 + request.pendingChunks++;
2082 + const errorId = request.nextChunkId++;
2083 + const digest = logRecoverableError(request, x);
2084 + emitErrorChunk(request, errorId, digest, x);
2085 if (wasReactNode) {
2080 - // Something errored. We'll still send everything we have up until this point.
2086 // We'll replace this element with a lazy reference that throws on the client
2087 // once it gets rendered.
2083 - request.pendingChunks++;
2084 - const errorId = request.nextChunkId++;
2085 - const digest = logRecoverableError(request, x);
2086 - emitErrorChunk(request, errorId, digest, x);
2088 return serializeLazyID(errorId);
2089 }
2089 - // Something errored but it was not in a React Node. There's no need to serialize
2090 - // it by value because it'll just error the whole parent row anyway so we can
2091 - // just stop any siblings and error the whole parent row.
2092 - throw x;
2090 + // If we don't know if it was a React Node we render a direct reference and let
2091 + // the client deal with it.
2092 + return serializeByValueID(errorId);
2093 }
2094 }
2095
@@ -2117,6 +2117,7 @@ function renderModelDestructive(
2117 if (typeof value === 'object') {
2118 switch ((value: any).$$typeof) {
2119 case REACT_ELEMENT_TYPE: {
2120 + let elementReference = null;
2121 const writtenObjects = request.writtenObjects;
2122 if (task.keyPath !== null || task.implicitSlot) {
2123 // If we're in some kind of context we can't reuse the result of this render or
@@ -2145,10 +2146,8 @@ function renderModelDestructive(
2146 if (parentReference !== undefined) {
2147 // If the parent has a reference, we can refer to this object indirectly
2148 // through the property name inside that parent.
2148 - writtenObjects.set(
2149 - value,
2150 - parentReference + ':' + parentPropertyName,
2151 - );
2149 + elementReference = parentReference + ':' + parentPropertyName;
2150 + writtenObjects.set(value, elementReference);
2151 }
2152 }
2153 }
@@ -2183,7 +2182,7 @@ function renderModelDestructive(
2182 }
2183
2184 // Attempt to render the Server Component.
2186 - return renderElement(
2185 + const newChild = renderElement(
2186 request,
2187 task,
2188 element.type,
@@ -2199,6 +2198,18 @@ function renderModelDestructive(
2198 : null,
2199 __DEV__ && enableOwnerStacks ? element._store.validated : 0,
2200 );
2201 + if (
2202 + typeof newChild === 'object' &&
2203 + newChild !== null &&
2204 + elementReference !== null
2205 + ) {
2206 + // If this element renders another object, we can now refer to that object through
2207 + // the same location as this element.
2208 + if (!writtenObjects.has(newChild)) {
2209 + writtenObjects.set(newChild, elementReference);
2210 + }
2211 + }
2212 + return newChild;
2213 }
2214 case REACT_LAZY_TYPE: {
2215 // Reset the task's thenable state before continuing. If there was one, it was
@@ -2478,15 +2489,16 @@ function renderModelDestructive(
2489 ) {
2490 // This looks like a ReactComponentInfo. We can't serialize the ConsoleTask object so we
2491 // need to omit it before serializing.
2481 - const componentDebugInfo = {
2492 + const componentDebugInfo: Omit<ReactComponentInfo, 'task'> = {
2493 name: value.name,
2494 env: value.env,
2484 - owner: value.owner,
2495 + owner: (value: any).owner,
2496 };
2497 if (enableOwnerStacks) {
2487 - (componentDebugInfo: any).stack = (value: any).stack;
2498 + // $FlowFixMe[cannot-write]
2499 + componentDebugInfo.stack = (value: any).stack;
2500 }
2489 - return (componentDebugInfo: any);
2501 + return componentDebugInfo;
2502 }
2503
2504 if (objectName(value) !== 'Object') {