From 6cfc9c1ff3138ba0d107b9e6249f47032c4da02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 15 Oct 2025 10:12:26 -0400 Subject: [PATCH 1/9] [DevTools] Don't measure fallbacks when suspended (#34850) We already do this in the update pass. That's what `shouldMeasureSuspenseNode` does. We also don't update measurements when we're inside an offscreen tree. However, we didn't check if the boundary itself was in a suspended state when in the `measureUnchangedSuspenseNodesRecursively` path. This caused boundaries to disappear when their fallback didn't have a rect (including their timeline entries). --- .../src/backend/fiber/renderer.js | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 688f1b473bf62..6ff78a0ba685a 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -3262,14 +3262,22 @@ export function attach( // We don't update rects inside disconnected subtrees. return; } - const nextRects = measureInstance(suspenseNode.instance); - const prevRects = suspenseNode.rects; - if (areEqualRects(prevRects, nextRects)) { - return; // Unchanged + const instance = suspenseNode.instance; + + const isSuspendedSuspenseComponent = + (instance.kind === FIBER_INSTANCE || + instance.kind === FILTERED_FIBER_INSTANCE) && + instance.data.tag === SuspenseComponent && + instance.data.memoizedState !== null; + if (isSuspendedSuspenseComponent) { + // This boundary itself was suspended and we don't measure those since that would measure + // the fallback. We want to keep a ghost of the rectangle of the content not currently shown. + return; } - // The rect has changed. While the bailed out root wasn't in a disconnected subtree, + + // While this boundary wasn't suspended and the bailed out root and wasn't in a disconnected subtree, // it's possible that this node was in one. So we need to check if we're offscreen. - let parent = suspenseNode.instance.parent; + let parent = instance.parent; while (parent !== null) { if ( (parent.kind === FIBER_INSTANCE || @@ -3285,6 +3293,13 @@ export function attach( } parent = parent.parent; } + + const nextRects = measureInstance(suspenseNode.instance); + const prevRects = suspenseNode.rects; + if (areEqualRects(prevRects, nextRects)) { + return; // Unchanged + } + // We changed inside a visible tree. // Since this boundary changed, it's possible it also affected its children so lets // measure them as well. From 751edd6e2cd2cb44aa302ffa667a0693e84e579d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 15 Oct 2025 10:24:45 -0400 Subject: [PATCH 2/9] [DevTools] Measure text nodes (#34851) We can't measure Text nodes directly but we can measure a Range around them. This is useful since it's common, at least in examples, to use text nodes as children of a Suspense boundary. Especially fallbacks. --- .../src/backend/fiber/renderer.js | 21 +++++++++++++++++-- .../src/backend/views/Highlighter/Overlay.js | 7 +++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 6ff78a0ba685a..6e4426d3ee936 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2251,7 +2251,10 @@ export function attach( if (typeof instance !== 'object' || instance === null) { return null; } - if (typeof instance.getClientRects === 'function') { + if ( + typeof instance.getClientRects === 'function' || + instance.nodeType === 3 + ) { // DOM const doc = instance.ownerDocument; if (instance === doc.documentElement) { @@ -2273,7 +2276,21 @@ export function attach( const win = doc && doc.defaultView; const scrollX = win ? win.scrollX : 0; const scrollY = win ? win.scrollY : 0; - const rects = instance.getClientRects(); + let rects; + if (instance.nodeType === 3) { + // Text nodes cannot be measured directly but we can measure a Range. + if (typeof doc.createRange !== 'function') { + return null; + } + const range = doc.createRange(); + if (typeof range.getClientRects !== 'function') { + return null; + } + range.selectNodeContents(instance); + rects = range.getClientRects(); + } else { + rects = instance.getClientRects(); + } for (let i = 0; i < rects.length; i++) { const rect = rects[i]; result.push({ diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js b/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js index fdd059cc23486..e55bdb4298c47 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js @@ -187,10 +187,13 @@ export default class Overlay { } } - inspect(nodes: $ReadOnlyArray, name?: ?string) { + inspect(nodes: $ReadOnlyArray, name?: ?string) { // We can't get the size of text nodes or comment nodes. React as of v15 // heavily uses comment nodes to delimit text. - const elements = nodes.filter(node => node.nodeType === Node.ELEMENT_NODE); + // TODO: We actually can measure text nodes. We should. + const elements: $ReadOnlyArray = (nodes.filter( + node => node.nodeType === Node.ELEMENT_NODE, + ): any); while (this.rects.length > elements.length) { const rect = this.rects.pop(); From 5747cadf44c0895a891f8c17489b8516829ef6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 15 Oct 2025 10:25:46 -0400 Subject: [PATCH 3/9] [DevTools] Don't hide overflow rectangles (#34852) I get the wish to click the shadow but not all child boundaries are within the bounds of the outer Suspense boundary's node. Sometimes they overflow naturally and if we make it overflow hidden we hide the boundaries. Maybe it would be ok if they're actually clipped by the real DOM but right now it covers up boundaries that should be there. Additionally, there's also a common case where the parent boundary shrinks when suspending the children. That then causes the suspended child boundaries to be clipped so that you can't restore them. Maybe the virtual boundary shouldn't shrink in this case. --- .../src/devtools/views/SuspenseTab/SuspenseRects.css | 5 ----- 1 file changed, 5 deletions(-) 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 ba862051d9513..bfc53d8b848f1 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css @@ -19,11 +19,6 @@ .SuspenseRectsBoundaryChildren { pointer-events: none; - /** - * So that the shadow of Boundaries within is clipped off. - * Otherwise it would look like this boundary is further elevated. - */ - overflow: hidden; } .SuspenseRectsScaledRect[data-visible='false'] > .SuspenseRectsBoundaryChildren { From 6773248311fca29669283e1059f11c9009f8f51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 15 Oct 2025 10:26:07 -0400 Subject: [PATCH 4/9] [DevTools] Track whether a boundary is currently suspended and make transparent (#34853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This makes the rects that are currently in a suspended state appear ghostly so that you can see where along the timeline you are in the rects screen. Screenshot 2025-10-14 at 11 43 20 PM --- .../src/backend/fiber/renderer.js | 58 +++++++++++++++---- .../src/backend/legacy/renderer.js | 1 + .../src/devtools/store.js | 8 ++- .../views/Profiler/CommitTreeBuilder.js | 12 ++-- .../views/SuspenseTab/SuspenseRects.css | 4 ++ .../views/SuspenseTab/SuspenseRects.js | 6 +- .../src/frontend/types.js | 1 + packages/react-devtools-shared/src/utils.js | 10 ++-- 8 files changed, 76 insertions(+), 24 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 6e4426d3ee936..b7fe41b96c5b4 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2139,8 +2139,8 @@ export function attach( // Regular operations pendingOperations.length + // All suspender changes are batched in a single message. - // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]] - (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0), + // [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, isSuspended]] + (numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 3 : 0), ); // Identify which renderer this update is coming from. @@ -2225,6 +2225,14 @@ export function attach( } operations[i++] = fiberIdWithChanges; operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0; + const instance = suspense.instance; + const isSuspended = + // TODO: Track if other SuspenseNode like SuspenseList rows are suspended. + (instance.kind === FIBER_INSTANCE || + instance.kind === FILTERED_FIBER_INSTANCE) && + instance.data.tag === SuspenseComponent && + instance.data.memoizedState !== null; + operations[i++] = isSuspended ? 1 : 0; operations[i++] = suspense.environments.size; suspense.environments.forEach((count, env) => { operations[i++] = getStringID(env); @@ -2657,9 +2665,15 @@ export function attach( const fiber = fiberInstance.data; const props = fiber.memoizedProps; // TODO: Compute a fallback name based on Owner, key etc. - const name = props === null ? null : props.name || null; + const name = + fiber.tag !== SuspenseComponent || props === null + ? null + : props.name || null; const nameStringID = getStringID(name); + const isSuspended = + fiber.tag === SuspenseComponent && fiber.memoizedState !== null; + if (__DEBUG__) { console.log('recordSuspenseMount()', suspenseInstance); } @@ -2670,6 +2684,7 @@ export function attach( pushOperation(fiberID); pushOperation(parentID); pushOperation(nameStringID); + pushOperation(isSuspended ? 1 : 0); const rects = suspenseInstance.rects; if (rects === null) { @@ -5038,15 +5053,24 @@ export function attach( const nextIsSuspended = isSuspendedOffscreen(nextFiber); if (isLegacySuspense) { - if ( - fiberInstance !== null && - fiberInstance.suspenseNode !== null && - (prevFiber.stateNode === null) !== (nextFiber.stateNode === null) - ) { - trackThrownPromisesFromRetryCache( - fiberInstance.suspenseNode, - nextFiber.stateNode, - ); + if (fiberInstance !== null && fiberInstance.suspenseNode !== null) { + const suspenseNode = fiberInstance.suspenseNode; + if ( + (prevFiber.stateNode === null) !== + (nextFiber.stateNode === null) + ) { + trackThrownPromisesFromRetryCache( + suspenseNode, + nextFiber.stateNode, + ); + } + if ( + (prevFiber.memoizedState === null) !== + (nextFiber.memoizedState === null) + ) { + // Toggle suspended state. + recordSuspenseSuspenders(suspenseNode); + } } } // The logic below is inspired by the code paths in updateSuspenseComponent() @@ -5194,6 +5218,14 @@ export function attach( ); } + if ( + (prevFiber.memoizedState === null) !== + (nextFiber.memoizedState === null) + ) { + // Toggle suspended state. + recordSuspenseSuspenders(suspenseNode); + } + shouldMeasureSuspenseNode = false; updateFlags |= updateSuspenseChildrenRecursively( nextContentFiber, @@ -5220,6 +5252,8 @@ export function attach( } trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode); + // Toggle suspended state. + recordSuspenseSuspenders(suspenseNode); mountSuspenseChildrenRecursively( nextContentFiber, diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 46709c9a8048c..1262a8d4647a6 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -417,6 +417,7 @@ export function attach( pushOperation(id); pushOperation(parentID); pushOperation(getStringID(null)); // name + pushOperation(0); // isSuspended // TODO: Measure rect of root pushOperation(-1); } else { diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index eeb6da60f8aeb..86961f5bd91fb 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -1552,7 +1552,8 @@ export default class Store extends EventEmitter<{ const id = operations[i + 1]; const parentID = operations[i + 2]; const nameStringID = operations[i + 3]; - const numRects = ((operations[i + 4]: any): number); + const isSuspended = operations[i + 4] === 1; + const numRects = ((operations[i + 5]: any): number); let name = stringTable[nameStringID]; if (this._idToSuspense.has(id)) { @@ -1579,7 +1580,7 @@ export default class Store extends EventEmitter<{ } } - i += 5; + i += 6; let rects: SuspenseNode['rects']; if (numRects === -1) { rects = null; @@ -1625,6 +1626,7 @@ export default class Store extends EventEmitter<{ name, rects, hasUniqueSuspenders: false, + isSuspended: isSuspended, }); hasSuspenseTreeChanged = true; @@ -1801,6 +1803,7 @@ export default class Store extends EventEmitter<{ for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) { const id = operations[i++]; const hasUniqueSuspenders = operations[i++] === 1; + const isSuspended = operations[i++] === 1; const environmentNamesLength = operations[i++]; const environmentNames = []; for ( @@ -1832,6 +1835,7 @@ export default class Store extends EventEmitter<{ } suspense.hasUniqueSuspenders = hasUniqueSuspenders; + suspense.isSuspended = isSuspended; // TODO: Recompute the environment names. } diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index 28be5f9e1c7e1..4addf10916693 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -378,7 +378,8 @@ function updateTree( const fiberID = operations[i + 1]; const parentID = operations[i + 2]; const nameStringID = operations[i + 3]; - const numRects = operations[i + 4]; + const isSuspended = operations[i + 4]; + const numRects = operations[i + 5]; const name = stringTable[nameStringID]; if (__DEBUG__) { @@ -388,16 +389,16 @@ function updateTree( } else { rects = '[' + - operations.slice(i + 5, i + 5 + numRects * 4).join(',') + + operations.slice(i + 6, i + 6 + numRects * 4).join(',') + ']'; } debug( 'Add suspense', - `node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID}`, + `node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID} suspended ${isSuspended}`, ); } - i += 5 + (numRects === -1 ? 0 : numRects * 4); + i += 6 + (numRects === -1 ? 0 : numRects * 4); break; } @@ -459,12 +460,13 @@ function updateTree( for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) { const suspenseNodeId = operations[i++]; const hasUniqueSuspenders = operations[i++] === 1; + const isSuspended = operations[i++] === 1; const environmentNamesLength = operations[i++]; i += environmentNamesLength; if (__DEBUG__) { debug( 'Suspender changes', - `Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`, + `Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`, ); } } 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 bfc53d8b848f1..591d908735efb 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css @@ -44,6 +44,10 @@ outline-width: 0; } +.SuspenseRectsScaledRect[data-suspended='true'] { + opacity: 0.3; +} + /* highlight this boundary */ .SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect { background-color: var(--color-background-hover); 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 f949631be8427..5f07bb61001ee 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -35,11 +35,13 @@ function ScaledRect({ className, rect, visible, + suspended, ...props }: { className: string, rect: Rect, visible: boolean, + suspended: boolean, ... }): React$Node { const viewBox = useContext(ViewBox); @@ -53,6 +55,7 @@ function ScaledRect({ {...props} className={styles.SuspenseRectsScaledRect + ' ' + className} data-visible={visible} + data-suspended={suspended} style={{ width, height, @@ -145,7 +148,8 @@ function SuspenseRects({ + visible={visible} + suspended={suspense.isSuspended}> {visible && suspense.rects !== null && diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 7762af43e0040..2a012ce33a17d 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -200,6 +200,7 @@ export type SuspenseNode = { name: string | null, rects: null | Array, hasUniqueSuspenders: boolean, + isSuspended: boolean, }; // Serialized version of ReactIOInfo diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 007db77f2202a..29ff6d566bd6f 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -340,9 +340,10 @@ export function printOperationsArray(operations: Array) { const fiberID = operations[i + 1]; const parentID = operations[i + 2]; const nameStringID = operations[i + 3]; - const numRects = operations[i + 4]; + const isSuspended = operations[i + 4]; + const numRects = operations[i + 5]; - i += 5; + i += 6; const name = stringTable[nameStringID]; let rects: string; @@ -368,7 +369,7 @@ export function printOperationsArray(operations: Array) { } logs.push( - `Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID}`, + `Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID} suspended ${isSuspended}`, ); break; } @@ -431,10 +432,11 @@ export function printOperationsArray(operations: Array) { for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) { const id = operations[i++]; const hasUniqueSuspenders = operations[i++] === 1; + const isSuspended = operations[i++] === 1; const environmentNamesLength = operations[i++]; i += environmentNamesLength; logs.push( - `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`, + `Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`, ); } From 77b2f909f6261ec2c5de75dbe76287ec6fead0d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 15 Oct 2025 10:26:46 -0400 Subject: [PATCH 5/9] [DevTools] Attempt at a better "unique suspender" text (#34854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nobody knows what this terminology means. Also, this tooltip component sucks: Screenshot 2025-10-15 at 12 04 49 AM --- .../src/devtools/views/SuspenseTab/SuspenseTab.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index f7b63fa2c62c2..9ade19c33075b 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -85,7 +85,9 @@ function ToggleUniqueSuspenders() { + title={ + 'Filter Suspense which does not suspend, or if the parent also suspend on the same.' + }> ); From 1873ad7960da8fd8d497d03da8050ad88b8bcacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 15 Oct 2025 11:42:03 -0400 Subject: [PATCH 6/9] [DevTools] The bridge event types should only be defined in one direction (#34859) This revealed that a lot of the event types were defined on the wrong end of the bridge. It was also a problem that events with the same name couldn't have different arguments. --- packages/react-devtools-shared/src/bridge.js | 21 +++++++++---------- .../Components/InspectHostNodesToggle.js | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index b6229192c23b1..3162dc215ff0a 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -217,10 +217,15 @@ export type BackendEvents = { selectElement: [number], shutdown: [], stopInspectingHost: [boolean], - syncSelectionFromBuiltinElementsPanel: [], syncSelectionToBuiltinElementsPanel: [], unsupportedRendererVersion: [], + extensionComponentsPanelShown: [], + extensionComponentsPanelHidden: [], + + resumeElementPolling: [], + pauseElementPolling: [], + // React Native style editor plug-in. isNativeStyleEditorSupported: [ {isSupported: boolean, validAttributes: ?$ReadOnlyArray}, @@ -240,8 +245,6 @@ type FrontendEvents = { clearWarningsForElementID: [ElementAndRendererID], copyElementPath: [CopyElementPathParams], deletePath: [DeletePath], - extensionComponentsPanelShown: [], - extensionComponentsPanelHidden: [], getBackendVersion: [], getBridgeProtocol: [], getIfHasUnsupportedRendererVersion: [], @@ -265,7 +268,7 @@ type FrontendEvents = { shutdown: [], startInspectingHost: [], startProfiling: [StartProfilingParams], - stopInspectingHost: [boolean], + stopInspectingHost: [], scrollToHostInstance: [ScrollToHostInstance], stopProfiling: [], storeAsGlobal: [StoreAsGlobalParams], @@ -275,6 +278,8 @@ type FrontendEvents = { viewAttributeSource: [ViewAttributeSourceParams], viewElementSource: [ElementAndRendererID], + syncSelectionFromBuiltinElementsPanel: [], + // React Native style editor plug-in. NativeStyleEditor_measure: [ElementAndRendererID], NativeStyleEditor_renameAttribute: [NativeStyleEditor_RenameAttributeParams], @@ -295,19 +300,13 @@ type FrontendEvents = { overrideProps: [OverrideValue], overrideState: [OverrideValue], - resumeElementPolling: [], - pauseElementPolling: [], - getHookSettings: [], }; class Bridge< OutgoingEvents: Object, IncomingEvents: Object, -> extends EventEmitter<{ - ...IncomingEvents, - ...OutgoingEvents, -}> { +> extends EventEmitter { _isShutdown: boolean = false; _messageQueue: Array = []; _scheduledFlush: boolean = false; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js index 2f2b9e9e91d0a..17a7b049cc9b3 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js @@ -26,7 +26,7 @@ export default function InspectHostNodesToggle(): React.Node { logEvent({event_name: 'inspect-element-button-clicked'}); bridge.send('startInspectingHost'); } else { - bridge.send('stopInspectingHost', false); + bridge.send('stopInspectingHost'); } }, [bridge], From e096403c595d67b689473908a52979c76bbefb9e Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:45:06 -0700 Subject: [PATCH 7/9] [compiler] Infer types for properties after holes in array patterns (#34847) In InferTypes when we infer types for properties during destructuring, we were breaking out of the loop when we encounter a hole in the array. Instead we should just skip that element and continue inferring later properties. Closes #34748 --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34847). * #34855 * __->__ #34847 --- .../src/TypeInference/InferTypes.ts | 2 +- ...setState-in-render-unbound-state.expect.md | 41 ++++++++++++++ ...nvalid-setState-in-render-unbound-state.js | 13 +++++ .../fixtures/compiler/holey-array.expect.md | 35 ------------ .../fixtures/compiler/holey-array.js | 11 ---- ...use-memo-transition-no-ispending.expect.md | 52 ++++++++++++++++++ ...eserve-use-memo-transition-no-ispending.js | 15 +++++ .../preserve-use-memo-unused-state.expect.md | 55 +++++++++++++++++++ .../preserve-use-memo-unused-state.js | 15 +++++ 9 files changed, 192 insertions(+), 47 deletions(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.js delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/holey-array.expect.md delete mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/holey-array.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-transition-no-ispending.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-transition-no-ispending.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-unused-state.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-unused-state.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts index 1c4443e5a49a0..55974db14ce17 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/TypeInference/InferTypes.ts @@ -393,7 +393,7 @@ function* generateInstructionTypes( shapeId: BuiltInArrayId, }); } else { - break; + continue; } } } else { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.expect.md new file mode 100644 index 0000000000000..423076cc3a4b1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.expect.md @@ -0,0 +1,41 @@ + +## Input + +```javascript +function Component(props) { + // Intentionally don't bind state, this repros a bug where we didn't + // infer the type of destructured properties after a hole in the array + let [, setState] = useState(); + setState(1); + return props.foo; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: ['TodoAdd'], + isComponent: 'TodoAdd', +}; + +``` + + +## Error + +``` +Found 1 error: + +Error: Calling setState during render may trigger an infinite loop + +Calling setState during render will trigger another render, and can lead to infinite loops. (https://react.dev/reference/react/useState). + +error.invalid-setState-in-render-unbound-state.ts:5:2 + 3 | // infer the type of destructured properties after a hole in the array + 4 | let [, setState] = useState(); +> 5 | setState(1); + | ^^^^^^^^ Found setState() in render + 6 | return props.foo; + 7 | } + 8 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.js new file mode 100644 index 0000000000000..58e2837692a1d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-setState-in-render-unbound-state.js @@ -0,0 +1,13 @@ +function Component(props) { + // Intentionally don't bind state, this repros a bug where we didn't + // infer the type of destructured properties after a hole in the array + let [, setState] = useState(); + setState(1); + return props.foo; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: ['TodoAdd'], + isComponent: 'TodoAdd', +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/holey-array.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/holey-array.expect.md deleted file mode 100644 index 160699b115ba9..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/holey-array.expect.md +++ /dev/null @@ -1,35 +0,0 @@ - -## Input - -```javascript -function t(props) { - let [, setstate] = useState(); - setstate(1); - return props.foo; -} - -export const FIXTURE_ENTRYPOINT = { - fn: t, - params: ['TodoAdd'], - isComponent: 'TodoAdd', -}; - -``` - -## Code - -```javascript -function t(props) { - const [, setstate] = useState(); - setstate(1); - return props.foo; -} - -export const FIXTURE_ENTRYPOINT = { - fn: t, - params: ["TodoAdd"], - isComponent: "TodoAdd", -}; - -``` - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/holey-array.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/holey-array.js deleted file mode 100644 index d76b88f579190..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/holey-array.js +++ /dev/null @@ -1,11 +0,0 @@ -function t(props) { - let [, setstate] = useState(); - setstate(1); - return props.foo; -} - -export const FIXTURE_ENTRYPOINT = { - fn: t, - params: ['TodoAdd'], - isComponent: 'TodoAdd', -}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-transition-no-ispending.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-transition-no-ispending.expect.md new file mode 100644 index 0000000000000..c5bfe197c3afc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-transition-no-ispending.expect.md @@ -0,0 +1,52 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees +import {useCallback, useTransition} from 'react'; + +function useFoo() { + const [, /* isPending intentionally not captured */ start] = useTransition(); + + return useCallback(() => { + start(); + }, []); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees +import { useCallback, useTransition } from "react"; + +function useFoo() { + const $ = _c(1); + const [, start] = useTransition(); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = () => { + start(); + }; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; + +``` + +### Eval output +(kind: ok) "[[ function params=0 ]]" \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-transition-no-ispending.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-transition-no-ispending.js new file mode 100644 index 0000000000000..d7560197f0311 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-transition-no-ispending.js @@ -0,0 +1,15 @@ +// @validatePreserveExistingMemoizationGuarantees +import {useCallback, useTransition} from 'react'; + +function useFoo() { + const [, /* isPending intentionally not captured */ start] = useTransition(); + + return useCallback(() => { + start(); + }, []); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-unused-state.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-unused-state.expect.md new file mode 100644 index 0000000000000..8444126755849 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-unused-state.expect.md @@ -0,0 +1,55 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees +import {useCallback, useTransition} from 'react'; + +function useFoo() { + const [, /* state value intentionally not captured */ setState] = useState(); + + return useCallback(() => { + setState(x => x + 1); + }, []); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees +import { useCallback, useTransition } from "react"; + +function useFoo() { + const $ = _c(1); + const [, setState] = useState(); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 = () => { + setState(_temp); + }; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} +function _temp(x) { + return x + 1; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; + +``` + +### Eval output +(kind: exception) useState is not defined \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-unused-state.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-unused-state.js new file mode 100644 index 0000000000000..e270d4f0192f9 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-use-memo-unused-state.js @@ -0,0 +1,15 @@ +// @validatePreserveExistingMemoizationGuarantees +import {useCallback, useTransition} from 'react'; + +function useFoo() { + const [, /* state value intentionally not captured */ setState] = useState(); + + return useCallback(() => { + setState(x => x + 1); + }, []); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [], +}; From 0fbb9b368393a85c728806e16630712bdafdd6b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 15 Oct 2025 13:43:43 -0400 Subject: [PATCH 8/9] [DevTools] Don't highlight on timeline (#34861) I find it very frustrating that the highlight covers up the content that I'm trying to review when stepping through the timeline. It also triggered on keyboard navigation due to the focus which was annoying. We could highlight something in the rects instead potentially. --- .../views/SuspenseTab/SuspenseScrubber.js | 4 ++-- .../views/SuspenseTab/SuspenseTimeline.js | 21 ++++--------------- 2 files changed, 6 insertions(+), 19 deletions(-) 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 53d20b6467c46..f1998aff4b16c 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js @@ -31,9 +31,9 @@ export default function SuspenseScrubber({ max: number, value: number, highlight: number, - onBlur: () => void, + onBlur?: () => void, onChange: (index: number) => void, - onFocus: () => void, + onFocus?: () => void, onHoverSegment: (index: number) => void, onHoverLeave: () => void, }): React$Node { diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 8ebb06899d62a..9b70812134288 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -11,7 +11,7 @@ import * as React from 'react'; import {useContext, useEffect} from 'react'; import {BridgeContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; -import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks'; +import {useScrollToHostInstance} from '../hooks'; import { SuspenseTreeDispatcherContext, SuspenseTreeStateContext, @@ -25,8 +25,6 @@ function SuspenseTimelineInput() { const bridge = useContext(BridgeContext); const treeDispatch = useContext(TreeDispatcherContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); - const {highlightHostInstance, clearHighlightHostInstance} = - useHighlightHostInstance(); const scrollToHostInstance = useScrollToHostInstance(); const {timeline, timelineIndex, hoveredTimelineIndex, playing, autoScroll} = @@ -37,7 +35,6 @@ function SuspenseTimelineInput() { function switchSuspenseNode(nextTimelineIndex: number) { const nextSelectedSuspenseID = timeline[nextTimelineIndex]; - highlightHostInstance(nextSelectedSuspenseID); treeDispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: nextSelectedSuspenseID, @@ -52,23 +49,14 @@ function SuspenseTimelineInput() { switchSuspenseNode(pendingTimelineIndex); } - function handleBlur() { - clearHighlightHostInstance(); - } - function handleFocus() { switchSuspenseNode(timelineIndex); } function handleHoverSegment(hoveredValue: number) { - const suspenseID = timeline[hoveredValue]; - if (suspenseID === undefined) { - throw new Error( - `Suspense node not found for value ${hoveredValue} in timeline.`, - ); - } - highlightHostInstance(suspenseID); + // TODO: Consider highlighting the rect instead. } + function handleUnhoverSegment() {} function skipPrevious() { const nextSelectedSuspenseID = timeline[timelineIndex - 1]; @@ -180,11 +168,10 @@ function SuspenseTimelineInput() { max={max} value={timelineIndex} highlight={hoveredTimelineIndex} - onBlur={handleBlur} onChange={handleChange} onFocus={handleFocus} onHoverSegment={handleHoverSegment} - onHoverLeave={clearHighlightHostInstance} + onHoverLeave={handleUnhoverSegment} /> From 903366b8b1ee4206020492c6e8140645c0cb563e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 15 Oct 2025 13:43:55 -0400 Subject: [PATCH 9/9] [DevTools] Don't select on hover (#34860) We should only persist a selection once you click. Currently, we persist the selection if you just hover which means you lose your selection immediately when just starting to inspect. That's not what Chrome Elements tab does - it selects on click. --- .../src/backend/views/Highlighter/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index 6fd93d3519c87..894c4fba94404 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -366,8 +366,6 @@ export default function setupHighlighter( // Don't pass the name explicitly. // It will be inferred from DOM tag and Fiber owner. showOverlay([target], null, agent, false); - - selectElementForNode(target); } function onPointerUp(event: MouseEvent) {