@samitouri / QOS-React-2 / commits / 3cfcdfb307

[Flight] Resolve Deep Cycles (#33664)

Stacked on #33666. If we ever get a future reference to a cycle and that reference gets eagerly parsed before the target has loaded then we can end up with a cycle that never gets resolved. That's because our cycle resolution only works if the cyclic future reference is created synchronously within the parsing path of the child. I haven't been able to construct a normal scenario where this would break. So this doesn't fail any tests. However, I can construct it with debug info since those are eagerly evaluated. It's also a prerequisite if the debug data can come out of order, like if it's on a different stream. The fix here is to make all the internal dependencies in the "listener" list into introspectable objects instead of closures. That way we can traverse the list of dependencies of a blocked reference to see if it ends up in a cycle and therefore skip the reference. It would be nice to address this once and for all to be more resilient to server changes, but I'm not sure if it's worth this complexity and the extra CPU cost of tracing the dependencies. Especially if it's just for debug data. closes #32316 fixes vercel/next.js#72104 --------- Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>

Sebastian Markbåge committed Jun 29, 2025 at 10:56 UTC 3cfcdfb30720a5b8de0e981c8fdabec1abb61588
2 files changed +422 -162
packages/react-client/src/ReactFlightClient.js
+385 -162
@@ -165,16 +165,16 @@ const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection
165
166 type PendingChunk<T> = {
167 status: 'pending',
168 - value: null | Array<(T) => mixed>,
169 - reason: null | Array<(mixed) => mixed>,
168 + value: null | Array<InitializationReference | (T => mixed)>,
169 + reason: null | Array<InitializationReference | (mixed => mixed)>,
170 _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
171 _debugInfo?: null | ReactDebugInfo, // DEV-only
172 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
173 };
174 type BlockedChunk<T> = {
175 status: 'blocked',
176 - value: null | Array<(T) => mixed>,
177 - reason: null | Array<(mixed) => mixed>,
176 + value: null | Array<InitializationReference | (T => mixed)>,
177 + reason: null | Array<InitializationReference | (mixed => mixed)>,
178 _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
179 _debugInfo?: null | ReactDebugInfo, // DEV-only
180 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
@@ -269,11 +269,7 @@ ReactPromise.prototype.then = function <T>(
269 initializeModuleChunk(chunk);
270 break;
271 }
272 - if (
273 - __DEV__ &&
274 - enableAsyncDebugInfo &&
275 - (typeof resolve !== 'function' || !(resolve: any).isReactInternalListener)
276 - ) {
272 + if (__DEV__ && enableAsyncDebugInfo) {
273 // Because only native Promises get picked up when we're awaiting we need to wrap
274 // this in a native Promise in DEV. This means that these callbacks are no longer sync
275 // but the lazy initialization is still sync and the .value can be inspected after,
@@ -297,19 +293,23 @@ ReactPromise.prototype.then = function <T>(
293 // The status might have changed after initialization.
294 switch (chunk.status) {
295 case INITIALIZED:
300 - resolve(chunk.value);
296 + if (typeof resolve === 'function') {
297 + resolve(chunk.value);
298 + }
299 break;
300 case PENDING:
301 case BLOCKED:
304 - if (resolve) {
302 + if (typeof resolve === 'function') {
303 if (chunk.value === null) {
306 - chunk.value = ([]: Array<(T) => mixed>);
304 + chunk.value = ([]: Array<InitializationReference | (T => mixed)>);
305 }
306 chunk.value.push(resolve);
307 }
310 - if (reject) {
308 + if (typeof reject === 'function') {
309 if (chunk.reason === null) {
312 - chunk.reason = ([]: Array<(mixed) => mixed>);
310 + chunk.reason = ([]: Array<
311 + InitializationReference | (mixed => mixed),
312 + >);
313 }
314 chunk.reason.push(reject);
315 }
@@ -318,7 +318,7 @@ ReactPromise.prototype.then = function <T>(
318 break;
319 }
320 default:
321 - if (reject) {
321 + if (typeof reject === 'function') {
322 reject(chunk.reason);
323 }
324 break;
@@ -408,24 +408,96 @@ function createErrorChunk<T>(
408 return new ReactPromise(ERRORED, null, error);
409 }
410
411 -function wakeChunk<T>(listeners: Array<(T) => mixed>, value: T): void {
411 +function wakeChunk<T>(
412 + listeners: Array<InitializationReference | (T => mixed)>,
413 + value: T,
414 +): void {
415 for (let i = 0; i < listeners.length; i++) {
416 const listener = listeners[i];
414 - listener(value);
417 + if (typeof listener === 'function') {
418 + listener(value);
419 + } else {
420 + fulfillReference(listener, value);
421 + }
422 }
423 }
424
425 +function rejectChunk(
426 + listeners: Array<InitializationReference | (mixed => mixed)>,
427 + error: mixed,
428 +): void {
429 + for (let i = 0; i < listeners.length; i++) {
430 + const listener = listeners[i];
431 + if (typeof listener === 'function') {
432 + listener(error);
433 + } else {
434 + rejectReference(listener, error);
435 + }
436 + }
437 +}
438 +
439 +function resolveBlockedCycle<T>(
440 + resolvedChunk: SomeChunk<T>,
441 + reference: InitializationReference,
442 +): null | InitializationHandler {
443 + const referencedChunk = reference.handler.chunk;
444 + if (referencedChunk === null) {
445 + return null;
446 + }
447 + if (referencedChunk === resolvedChunk) {
448 + // We found the cycle. We can resolve the blocked cycle now.
449 + return reference.handler;
450 + }
451 + const resolveListeners = referencedChunk.value;
452 + if (resolveListeners !== null) {
453 + for (let i = 0; i < resolveListeners.length; i++) {
454 + const listener = resolveListeners[i];
455 + if (typeof listener !== 'function') {
456 + const foundHandler = resolveBlockedCycle(resolvedChunk, listener);
457 + if (foundHandler !== null) {
458 + return foundHandler;
459 + }
460 + }
461 + }
462 + }
463 + return null;
464 +}
465 +
466 function wakeChunkIfInitialized<T>(
467 chunk: SomeChunk<T>,
420 - resolveListeners: Array<(T) => mixed>,
421 - rejectListeners: null | Array<(mixed) => mixed>,
468 + resolveListeners: Array<InitializationReference | (T => mixed)>,
469 + rejectListeners: null | Array<InitializationReference | (mixed => mixed)>,
470 ): void {
471 switch (chunk.status) {
472 case INITIALIZED:
473 wakeChunk(resolveListeners, chunk.value);
474 break;
427 - case PENDING:
475 case BLOCKED:
476 + // It is possible that we're blocked on our own chunk if it's a cycle.
477 + // Before adding back the listeners to the chunk, let's check if it would
478 + // result in a cycle.
479 + for (let i = 0; i < resolveListeners.length; i++) {
480 + const listener = resolveListeners[i];
481 + if (typeof listener !== 'function') {
482 + const reference: InitializationReference = listener;
483 + const cyclicHandler = resolveBlockedCycle(chunk, reference);
484 + if (cyclicHandler !== null) {
485 + // This reference points back to this chunk. We can resolve the cycle by
486 + // using the value from that handler.
487 + fulfillReference(reference, cyclicHandler.value);
488 + resolveListeners.splice(i, 1);
489 + i--;
490 + if (rejectListeners !== null) {
491 + const rejectionIdx = rejectListeners.indexOf(reference);
492 + if (rejectionIdx !== -1) {
493 + rejectListeners.splice(rejectionIdx, 1);
494 + }
495 + }
496 + }
497 + }
498 + }
499 + // Fallthrough
500 + case PENDING:
501 if (chunk.value) {
502 for (let i = 0; i < resolveListeners.length; i++) {
503 chunk.value.push(resolveListeners[i]);
@@ -447,7 +519,7 @@ function wakeChunkIfInitialized<T>(
519 break;
520 case ERRORED:
521 if (rejectListeners) {
450 - wakeChunk(rejectListeners, chunk.reason);
522 + rejectChunk(rejectListeners, chunk.reason);
523 }
524 break;
525 }
@@ -468,7 +540,7 @@ function triggerErrorOnChunk<T>(chunk: SomeChunk<T>, error: mixed): void {
540 erroredChunk.status = ERRORED;
541 erroredChunk.reason = error;
542 if (listeners !== null) {
471 - wakeChunk(listeners, error);
543 + rejectChunk(listeners, error);
544 }
545 }
546
@@ -598,6 +670,19 @@ function resolveModuleChunk<T>(
670 }
671 }
672
673 +type InitializationReference = {
674 + response: Response, // TODO: Remove Response from here and pass it through instead.
675 + handler: InitializationHandler,
676 + parentObject: Object,
677 + key: string,
678 + map: (
679 + response: Response,
680 + model: any,
681 + parentObject: Object,
682 + key: string,
683 + ) => any,
684 + path: Array<string>,
685 +};
686 type InitializationHandler = {
687 parent: null | InitializationHandler,
688 chunk: null | BlockedChunk<any>,
@@ -998,8 +1083,191 @@ function getChunk(response: Response, id: number): SomeChunk<any> {
1083 return chunk;
1084 }
1085
1086 +function fulfillReference(
1087 + reference: InitializationReference,
1088 + value: any,
1089 +): void {
1090 + const {response, handler, parentObject, key, map, path} = reference;
1091 +
1092 + for (let i = 1; i < path.length; i++) {
1093 + while (value.$$typeof === REACT_LAZY_TYPE) {
1094 + // We never expect to see a Lazy node on this path because we encode those as
1095 + // separate models. This must mean that we have inserted an extra lazy node
1096 + // e.g. to replace a blocked element. We must instead look for it inside.
1097 + const referencedChunk: SomeChunk<any> = value._payload;
1098 + if (referencedChunk === handler.chunk) {
1099 + // This is a reference to the thing we're currently blocking. We can peak
1100 + // inside of it to get the value.
1101 + value = handler.value;
1102 + continue;
1103 + } else {
1104 + switch (referencedChunk.status) {
1105 + case RESOLVED_MODEL:
1106 + initializeModelChunk(referencedChunk);
1107 + break;
1108 + case RESOLVED_MODULE:
1109 + initializeModuleChunk(referencedChunk);
1110 + break;
1111 + }
1112 + switch (referencedChunk.status) {
1113 + case INITIALIZED: {
1114 + value = referencedChunk.value;
1115 + continue;
1116 + }
1117 + case BLOCKED: {
1118 + // It is possible that we're blocked on our own chunk if it's a cycle.
1119 + // Before adding the listener to the inner chunk, let's check if it would
1120 + // result in a cycle.
1121 + const cyclicHandler = resolveBlockedCycle(
1122 + referencedChunk,
1123 + reference,
1124 + );
1125 + if (cyclicHandler !== null) {
1126 + // This reference points back to this chunk. We can resolve the cycle by
1127 + // using the value from that handler.
1128 + value = cyclicHandler.value;
1129 + continue;
1130 + }
1131 + // Fallthrough
1132 + }
1133 + case PENDING: {
1134 + // If we're not yet initialized we need to skip what we've already drilled
1135 + // through and then wait for the next value to become available.
1136 + path.splice(0, i - 1);
1137 + // Add "listener" to our new chunk dependency.
1138 + if (referencedChunk.value === null) {
1139 + referencedChunk.value = [reference];
1140 + } else {
1141 + referencedChunk.value.push(reference);
1142 + }
1143 + if (referencedChunk.reason === null) {
1144 + referencedChunk.reason = [reference];
1145 + } else {
1146 + referencedChunk.reason.push(reference);
1147 + }
1148 + return;
1149 + }
1150 + case HALTED: {
1151 + // Do nothing. We couldn't fulfill.
1152 + // TODO: Mark downstreams as halted too.
1153 + return;
1154 + }
1155 + default: {
1156 + rejectReference(reference, referencedChunk.reason);
1157 + return;
1158 + }
1159 + }
1160 + }
1161 + }
1162 + value = value[path[i]];
1163 + }
1164 + const mappedValue = map(response, value, parentObject, key);
1165 + parentObject[key] = mappedValue;
1166 +
1167 + // If this is the root object for a model reference, where `handler.value`
1168 + // is a stale `null`, the resolved value can be used directly.
1169 + if (key === '' && handler.value === null) {
1170 + handler.value = mappedValue;
1171 + }
1172 +
1173 + // If the parent object is an unparsed React element tuple, we also need to
1174 + // update the props and owner of the parsed element object (i.e.
1175 + // handler.value).
1176 + if (
1177 + parentObject[0] === REACT_ELEMENT_TYPE &&
1178 + typeof handler.value === 'object' &&
1179 + handler.value !== null &&
1180 + handler.value.$$typeof === REACT_ELEMENT_TYPE
1181 + ) {
1182 + const element: any = handler.value;
1183 + switch (key) {
1184 + case '3':
1185 + element.props = mappedValue;
1186 + break;
1187 + case '4':
1188 + if (__DEV__) {
1189 + element._owner = mappedValue;
1190 + }
1191 + break;
1192 + case '5':
1193 + if (__DEV__) {
1194 + element._debugStack = mappedValue;
1195 + }
1196 + break;
1197 + }
1198 + }
1199 +
1200 + handler.deps--;
1201 +
1202 + if (handler.deps === 0) {
1203 + const chunk = handler.chunk;
1204 + if (chunk === null || chunk.status !== BLOCKED) {
1205 + return;
1206 + }
1207 + const resolveListeners = chunk.value;
1208 + const initializedChunk: InitializedChunk<any> = (chunk: any);
1209 + initializedChunk.status = INITIALIZED;
1210 + initializedChunk.value = handler.value;
1211 + if (resolveListeners !== null) {
1212 + wakeChunk(resolveListeners, handler.value);
1213 + }
1214 + }
1215 +}
1216 +
1217 +function rejectReference(
1218 + reference: InitializationReference,
1219 + error: mixed,
1220 +): void {
1221 + const {handler} = reference;
1222 +
1223 + if (handler.errored) {
1224 + // We've already errored. We could instead build up an AggregateError
1225 + // but if there are multiple errors we just take the first one like
1226 + // Promise.all.
1227 + return;
1228 + }
1229 + const blockedValue = handler.value;
1230 + handler.errored = true;
1231 + handler.value = error;
1232 + const chunk = handler.chunk;
1233 + if (chunk === null || chunk.status !== BLOCKED) {
1234 + return;
1235 + }
1236 +
1237 + if (__DEV__) {
1238 + if (
1239 + typeof blockedValue === 'object' &&
1240 + blockedValue !== null &&
1241 + blockedValue.$$typeof === REACT_ELEMENT_TYPE
1242 + ) {
1243 + const element = blockedValue;
1244 + // Conceptually the error happened inside this Element but right before
1245 + // it was rendered. We don't have a client side component to render but
1246 + // we can add some DebugInfo to explain that this was conceptually a
1247 + // Server side error that errored inside this element. That way any stack
1248 + // traces will point to the nearest JSX that errored - e.g. during
1249 + // serialization.
1250 + const erroredComponent: ReactComponentInfo = {
1251 + name: getComponentNameFromType(element.type) || '',
1252 + owner: element._owner,
1253 + };
1254 + // $FlowFixMe[cannot-write]
1255 + erroredComponent.debugStack = element._debugStack;
1256 + if (supportsCreateTask) {
1257 + // $FlowFixMe[cannot-write]
1258 + erroredComponent.debugTask = element._debugTask;
1259 + }
1260 + const chunkDebugInfo: ReactDebugInfo =
1261 + chunk._debugInfo || (chunk._debugInfo = []);
1262 + chunkDebugInfo.push(erroredComponent);
1263 + }
1264 + }
1265 +
1266 + triggerErrorOnChunk(chunk, error);
1267 +}
1268 +
1269 function waitForReference<T>(
1002 - referencedChunk: SomeChunk<T>,
1270 + referencedChunk: PendingChunk<T> | BlockedChunk<T>,
1271 parentObject: Object,
1272 key: string,
1273 response: Response,
@@ -1020,137 +1288,27 @@ function waitForReference<T>(
1288 };
1289 }
1290
1023 - function fulfill(value: any): void {
1024 - for (let i = 1; i < path.length; i++) {
1025 - while (value.$$typeof === REACT_LAZY_TYPE) {
1026 - // We never expect to see a Lazy node on this path because we encode those as
1027 - // separate models. This must mean that we have inserted an extra lazy node
1028 - // e.g. to replace a blocked element. We must instead look for it inside.
1029 - const chunk: SomeChunk<any> = value._payload;
1030 - if (chunk === handler.chunk) {
1031 - // This is a reference to the thing we're currently blocking. We can peak
1032 - // inside of it to get the value.
1033 - value = handler.value;
1034 - continue;
1035 - } else if (chunk.status === INITIALIZED) {
1036 - value = chunk.value;
1037 - continue;
1038 - } else {
1039 - // If we're not yet initialized we need to skip what we've already drilled
1040 - // through and then wait for the next value to become available.
1041 - path.splice(0, i - 1);
1042 - chunk.then(fulfill, reject);
1043 - return;
1044 - }
1045 - }
1046 - value = value[path[i]];
1047 - }
1048 - const mappedValue = map(response, value, parentObject, key);
1049 - parentObject[key] = mappedValue;
1050 -
1051 - // If this is the root object for a model reference, where `handler.value`
1052 - // is a stale `null`, the resolved value can be used directly.
1053 - if (key === '' && handler.value === null) {
1054 - handler.value = mappedValue;
1055 - }
1056 -
1057 - // If the parent object is an unparsed React element tuple, we also need to
1058 - // update the props and owner of the parsed element object (i.e.
1059 - // handler.value).
1060 - if (
1061 - parentObject[0] === REACT_ELEMENT_TYPE &&
1062 - typeof handler.value === 'object' &&
1063 - handler.value !== null &&
1064 - handler.value.$$typeof === REACT_ELEMENT_TYPE
1065 - ) {
1066 - const element: any = handler.value;
1067 - switch (key) {
1068 - case '3':
1069 - element.props = mappedValue;
1070 - break;
1071 - case '4':
1072 - if (__DEV__) {
1073 - element._owner = mappedValue;
1074 - }
1075 - break;
1076 - case '5':
1077 - if (__DEV__) {
1078 - element._debugStack = mappedValue;
1079 - }
1080 - break;
1081 - }
1082 - }
1083 -
1084 - handler.deps--;
1291 + const reference: InitializationReference = {
1292 + response,
1293 + handler,
1294 + parentObject,
1295 + key,
1296 + map,
1297 + path,
1298 + };
1299
1086 - if (handler.deps === 0) {
1087 - const chunk = handler.chunk;
1088 - if (chunk === null || chunk.status !== BLOCKED) {
1089 - return;
1090 - }
1091 - const resolveListeners = chunk.value;
1092 - const initializedChunk: InitializedChunk<T> = (chunk: any);
1093 - initializedChunk.status = INITIALIZED;
1094 - initializedChunk.value = handler.value;
1095 - if (resolveListeners !== null) {
1096 - wakeChunk(resolveListeners, handler.value);
1097 - }
1098 - }
1099 - }
1100 - // Use to avoid the microtask resolution in DEV.
1101 - if (__DEV__ && enableAsyncDebugInfo) {
1102 - (fulfill: any).isReactInternalListener = true;
1300 + // Add "listener".
1301 + if (referencedChunk.value === null) {
1302 + referencedChunk.value = [reference];
1303 + } else {
1304 + referencedChunk.value.push(reference);
1305 }
1104 -
1105 - function reject(error: mixed): void {
1106 - if (handler.errored) {
1107 - // We've already errored. We could instead build up an AggregateError
1108 - // but if there are multiple errors we just take the first one like
1109 - // Promise.all.
1110 - return;
1111 - }
1112 - const blockedValue = handler.value;
1113 - handler.errored = true;
1114 - handler.value = error;
1115 - const chunk = handler.chunk;
1116 - if (chunk === null || chunk.status !== BLOCKED) {
1117 - return;
1118 - }
1119 -
1120 - if (__DEV__) {
1121 - if (
1122 - typeof blockedValue === 'object' &&
1123 - blockedValue !== null &&
1124 - blockedValue.$$typeof === REACT_ELEMENT_TYPE
1125 - ) {
1126 - const element = blockedValue;
1127 - // Conceptually the error happened inside this Element but right before
1128 - // it was rendered. We don't have a client side component to render but
1129 - // we can add some DebugInfo to explain that this was conceptually a
1130 - // Server side error that errored inside this element. That way any stack
1131 - // traces will point to the nearest JSX that errored - e.g. during
1132 - // serialization.
1133 - const erroredComponent: ReactComponentInfo = {
1134 - name: getComponentNameFromType(element.type) || '',
1135 - owner: element._owner,
1136 - };
1137 - // $FlowFixMe[cannot-write]
1138 - erroredComponent.debugStack = element._debugStack;
1139 - if (supportsCreateTask) {
1140 - // $FlowFixMe[cannot-write]
1141 - erroredComponent.debugTask = element._debugTask;
1142 - }
1143 - const chunkDebugInfo: ReactDebugInfo =
1144 - chunk._debugInfo || (chunk._debugInfo = []);
1145 - chunkDebugInfo.push(erroredComponent);
1146 - }
1147 - }
1148 -
1149 - triggerErrorOnChunk(chunk, error);
1306 + if (referencedChunk.reason === null) {
1307 + referencedChunk.reason = [reference];
1308 + } else {
1309 + referencedChunk.reason.push(reference);
1310 }
1311
1152 - referencedChunk.then(fulfill, reject);
1153 -
1312 // Return a place holder value for now.
1313 return (null: any);
1314 }
@@ -1363,17 +1521,65 @@ function getOutlinedModel<T>(
1521 for (let i = 1; i < path.length; i++) {
1522 while (value.$$typeof === REACT_LAZY_TYPE) {
1523 const referencedChunk: SomeChunk<any> = value._payload;
1366 - if (referencedChunk.status === INITIALIZED) {
1367 - value = referencedChunk.value;
1368 - } else {
1369 - return waitForReference(
1370 - referencedChunk,
1371 - parentObject,
1372 - key,
1373 - response,
1374 - map,
1375 - path.slice(i - 1),
1376 - );
1524 + switch (referencedChunk.status) {
1525 + case RESOLVED_MODEL:
1526 + initializeModelChunk(referencedChunk);
1527 + break;
1528 + case RESOLVED_MODULE:
1529 + initializeModuleChunk(referencedChunk);
1530 + break;
1531 + }
1532 + switch (referencedChunk.status) {
1533 + case INITIALIZED: {
1534 + value = referencedChunk.value;
1535 + break;
1536 + }
1537 + case BLOCKED:
1538 + case PENDING: {
1539 + return waitForReference(
1540 + referencedChunk,
1541 + parentObject,
1542 + key,
1543 + response,
1544 + map,
1545 + path.slice(i - 1),
1546 + );
1547 + }
1548 + case HALTED: {
1549 + // Add a dependency that will never resolve.
1550 + // TODO: Mark downstreams as halted too.
1551 + let handler: InitializationHandler;
1552 + if (initializingHandler) {
1553 + handler = initializingHandler;
1554 + handler.deps++;
1555 + } else {
1556 + handler = initializingHandler = {
1557 + parent: null,
1558 + chunk: null,
1559 + value: null,
1560 + deps: 1,
1561 + errored: false,
1562 + };
1563 + }
1564 + return (null: any);
1565 + }
1566 + default: {
1567 + // This is an error. Instead of erroring directly, we're going to encode this on
1568 + // an initialization handler so that we can catch it at the nearest Element.
1569 + if (initializingHandler) {
1570 + initializingHandler.errored = true;
1571 + initializingHandler.value = referencedChunk.reason;
1572 + } else {
1573 + initializingHandler = {
1574 + parent: null,
1575 + chunk: null,
1576 + value: referencedChunk.reason,
1577 + deps: 0,
1578 + errored: true,
1579 + };
1580 + }
1581 + return (null: any);
1582 + }
1583 }
1584 }
1585 value = value[path[i]];
@@ -1408,8 +1614,25 @@ function getOutlinedModel<T>(
1614 return chunkValue;
1615 case PENDING:
1616 case BLOCKED:
1411 - case HALTED:
1617 return waitForReference(chunk, parentObject, key, response, map, path);
1618 + case HALTED: {
1619 + // Add a dependency that will never resolve.
1620 + // TODO: Mark downstreams as halted too.
1621 + let handler: InitializationHandler;
1622 + if (initializingHandler) {
1623 + handler = initializingHandler;
1624 + handler.deps++;
1625 + } else {
1626 + handler = initializingHandler = {
1627 + parent: null,
1628 + chunk: null,
1629 + value: null,
1630 + deps: 1,
1631 + errored: false,
1632 + };
1633 + }
1634 + return (null: any);
1635 + }
1636 default:
1637 // This is an error. Instead of erroring directly, we're going to encode this on
1638 // an initialization handler so that we can catch it at the nearest Element.
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+37
@@ -2624,4 +2624,41 @@ describe('ReactFlightDOMBrowser', () => {
2624 expect(responseFoo.bar).toBe(responseBar);
2625 expect(Array.from(responseBar)[0]).toBe(responseFoo);
2626 });
2627 +
2628 + it('should resolve deduped references in maps used in client component props', async () => {
2629 + const ClientComponent = clientExports(function ClientComponent({
2630 + shared,
2631 + map,
2632 + }) {
2633 + expect(map.get(42)).toBe(shared);
2634 + return JSON.stringify({shared, map: Array.from(map)});
2635 + });
2636 +
2637 + function Server() {
2638 + const shared = {id: 42};
2639 + const map = new Map([[42, shared]]);
2640 +
2641 + return <ClientComponent shared={shared} map={map} />;
2642 + }
2643 +
2644 + const stream = await serverAct(() =>
2645 + ReactServerDOMServer.renderToReadableStream(<Server />, webpackMap),
2646 + );
2647 +
2648 + function ClientRoot({response}) {
2649 + return use(response);
2650 + }
2651 +
2652 + const response = ReactServerDOMClient.createFromReadableStream(stream);
2653 + const container = document.createElement('div');
2654 + const root = ReactDOMClient.createRoot(container);
2655 +
2656 + await act(() => {
2657 + root.render(<ClientRoot response={response} />);
2658 + });
2659 +
2660 + expect(container.innerHTML).toBe(
2661 + '{"shared":{"id":42},"map":[[42,{"id":42}]]}',
2662 + );
2663 + });
2664 });