@samitouri / QOS-React / commits / d415fd3ed7

[Flight] Handle Lazy in `renderDebugModel` (#34536)

If we don't handle Lazy types specifically in `renderDebugModel`, all of their properties will be emitted using `renderDebugModel` as well. This also includes its `_debugInfo` property, if the Lazy comes from the Flight client. That array might contain objects that are deduped, and resolving those references in the client can cause runtime errors, e.g.: ``` TypeError: Cannot read properties of undefined (reading '$$typeof') ``` This happened specifically when an "RSC stream" debug info entry, coming from the Flight client through IO tracking, was emitted and its `debugTask` property was deduped, which couldn't be resolved in the client. To avoid actually initializing a lazy causing a side-effect, we make some assumptions about the structure of its payload, and only emit resolved or rejected values, otherwise we emit a halted chunk.

Hendrik Liebau committed Sep 19, 2025 at 23:38 UTC d415fd3ed716f02f463232341ab21e909e0058ca
1 file changed +64
packages/react-server/src/ReactFlightServer.js
+64
@@ -4702,6 +4702,70 @@ function renderDebugModel(
4702 element._store.validated,
4703 ];
4704 }
4705 + case REACT_LAZY_TYPE: {
4706 + // To avoid actually initializing a lazy causing a side-effect, we make
4707 + // some assumptions about the structure of the payload even though
4708 + // that's not really part of the contract. In practice, this is really
4709 + // just coming from React.lazy helper or Flight.
4710 + const lazy: LazyComponent<any, any> = (value: any);
4711 + const payload = lazy._payload;
4712 +
4713 + if (payload !== null && typeof payload === 'object') {
4714 + // React.lazy constructor
4715 + switch (payload._status) {
4716 + case -1 /* Uninitialized */:
4717 + case 0 /* Pending */:
4718 + break;
4719 + case 1 /* Resolved */: {
4720 + const id = outlineDebugModel(request, counter, payload._result);
4721 + return serializeLazyID(id);
4722 + }
4723 + case 2 /* Rejected */: {
4724 + // We don't log these errors since they didn't actually throw into
4725 + // Flight.
4726 + const digest = '';
4727 + const id = request.nextChunkId++;
4728 + emitErrorChunk(request, id, digest, payload._result, true, null);
4729 + return serializeLazyID(id);
4730 + }
4731 + }
4732 +
4733 + // React Flight
4734 + switch (payload.status) {
4735 + case 'pending':
4736 + case 'blocked':
4737 + case 'resolved_model':
4738 + // The value is an uninitialized model from the Flight client.
4739 + // It's not very useful to emit that.
4740 + break;
4741 + case 'resolved_module':
4742 + // The value is client reference metadata from the Flight client.
4743 + // It's likely for SSR, so we choose not to emit it.
4744 + break;
4745 + case 'fulfilled': {
4746 + const id = outlineDebugModel(request, counter, payload.value);
4747 + return serializeLazyID(id);
4748 + }
4749 + case 'rejected': {
4750 + // We don't log these errors since they didn't actually throw into
4751 + // Flight.
4752 + const digest = '';
4753 + const id = request.nextChunkId++;
4754 + emitErrorChunk(request, id, digest, payload.reason, true, null);
4755 + return serializeLazyID(id);
4756 + }
4757 + }
4758 + }
4759 +
4760 + // We couldn't emit a resolved or rejected value synchronously. For now,
4761 + // we emit this as a halted chunk. TODO: We could maybe also handle
4762 + // pending lazy debug models like we do in serializeDebugThenable,
4763 + // if/when we determine that it's worth the added complexity.
4764 + request.pendingDebugChunks++;
4765 + const id = request.nextChunkId++;
4766 + emitDebugHaltChunk(request, id);
4767 + return serializeLazyID(id);
4768 + }
4769 }
4770
4771 // $FlowFixMe[method-unbinding]