@samitouri / QOS-React / commits / 288d428af1

[DevTools] Only show the highest end/byteSize I/O of RSC streams (#34435)

Stacked on #34425. RSC stream info is split into one I/O entry per chunk. This means that when a single instance or boundary depends on multiple chunks, it'll show the same stream multiple times. This makes it so just the last one is shown. This is a special case for the name "RSC stream" but ideally we'd more explicitly model the concept of awaiting only part of a stream. <img width="667" height="427" alt="Screenshot 2025-09-09 at 2 09 43 PM" src="https://github.com/user-attachments/assets/890f6f61-4657-4ca9-82fd-df55a696bacc" /> Another remaining issue is that it's possible for an intermediate chunk to be depended on by just a child boundary. In that case that can be considered a "unique suspender" even though the parent depends on a later one. Ideally it would dedupe on everything below. Could also model it as every Promise depends on its chunk and every previous chunk.

Sebastian Markbåge committed Sep 10, 2025 at 09:08 UTC 288d428af1bcce6b89d004aed9f5c9d24c4bbd33
1 file changed +96 -13
packages/react-devtools-shared/src/backend/fiber/renderer.js
+96 -13
@@ -5733,6 +5733,15 @@ export function attach(
5733 // to a specific instance will have those appear in order of when that instance was discovered.
5734 let hooksCacheKey: null | DevToolsInstance = null;
5735 let hooksCache: null | HooksTree = null;
5736 + // Collect the stream entries with the highest byte offset and end time.
5737 + const streamEntries: Map<
5738 + Promise<mixed>,
5739 + {
5740 + asyncInfo: ReactAsyncInfo,
5741 + instance: DevToolsInstance,
5742 + hooks: null | HooksTree,
5743 + },
5744 + > = new Map();
5745 suspenseNode.suspendedBy.forEach((set, ioInfo) => {
5746 let parentNode = suspenseNode.parent;
5747 while (parentNode !== null) {
@@ -5771,9 +5780,92 @@ export function attach(
5780 }
5781 }
5782 }
5774 - result.push(serializeAsyncInfo(asyncInfo, firstInstance, hooks));
5783 + const newIO = asyncInfo.awaited;
5784 + if (newIO.name === 'RSC stream' && newIO.value != null) {
5785 + const streamPromise = newIO.value;
5786 + // Special case RSC stream entries to pick the last entry keyed by the stream.
5787 + const existingEntry = streamEntries.get(streamPromise);
5788 + if (existingEntry === undefined) {
5789 + streamEntries.set(streamPromise, {
5790 + asyncInfo,
5791 + instance: firstInstance,
5792 + hooks,
5793 + });
5794 + } else {
5795 + const existingIO = existingEntry.asyncInfo.awaited;
5796 + if (
5797 + newIO !== existingIO &&
5798 + ((newIO.byteSize !== undefined &&
5799 + existingIO.byteSize !== undefined &&
5800 + newIO.byteSize > existingIO.byteSize) ||
5801 + newIO.end > existingIO.end)
5802 + ) {
5803 + // The new entry is later in the stream that the old entry. Replace it.
5804 + existingEntry.asyncInfo = asyncInfo;
5805 + existingEntry.instance = firstInstance;
5806 + existingEntry.hooks = hooks;
5807 + }
5808 + }
5809 + } else {
5810 + result.push(serializeAsyncInfo(asyncInfo, firstInstance, hooks));
5811 + }
5812 + }
5813 + }
5814 + });
5815 + // Add any deduped stream entries.
5816 + streamEntries.forEach(({asyncInfo, instance, hooks}) => {
5817 + result.push(serializeAsyncInfo(asyncInfo, instance, hooks));
5818 + });
5819 + return result;
5820 + }
5821 +
5822 + function getSuspendedByOfInstance(
5823 + devtoolsInstance: DevToolsInstance,
5824 + hooks: null | HooksTree,
5825 + ): Array<SerializedAsyncInfo> {
5826 + const suspendedBy = devtoolsInstance.suspendedBy;
5827 + if (suspendedBy === null) {
5828 + return [];
5829 + }
5830 +
5831 + const foundIOEntries: Set<ReactIOInfo> = new Set();
5832 + const streamEntries: Map<Promise<mixed>, ReactAsyncInfo> = new Map();
5833 + const result: Array<SerializedAsyncInfo> = [];
5834 + for (let i = 0; i < suspendedBy.length; i++) {
5835 + const asyncInfo = suspendedBy[i];
5836 + const ioInfo = asyncInfo.awaited;
5837 + if (foundIOEntries.has(ioInfo)) {
5838 + // We have already added this I/O entry to the result. We can dedupe it.
5839 + // This can happen when an instance depends on the same data in mutliple places.
5840 + continue;
5841 + }
5842 + foundIOEntries.add(ioInfo);
5843 + if (ioInfo.name === 'RSC stream' && ioInfo.value != null) {
5844 + const streamPromise = ioInfo.value;
5845 + // Special case RSC stream entries to pick the last entry keyed by the stream.
5846 + const existingEntry = streamEntries.get(streamPromise);
5847 + if (existingEntry === undefined) {
5848 + streamEntries.set(streamPromise, asyncInfo);
5849 + } else {
5850 + const existingIO = existingEntry.awaited;
5851 + if (
5852 + ioInfo !== existingIO &&
5853 + ((ioInfo.byteSize !== undefined &&
5854 + existingIO.byteSize !== undefined &&
5855 + ioInfo.byteSize > existingIO.byteSize) ||
5856 + ioInfo.end > existingIO.end)
5857 + ) {
5858 + // The new entry is later in the stream that the old entry. Replace it.
5859 + streamEntries.set(streamPromise, asyncInfo);
5860 + }
5861 }
5862 + } else {
5863 + result.push(serializeAsyncInfo(asyncInfo, devtoolsInstance, hooks));
5864 }
5865 + }
5866 + // Add any deduped stream entries.
5867 + streamEntries.forEach(asyncInfo => {
5868 + result.push(serializeAsyncInfo(asyncInfo, devtoolsInstance, hooks));
5869 });
5870 return result;
5871 }
@@ -6297,11 +6389,7 @@ export function attach(
6389 // In this case, this becomes associated with the Client/Host Component where as normally
6390 // you'd expect these to be associated with the Server Component that awaited the data.
6391 // TODO: Prepend other suspense sources like css, images and use().
6300 - fiberInstance.suspendedBy === null
6301 - ? []
6302 - : fiberInstance.suspendedBy.map(info =>
6303 - serializeAsyncInfo(info, fiberInstance, hooks),
6304 - );
6392 + getSuspendedByOfInstance(fiberInstance, hooks);
6393 const suspendedByRange = getSuspendedByRange(
6394 getNearestSuspenseNode(fiberInstance),
6395 );
@@ -6446,7 +6534,7 @@ export function attach(
6534
6535 const isSuspended = null;
6536 // Things that Suspended this Server Component (use(), awaits and direct child promises)
6449 - const suspendedBy = virtualInstance.suspendedBy;
6537 + const suspendedBy = getSuspendedByOfInstance(virtualInstance, null);
6538 const suspendedByRange = getSuspendedByRange(
6539 getNearestSuspenseNode(virtualInstance),
6540 );
@@ -6497,12 +6585,7 @@ export function attach(
6585 ? []
6586 : Array.from(componentLogsEntry.warnings.entries()),
6587
6500 - suspendedBy:
6501 - suspendedBy === null
6502 - ? []
6503 - : suspendedBy.map(info =>
6504 - serializeAsyncInfo(info, virtualInstance, null),
6505 - ),
6588 + suspendedBy: suspendedBy,
6589 suspendedByRange: suspendedByRange,
6590 unknownSuspenders: UNKNOWN_SUSPENDERS_NONE,
6591