@samitouri / QOS-React / commits / 6a51a9fea6

[DevTools] Track Server Environment Names of Each SuspenseNode (#34605)

Tracks the environment names of the I/O in each SuspenseNode and sent it to the front end when the suspenders change. In the front end, every child boundary should really be treated as it has all environment names of the parents too since they're blocked by the parent too. We could do this tracking on backend but if there's ever one added on the root would need to be send for every child. This lets us highlight which subtrees are blocked by content on the server. --------- Co-authored-by: Sebastian "Sebbie" Silbermann <silbermann.sebastian@gmail.com>

Sebastian Markbåge committed Sep 26, 2025 at 09:43 UTC 6a51a9fea6b5dc6c83f428b09b160a793cf92f15
4 files changed +92 -20
packages/react-devtools-shared/src/backend/fiber/renderer.js
+51 -2
@@ -299,6 +299,7 @@ type SuspenseNode = {
299 nextSibling: null | SuspenseNode,
300 rects: null | Array<Rect>, // The bounding rects of content children.
301 suspendedBy: Map<ReactIOInfo, Set<DevToolsInstance>>, // Tracks which data we're suspended by and the children that suspend it.
302 + environments: Map<string, number>, // Tracks the Flight environment names that suspended this. I.e. if the server blocked this.
303 // Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all
304 // also in the parent sets. This determine whether this could contribute in the loading sequence.
305 hasUniqueSuspenders: boolean,
@@ -327,6 +328,7 @@ function createSuspenseNode(
328 nextSibling: null,
329 rects: null,
330 suspendedBy: new Map(),
331 + environments: new Map(),
332 hasUniqueSuspenders: false,
333 hasUnknownSuspenders: false,
334 });
@@ -2220,6 +2222,10 @@ export function attach(
2222 }
2223 operations[i++] = fiberIdWithChanges;
2224 operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
2225 + operations[i++] = suspense.environments.size;
2226 + suspense.environments.forEach((count, env) => {
2227 + operations[i++] = getStringID(env);
2228 + });
2229 });
2230 }
2231
@@ -2725,6 +2731,13 @@ export function attach(
2731 return;
2732 }
2733
2734 + // TODO: Just enqueue the operations here instead of stashing by id.
2735 +
2736 + // Ensure each environment gets recorded in the string table since it is emitted
2737 + // before we loop it over again later during flush.
2738 + suspenseNode.environments.forEach((count, env) => {
2739 + getStringID(env);
2740 + });
2741 pendingSuspenderChanges.add(fiberInstance.id);
2742 }
2743
@@ -2807,7 +2820,20 @@ export function attach(
2820 let suspendedBySet = suspenseNodeSuspendedBy.get(ioInfo);
2821 if (suspendedBySet === undefined) {
2822 suspendedBySet = new Set();
2810 - suspenseNodeSuspendedBy.set(asyncInfo.awaited, suspendedBySet);
2823 + suspenseNodeSuspendedBy.set(ioInfo, suspendedBySet);
2824 + // We've added a dependency. We must increment the ref count of the environment.
2825 + const env = ioInfo.env;
2826 + if (env != null) {
2827 + const environmentCounts = parentSuspenseNode.environments;
2828 + const count = environmentCounts.get(env);
2829 + if (count === undefined || count === 0) {
2830 + environmentCounts.set(env, 1);
2831 + // We've discovered a new environment for this SuspenseNode. We'll to update the node.
2832 + recordSuspenseSuspenders(parentSuspenseNode);
2833 + } else {
2834 + environmentCounts.set(env, count + 1);
2835 + }
2836 + }
2837 }
2838 // The child of the Suspense boundary that was suspended on this, or null if suspended at the root.
2839 // This is used to keep track of how many dependents are still alive and also to get information
@@ -2897,6 +2923,7 @@ export function attach(
2923 : instance.suspenseNode;
2924 if (previousSuspendedBy !== null && suspenseNode !== null) {
2925 const nextSuspendedBy = instance.suspendedBy;
2926 + let changedEnvironment = false;
2927 for (let i = 0; i < previousSuspendedBy.length; i++) {
2928 const asyncInfo = previousSuspendedBy[i];
2929 if (
@@ -2935,7 +2962,26 @@ export function attach(
2962 }
2963 }
2964 if (suspendedBySet !== undefined && suspendedBySet.size === 0) {
2938 - suspenseNode.suspendedBy.delete(asyncInfo.awaited);
2965 + suspenseNode.suspendedBy.delete(ioInfo);
2966 + // Successfully removed all dependencies. We can decrement the ref count of the environment.
2967 + const env = ioInfo.env;
2968 + if (env != null) {
2969 + const environmentCounts = suspenseNode.environments;
2970 + const count = environmentCounts.get(env);
2971 + if (count === undefined || count === 0) {
2972 + throw new Error(
2973 + 'We are removing an environment but it was not in the set. ' +
2974 + 'This is a bug in React.',
2975 + );
2976 + }
2977 + if (count === 1) {
2978 + environmentCounts.delete(env);
2979 + // Last one. We've now change the set of environments. We'll need to update the node.
2980 + changedEnvironment = true;
2981 + } else {
2982 + environmentCounts.set(env, count - 1);
2983 + }
2984 + }
2985 }
2986 if (
2987 suspenseNode.hasUniqueSuspenders &&
@@ -2948,6 +2994,9 @@ export function attach(
2994 }
2995 }
2996 }
2997 + if (changedEnvironment) {
2998 + recordSuspenseSuspenders(suspenseNode);
2999 + }
3000 }
3001 }
3002
packages/react-devtools-shared/src/devtools/store.js
+15 -6
@@ -1759,12 +1759,22 @@ export default class Store extends EventEmitter<{
1759 break;
1760 }
1761 case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
1762 - const changeLength = operations[i + 1];
1763 - i += 2;
1762 + i++;
1763 + const changeLength = operations[i++];
1764
1765 for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
1766 - const id = operations[i];
1767 - const hasUniqueSuspenders = operations[i + 1] === 1;
1766 + const id = operations[i++];
1767 + const hasUniqueSuspenders = operations[i++] === 1;
1768 + const environmentNamesLength = operations[i++];
1769 + const environmentNames = [];
1770 + for (
1771 + let envIndex = 0;
1772 + envIndex < environmentNamesLength;
1773 + envIndex++
1774 + ) {
1775 + const environmentNameStringID = operations[i++];
1776 + environmentNames.push(stringTable[environmentNameStringID]);
1777 + }
1778 const suspense = this._idToSuspense.get(id);
1779
1780 if (suspense === undefined) {
@@ -1777,8 +1787,6 @@ export default class Store extends EventEmitter<{
1787 break;
1788 }
1789
1780 - i += 2;
1781 -
1790 if (__DEBUG__) {
1791 const previousHasUniqueSuspenders = suspense.hasUniqueSuspenders;
1792 debug(
@@ -1788,6 +1796,7 @@ export default class Store extends EventEmitter<{
1796 }
1797
1798 suspense.hasUniqueSuspenders = hasUniqueSuspenders;
1799 + // TODO: Recompute the environment names.
1800 }
1801
1802 hasSuspenseTreeChanged = true;
packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js
+14 -6
@@ -454,14 +454,22 @@ function updateTree(
454 }
455
456 case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
457 - const changesLength = ((operations[i + 1]: any): number);
458 -
459 - if (__DEBUG__) {
460 - const changes = operations.slice(i + 2, i + 2 + changesLength * 2);
461 - debug('Suspender changes', `[${changes.join(',')}]`);
457 + i++;
458 + const changeLength = ((operations[i++]: any): number);
459 +
460 + for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
461 + const suspenseNodeId = operations[i++];
462 + const hasUniqueSuspenders = operations[i++] === 1;
463 + const environmentNamesLength = operations[i++];
464 + i += environmentNamesLength;
465 + if (__DEBUG__) {
466 + debug(
467 + 'Suspender changes',
468 + `Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
469 + );
470 + }
471 }
472
464 - i += 2 + changesLength * 2;
473 break;
474 }
475
packages/react-devtools-shared/src/utils.js
+12 -6
@@ -426,12 +426,18 @@ export function printOperationsArray(operations: Array<number>) {
426 break;
427 }
428 case SUSPENSE_TREE_OPERATION_SUSPENDERS: {
429 - const changeLength = operations[i + 1];
430 - i += 2;
431 - const changes = operations.slice(i, i + changeLength * 2);
432 - i += changeLength;
433 -
434 - logs.push(`Suspense node suspender changes ${changes.join(',')}`);
429 + i++;
430 + const changeLength = ((operations[i++]: any): number);
431 +
432 + for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
433 + const id = operations[i++];
434 + const hasUniqueSuspenders = operations[i++] === 1;
435 + const environmentNamesLength = operations[i++];
436 + i += environmentNamesLength;
437 + logs.push(
438 + `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
439 + );
440 + }
441
442 break;
443 }