From b88ee7b61378c98f55dce58e1578a7a0532e6c36 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 3 Aug 2025 17:30:54 -0400 Subject: [PATCH 1/6] Add rect tracking types to SuspenseNode --- packages/react-devtools-shared/src/backend/fiber/renderer.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 189d504ad51..7681b304ca6 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -271,6 +271,9 @@ function createVirtualInstance( type DevToolsInstance = FiberInstance | VirtualInstance | FilteredFiberInstance; +// A Generic Rect super type which can include DOMRect and other objects with similar shape like in React Native. +type Rect = {x: number, y: number, width: number, height: number, ...}; + type SuspenseNode = { // The Instance can be a Suspense boundary, a SuspenseList Row, or HostRoot. // It can also be disconnected from the main tree if it's a Filtered Instance. @@ -278,6 +281,7 @@ type SuspenseNode = { parent: null | SuspenseNode, firstChild: null | SuspenseNode, nextSibling: null | SuspenseNode, + rects: null | Array, // The bounding rects of content children. suspendedBy: Map>, // Tracks which data we're suspended by and the children that suspend it. // Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all // also in the parent sets. This determine whether this could contribute in the loading sequence. @@ -292,6 +296,7 @@ function createSuspenseNode( parent: null, firstChild: null, nextSibling: null, + rects: null, suspendedBy: new Map(), hasUniqueSuspenders: false, }); From de65be51098bd9aa980ec2a7962dacfa92fa8706 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 3 Aug 2025 18:01:38 -0400 Subject: [PATCH 2/6] Add feature detected ability to measure HostInstances Consider making this capability injected by the renderer. --- .../src/backend/fiber/renderer.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 7681b304ca6..99084d26486 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2135,6 +2135,54 @@ export function attach( pendingStringTableLength = 0; } + function measureHostInstance(instance: HostInstance): null | Array { + // Feature detect measurement capabilities of this environment. + // TODO: Consider making this capability injected by the ReactRenderer. + if (typeof instance !== 'object' || instance === null) { + return null; + } + if (typeof instance.getClientRects === 'function') { + // DOM + return Array.from(instance.getClientRects()); + } + if (instance.canonical) { + // Native + const publicInstance = instance.canonical.publicInstance; + if (!publicInstance) { + // The publicInstance may not have been initialized yet if there was no ref on this node. + // We can't initialize it from any existing Hook but we could fallback to this async form: + // renderer.extraDevToolsConfig.getInspectorDataForInstance(instance).hierarchy[last].getInspectorData().measure(callback) + return null; + } + if (typeof publicInstance.getBoundingClientRect === 'function') { + // enableAccessToHostTreeInFabric / ReadOnlyElement + return [publicInstance.getBoundingClientRect()]; + } + if (typeof publicInstance.unstable_getBoundingClientRect === 'function') { + // ReactFabricHostComponent + return [publicInstance.unstable_getBoundingClientRect()]; + } + } + return null; + } + + function measureInstance(instance: DevToolsInstance): null | Array { + // Synchronously return the client rects of the Host instances directly inside this Instance. + const hostInstances = findAllCurrentHostInstances(instance); + let result: null | Array = null; + for (let i = 0; i < hostInstances.length; i++) { + const childResult = measureHostInstance(hostInstances[i]); + if (childResult !== null) { + if (result === null) { + result = childResult; + } else { + result = result.concat(childResult); + } + } + } + return result; + } + function getStringID(string: string | null): number { if (string === null) { return 0; From 4612c2033ae5698ae28820532ee8d9a1767d2a8f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 3 Aug 2025 18:04:28 -0400 Subject: [PATCH 3/6] Measure suspense nodes on mount and if it or its children updates We only do this if we're not the Suspense boundary that's suspended or if we're in a hidden tree. This lets all Suspense boundaries show their content's rect, rather than their fallback's rect (which might be null). --- .../src/backend/fiber/renderer.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 99084d26486..1d2ff529665 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -3082,6 +3082,10 @@ export function attach( newInstance = recordMount(fiber, reconcilingParent); if (fiber.tag === SuspenseComponent || fiber.tag === HostRoot) { newSuspenseNode = createSuspenseNode(newInstance); + // Measure this Suspense node. In general we shouldn't do this until we have + // inserted the new children but since we know this is a FiberInstance we'll + // just use the Fiber anyway. + newSuspenseNode.rects = measureInstance(newInstance); } insertChild(newInstance); if (__DEBUG__) { @@ -3111,6 +3115,10 @@ export function attach( newInstance = createFilteredFiberInstance(fiber); if (fiber.tag === SuspenseComponent) { newSuspenseNode = createSuspenseNode(newInstance); + // Measure this Suspense node. In general we shouldn't do this until we have + // inserted the new children but since we know this is a FiberInstance we'll + // just use the Fiber anyway. + newSuspenseNode.rects = measureInstance(newInstance); } insertChild(newInstance); if (__DEBUG__) { @@ -4137,6 +4145,17 @@ export function attach( ) { shouldResetChildren = true; } + } else if ( + nextFiber.memoizedState === null && + fiberInstance.suspenseNode !== null + ) { + if (!isInDisconnectedSubtree) { + // Measure this Suspense node in case it changed. We don't update the rect while + // we're inside a disconnected subtree nor if we are the Suspense boundary that + // is suspended. This lets us keep the rectangle of the displayed content while + // we're suspended to visualize the resulting state. + fiberInstance.suspenseNode.rects = measureInstance(fiberInstance); + } } } else { // Common case: Primary -> Primary. @@ -4232,6 +4251,16 @@ export function attach( previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; if (shouldPopSuspenseNode) { + if ( + !isInDisconnectedSubtree && + reconcilingParentSuspenseNode !== null + ) { + // Measure this Suspense node in case it changed. We don't update the rect + // while we're inside a disconnected subtree so that we keep the outline + // as it was before we hid the parent. + reconcilingParentSuspenseNode.rects = + measureInstance(fiberInstance); + } reconcilingParentSuspenseNode = stashedSuspenseParent; previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; From 4cb763523b0dd29db4b69a8b8d834a7465838989 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 3 Aug 2025 19:19:47 -0400 Subject: [PATCH 4/6] Measure bailed out suspense nodes in case layout cause them to change If they didn't, we can bail out there. If they did, we also check their children. --- .../src/backend/fiber/renderer.js | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 1d2ff529665..087263f79c9 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2821,6 +2821,78 @@ export function attach( return false; } + function areEqualRects( + a: null | Array, + b: null | Array, + ): boolean { + if (a === null) { + return b === null; + } + if (b === null) { + return false; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + const aRect = a[i]; + const bRect = b[i]; + if ( + aRect.x !== bRect.x || + aRect.y !== bRect.y || + aRect.width !== bRect.width || + aRect.height !== bRect.height + ) { + return false; + } + } + return true; + } + + function measureUnchangedSuspenseNodesRecursively( + suspenseNode: SuspenseNode, + ): void { + if (isInDisconnectedSubtree) { + // We don't update rects inside disconnected subtrees. + return; + } + const nextRects = measureInstance(suspenseNode.instance); + const prevRects = suspenseNode.rects; + if (areEqualRects(prevRects, nextRects)) { + return; // Unchanged + } + // The rect has changed. While the bailed out root 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; + while (parent !== null) { + if ( + (parent.kind === FIBER_INSTANCE || + parent.kind === FILTERED_FIBER_INSTANCE) && + parent.data.tag === OffscreenComponent && + parent.data.memoizedState !== null + ) { + // We're inside a hidden offscreen Fiber. We're in a disconnected tree. + return; + } + if (parent.suspenseNode !== null) { + // Found our parent SuspenseNode. We can bail out now. + break; + } + parent = parent.parent; + } + // We changed inside a visible tree. + suspenseNode.rects = nextRects; + // Since this boundary changed, it's possible it also affected its children so lets + // measure them as well. + for ( + let child = suspenseNode.firstChild; + child !== null; + child = child.nextSibling + ) { + measureUnchangedSuspenseNodesRecursively(child); + } + } + function consumeSuspenseNodesOfExistingInstance( instance: DevToolsInstance, ): void { @@ -2859,6 +2931,9 @@ export function attach( previouslyReconciledSiblingSuspenseNode.nextSibling = suspenseNode; } previouslyReconciledSiblingSuspenseNode = suspenseNode; + // While React didn't rerender this node, it's possible that it was affected by + // layout due to mutation of a parent or sibling. Check if it changed size. + measureUnchangedSuspenseNodesRecursively(suspenseNode); // Continue suspenseNode = nextRemainingSibling; } else if (foundOne) { From 195a06f06867d6f9b728843b414802fadd2914e0 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 3 Aug 2025 19:30:34 -0400 Subject: [PATCH 5/6] Notify recordSuspenseResize when rects change --- .../src/backend/fiber/renderer.js | 24 +++++++++++++++---- 1 file changed, 20 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 087263f79c9..1157dec2822 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2492,6 +2492,10 @@ export function attach( } } + function recordSuspenseResize(suspenseNode: SuspenseNode): void { + // TODO: Notify the front end of the change. + } + // Running state of the remaining children from the previous version of this parent that // we haven't yet added back. This should be reset anytime we change parent. // Any remaining ones at the end will be deleted. @@ -2881,7 +2885,6 @@ export function attach( parent = parent.parent; } // We changed inside a visible tree. - suspenseNode.rects = nextRects; // Since this boundary changed, it's possible it also affected its children so lets // measure them as well. for ( @@ -2891,6 +2894,8 @@ export function attach( ) { measureUnchangedSuspenseNodesRecursively(child); } + suspenseNode.rects = nextRects; + recordSuspenseResize(suspenseNode); } function consumeSuspenseNodesOfExistingInstance( @@ -4229,7 +4234,13 @@ export function attach( // we're inside a disconnected subtree nor if we are the Suspense boundary that // is suspended. This lets us keep the rectangle of the displayed content while // we're suspended to visualize the resulting state. - fiberInstance.suspenseNode.rects = measureInstance(fiberInstance); + const suspenseNode = fiberInstance.suspenseNode; + const prevRects = suspenseNode.rects; + const nextRects = measureInstance(fiberInstance); + if (!areEqualRects(prevRects, nextRects)) { + suspenseNode.rects = nextRects; + recordSuspenseResize(suspenseNode); + } } } } else { @@ -4333,8 +4344,13 @@ export function attach( // Measure this Suspense node in case it changed. We don't update the rect // while we're inside a disconnected subtree so that we keep the outline // as it was before we hid the parent. - reconcilingParentSuspenseNode.rects = - measureInstance(fiberInstance); + const suspenseNode = reconcilingParentSuspenseNode; + const prevRects = suspenseNode.rects; + const nextRects = measureInstance(fiberInstance); + if (!areEqualRects(prevRects, nextRects)) { + suspenseNode.rects = nextRects; + recordSuspenseResize(suspenseNode); + } } reconcilingParentSuspenseNode = stashedSuspenseParent; previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; From 136f8bd4652e6856cacb22739482daeb165486b7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 5 Aug 2025 16:37:26 -0400 Subject: [PATCH 6/6] Make the rects relative to the document and not the viewport --- .../src/backend/fiber/renderer.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 1157dec2822..aab9476db30 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2143,7 +2143,22 @@ export function attach( } if (typeof instance.getClientRects === 'function') { // DOM - return Array.from(instance.getClientRects()); + const result = []; + const doc = instance.ownerDocument; + const win = doc && doc.defaultView; + const scrollX = win ? win.scrollX : 0; + const scrollY = win ? win.scrollY : 0; + const rects = instance.getClientRects(); + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + result.push({ + x: rect.x + scrollX, + y: rect.y + scrollY, + width: rect.width, + height: rect.height, + }); + } + return result; } if (instance.canonical) { // Native