From f4f67cf29d113de96bce29c37054081bd2356ef7 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Fri, 27 Feb 2026 23:13:16 +0100 Subject: [PATCH 1/2] [DevTools] Gray out non-suspended Suspense boundaries in Suspense tab When the unique suspenders filter is off, non-suspended Suspense boundaries now appear gray (using --color-dim) instead of the environment color, making it easy to distinguish which boundaries actually caused loading states. This applies to both the scrubber pills and the layout rects. The hasUniqueSuspenders field is passed through SuspenseTimelineStep so the scrubber can use it directly without an extra store lookup. Co-Authored-By: Claude Opus 4.6 --- packages/react-devtools-shared/src/devtools/store.js | 3 +++ .../src/devtools/views/SuspenseTab/SuspenseRects.css | 4 ++++ .../src/devtools/views/SuspenseTab/SuspenseRects.js | 12 ++++++++++-- .../devtools/views/SuspenseTab/SuspenseScrubber.css | 8 ++++++++ .../devtools/views/SuspenseTab/SuspenseScrubber.js | 3 +++ packages/react-devtools-shared/src/frontend/types.js | 1 + 6 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 5466e798aad4..9053458559da 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -972,6 +972,7 @@ export default class Store extends EventEmitter<{ id: suspense.id, environment: environmentName, endTime: suspense.endTime, + hasUniqueSuspenders: true, }; target.push(rootStep); } else { @@ -1051,6 +1052,7 @@ export default class Store extends EventEmitter<{ // TODO: Get environment for Activity environment: null, endTime: 0, + hasUniqueSuspenders: true, }); const transitionChildren = this.getSuspenseChildren(focusedTransitionID); @@ -1106,6 +1108,7 @@ export default class Store extends EventEmitter<{ id: child.id, environment: environmentName, endTime: maxEndTime, + hasUniqueSuspenders: child.hasUniqueSuspenders, }); } this.pushTimelineStepsInDocumentOrder( diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css index 6b43f873ae85..d16788d7e4bb 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css @@ -108,3 +108,7 @@ .SuspenseRectsBoundary[data-selected='true'] > .SuspenseRectsRect { box-shadow: none; } + +.SuspenseRectsBoundaryNotSuspended { + --color-suspense: var(--color-dim); +} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index 69d4767a489f..5ea671377bca 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -192,7 +192,10 @@ function SuspenseRects({ className={ styles.SuspenseRectsBoundary + ' ' + - getClassNameForEnvironment(environment) + getClassNameForEnvironment(environment) + + (!suspense.hasUniqueSuspenders + ? ' ' + styles.SuspenseRectsBoundaryNotSuspended + : '') } visible={visible} selected={selected} @@ -550,6 +553,7 @@ function SuspenseRectsContainer({ let selectedBoundingBox = null; let selectedEnvironment = null; + let selectedHasUniqueSuspenders = true; if (isRootSelected) { selectedEnvironment = rootEnvironment; } else if ( @@ -563,6 +567,7 @@ function SuspenseRectsContainer({ (selectedSuspenseNode.hasUniqueSuspenders || !uniqueSuspendersOnly) ) { selectedBoundingBox = getBoundingBox(selectedSuspenseNode.rects); + selectedHasUniqueSuspenders = selectedSuspenseNode.hasUniqueSuspenders; for (let i = 0; i < timeline.length; i++) { const timelineStep = timeline[i]; if (timelineStep.id === inspectedElementID) { @@ -605,7 +610,10 @@ function SuspenseRectsContainer({ className={ styles.SuspenseRectOutline + ' ' + - getClassNameForEnvironment(selectedEnvironment) + getClassNameForEnvironment(selectedEnvironment) + + (!selectedHasUniqueSuspenders + ? ' ' + styles.SuspenseRectsBoundaryNotSuspended + : '') } rect={selectedBoundingBox} adjust={true} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css index 7ccb92b78bb2..757b3afe2918 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css @@ -54,6 +54,14 @@ background: var(--color-transition); } +.SuspenseScrubberBeadNotSuspended { + background: color-mix(in srgb, var(--color-dim) 25%, transparent); +} + +.SuspenseScrubberBeadNotSuspended.SuspenseScrubberBeadSelected { + background: var(--color-dim); +} + .SuspenseScrubberStepHighlight > .SuspenseScrubberBead { height: 0.75rem; transition: all 0.3s ease-out; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js index f8c0224c0bca..e6acb3cee7a9 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js @@ -95,6 +95,9 @@ export default function SuspenseScrubber({ ? // The first step in the timeline is always a Transition (Initial Paint). ' ' + styles.SuspenseScrubberBeadTransition : '') + + (!step.hasUniqueSuspenders + ? ' ' + styles.SuspenseScrubberBeadNotSuspended + : '') + ' ' + getClassNameForEnvironment(environment) + (index <= value ? ' ' + styles.SuspenseScrubberBeadSelected : '') diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index a78831cf229b..fd3c02160811 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -211,6 +211,7 @@ export type SuspenseTimelineStep = { id: SuspenseNode['id'] | Element['id'], // TODO: Will become a group. environment: null | string, endTime: number, + hasUniqueSuspenders: boolean, }; export type SuspenseNode = { From 465e778853ae4e836911d29a1a8839d3f914c4d7 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 28 Feb 2026 16:16:58 +0100 Subject: [PATCH 2/2] [DevTools] Compute root step hasUniqueSuspenders as union of all roots Co-Authored-By: Claude Opus 4.6 --- packages/react-devtools-shared/src/devtools/store.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 9053458559da..3137174aff85 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -972,7 +972,7 @@ export default class Store extends EventEmitter<{ id: suspense.id, environment: environmentName, endTime: suspense.endTime, - hasUniqueSuspenders: true, + hasUniqueSuspenders: suspense.hasUniqueSuspenders, }; target.push(rootStep); } else { @@ -984,6 +984,10 @@ export default class Store extends EventEmitter<{ // If any root has a higher end time, let's use that. rootStep.endTime = suspense.endTime; } + if (!rootStep.hasUniqueSuspenders) { + // If any root has unique suspenders, the merged root should too. + rootStep.hasUniqueSuspenders = suspense.hasUniqueSuspenders; + } } this.pushTimelineStepsInDocumentOrder( suspense.children,