@samitouri / QOS-React-1 / commits / fa212fc2b1

[DevTools] Measure the Rectangle of Suspense boundaries as we reconcile (#34090)

Stacked on #34089. This measures the client rects of the direct children of Suspense boundaries as we reconcile. This will be used by the Suspense tab to visualize the boundaries given their outlines. We could ask for this more lazily just in case we're currently looking at the Suspense tab. We could also do something like monitor the sizes using a ResizeObserver to cover when they change. However, it should be pretty cheap to this in the reconciliation phase since we're already mostly visiting these nodes on the way down. We have also already done all the layouts at this point since it was part of the commit phase and paint already. So we're just reading cached values in this phase. We can also infer that things are expected to change when parents or sibling changes. Similar technique as ViewTransitions.

Sebastian Markbåge committed Aug 6, 2025 at 14:56 UTC fa212fc2b14c2262849c8eab01e7c1895cb20c63
1 file changed +188
packages/react-devtools-shared/src/backend/fiber/renderer.js
+188
@@ -271,6 +271,9 @@ function createVirtualInstance(
271
272 type DevToolsInstance = FiberInstance | VirtualInstance | FilteredFiberInstance;
273
274 +// A Generic Rect super type which can include DOMRect and other objects with similar shape like in React Native.
275 +type Rect = {x: number, y: number, width: number, height: number, ...};
276 +
277 type SuspenseNode = {
278 // The Instance can be a Suspense boundary, a SuspenseList Row, or HostRoot.
279 // It can also be disconnected from the main tree if it's a Filtered Instance.
@@ -278,6 +281,7 @@ type SuspenseNode = {
281 parent: null | SuspenseNode,
282 firstChild: null | SuspenseNode,
283 nextSibling: null | SuspenseNode,
284 + rects: null | Array<Rect>, // The bounding rects of content children.
285 suspendedBy: Map<ReactIOInfo, Set<DevToolsInstance>>, // Tracks which data we're suspended by and the children that suspend it.
286 // Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all
287 // also in the parent sets. This determine whether this could contribute in the loading sequence.
@@ -292,6 +296,7 @@ function createSuspenseNode(
296 parent: null,
297 firstChild: null,
298 nextSibling: null,
299 + rects: null,
300 suspendedBy: new Map(),
301 hasUniqueSuspenders: false,
302 });
@@ -2130,6 +2135,69 @@ export function attach(
2135 pendingStringTableLength = 0;
2136 }
2137
2138 + function measureHostInstance(instance: HostInstance): null | Array<Rect> {
2139 + // Feature detect measurement capabilities of this environment.
2140 + // TODO: Consider making this capability injected by the ReactRenderer.
2141 + if (typeof instance !== 'object' || instance === null) {
2142 + return null;
2143 + }
2144 + if (typeof instance.getClientRects === 'function') {
2145 + // DOM
2146 + const result = [];
2147 + const doc = instance.ownerDocument;
2148 + const win = doc && doc.defaultView;
2149 + const scrollX = win ? win.scrollX : 0;
2150 + const scrollY = win ? win.scrollY : 0;
2151 + const rects = instance.getClientRects();
2152 + for (let i = 0; i < rects.length; i++) {
2153 + const rect = rects[i];
2154 + result.push({
2155 + x: rect.x + scrollX,
2156 + y: rect.y + scrollY,
2157 + width: rect.width,
2158 + height: rect.height,
2159 + });
2160 + }
2161 + return result;
2162 + }
2163 + if (instance.canonical) {
2164 + // Native
2165 + const publicInstance = instance.canonical.publicInstance;
2166 + if (!publicInstance) {
2167 + // The publicInstance may not have been initialized yet if there was no ref on this node.
2168 + // We can't initialize it from any existing Hook but we could fallback to this async form:
2169 + // renderer.extraDevToolsConfig.getInspectorDataForInstance(instance).hierarchy[last].getInspectorData().measure(callback)
2170 + return null;
2171 + }
2172 + if (typeof publicInstance.getBoundingClientRect === 'function') {
2173 + // enableAccessToHostTreeInFabric / ReadOnlyElement
2174 + return [publicInstance.getBoundingClientRect()];
2175 + }
2176 + if (typeof publicInstance.unstable_getBoundingClientRect === 'function') {
2177 + // ReactFabricHostComponent
2178 + return [publicInstance.unstable_getBoundingClientRect()];
2179 + }
2180 + }
2181 + return null;
2182 + }
2183 +
2184 + function measureInstance(instance: DevToolsInstance): null | Array<Rect> {
2185 + // Synchronously return the client rects of the Host instances directly inside this Instance.
2186 + const hostInstances = findAllCurrentHostInstances(instance);
2187 + let result: null | Array<Rect> = null;
2188 + for (let i = 0; i < hostInstances.length; i++) {
2189 + const childResult = measureHostInstance(hostInstances[i]);
2190 + if (childResult !== null) {
2191 + if (result === null) {
2192 + result = childResult;
2193 + } else {
2194 + result = result.concat(childResult);
2195 + }
2196 + }
2197 + }
2198 + return result;
2199 + }
2200 +
2201 function getStringID(string: string | null): number {
2202 if (string === null) {
2203 return 0;
@@ -2439,6 +2507,10 @@ export function attach(
2507 }
2508 }
2509
2510 + function recordSuspenseResize(suspenseNode: SuspenseNode): void {
2511 + // TODO: Notify the front end of the change.
2512 + }
2513 +
2514 // Running state of the remaining children from the previous version of this parent that
2515 // we haven't yet added back. This should be reset anytime we change parent.
2516 // Any remaining ones at the end will be deleted.
@@ -2768,6 +2840,79 @@ export function attach(
2840 return false;
2841 }
2842
2843 + function areEqualRects(
2844 + a: null | Array<Rect>,
2845 + b: null | Array<Rect>,
2846 + ): boolean {
2847 + if (a === null) {
2848 + return b === null;
2849 + }
2850 + if (b === null) {
2851 + return false;
2852 + }
2853 + if (a.length !== b.length) {
2854 + return false;
2855 + }
2856 + for (let i = 0; i < a.length; i++) {
2857 + const aRect = a[i];
2858 + const bRect = b[i];
2859 + if (
2860 + aRect.x !== bRect.x ||
2861 + aRect.y !== bRect.y ||
2862 + aRect.width !== bRect.width ||
2863 + aRect.height !== bRect.height
2864 + ) {
2865 + return false;
2866 + }
2867 + }
2868 + return true;
2869 + }
2870 +
2871 + function measureUnchangedSuspenseNodesRecursively(
2872 + suspenseNode: SuspenseNode,
2873 + ): void {
2874 + if (isInDisconnectedSubtree) {
2875 + // We don't update rects inside disconnected subtrees.
2876 + return;
2877 + }
2878 + const nextRects = measureInstance(suspenseNode.instance);
2879 + const prevRects = suspenseNode.rects;
2880 + if (areEqualRects(prevRects, nextRects)) {
2881 + return; // Unchanged
2882 + }
2883 + // The rect has changed. While the bailed out root wasn't in a disconnected subtree,
2884 + // it's possible that this node was in one. So we need to check if we're offscreen.
2885 + let parent = suspenseNode.instance.parent;
2886 + while (parent !== null) {
2887 + if (
2888 + (parent.kind === FIBER_INSTANCE ||
2889 + parent.kind === FILTERED_FIBER_INSTANCE) &&
2890 + parent.data.tag === OffscreenComponent &&
2891 + parent.data.memoizedState !== null
2892 + ) {
2893 + // We're inside a hidden offscreen Fiber. We're in a disconnected tree.
2894 + return;
2895 + }
2896 + if (parent.suspenseNode !== null) {
2897 + // Found our parent SuspenseNode. We can bail out now.
2898 + break;
2899 + }
2900 + parent = parent.parent;
2901 + }
2902 + // We changed inside a visible tree.
2903 + // Since this boundary changed, it's possible it also affected its children so lets
2904 + // measure them as well.
2905 + for (
2906 + let child = suspenseNode.firstChild;
2907 + child !== null;
2908 + child = child.nextSibling
2909 + ) {
2910 + measureUnchangedSuspenseNodesRecursively(child);
2911 + }
2912 + suspenseNode.rects = nextRects;
2913 + recordSuspenseResize(suspenseNode);
2914 + }
2915 +
2916 function consumeSuspenseNodesOfExistingInstance(
2917 instance: DevToolsInstance,
2918 ): void {
@@ -2806,6 +2951,9 @@ export function attach(
2951 previouslyReconciledSiblingSuspenseNode.nextSibling = suspenseNode;
2952 }
2953 previouslyReconciledSiblingSuspenseNode = suspenseNode;
2954 + // While React didn't rerender this node, it's possible that it was affected by
2955 + // layout due to mutation of a parent or sibling. Check if it changed size.
2956 + measureUnchangedSuspenseNodesRecursively(suspenseNode);
2957 // Continue
2958 suspenseNode = nextRemainingSibling;
2959 } else if (foundOne) {
@@ -3029,6 +3177,10 @@ export function attach(
3177 newInstance = recordMount(fiber, reconcilingParent);
3178 if (fiber.tag === SuspenseComponent || fiber.tag === HostRoot) {
3179 newSuspenseNode = createSuspenseNode(newInstance);
3180 + // Measure this Suspense node. In general we shouldn't do this until we have
3181 + // inserted the new children but since we know this is a FiberInstance we'll
3182 + // just use the Fiber anyway.
3183 + newSuspenseNode.rects = measureInstance(newInstance);
3184 }
3185 insertChild(newInstance);
3186 if (__DEBUG__) {
@@ -3058,6 +3210,10 @@ export function attach(
3210 newInstance = createFilteredFiberInstance(fiber);
3211 if (fiber.tag === SuspenseComponent) {
3212 newSuspenseNode = createSuspenseNode(newInstance);
3213 + // Measure this Suspense node. In general we shouldn't do this until we have
3214 + // inserted the new children but since we know this is a FiberInstance we'll
3215 + // just use the Fiber anyway.
3216 + newSuspenseNode.rects = measureInstance(newInstance);
3217 }
3218 insertChild(newInstance);
3219 if (__DEBUG__) {
@@ -4084,6 +4240,23 @@ export function attach(
4240 ) {
4241 shouldResetChildren = true;
4242 }
4243 + } else if (
4244 + nextFiber.memoizedState === null &&
4245 + fiberInstance.suspenseNode !== null
4246 + ) {
4247 + if (!isInDisconnectedSubtree) {
4248 + // Measure this Suspense node in case it changed. We don't update the rect while
4249 + // we're inside a disconnected subtree nor if we are the Suspense boundary that
4250 + // is suspended. This lets us keep the rectangle of the displayed content while
4251 + // we're suspended to visualize the resulting state.
4252 + const suspenseNode = fiberInstance.suspenseNode;
4253 + const prevRects = suspenseNode.rects;
4254 + const nextRects = measureInstance(fiberInstance);
4255 + if (!areEqualRects(prevRects, nextRects)) {
4256 + suspenseNode.rects = nextRects;
4257 + recordSuspenseResize(suspenseNode);
4258 + }
4259 + }
4260 }
4261 } else {
4262 // Common case: Primary -> Primary.
@@ -4179,6 +4352,21 @@ export function attach(
4352 previouslyReconciledSibling = stashedPrevious;
4353 remainingReconcilingChildren = stashedRemaining;
4354 if (shouldPopSuspenseNode) {
4355 + if (
4356 + !isInDisconnectedSubtree &&
4357 + reconcilingParentSuspenseNode !== null
4358 + ) {
4359 + // Measure this Suspense node in case it changed. We don't update the rect
4360 + // while we're inside a disconnected subtree so that we keep the outline
4361 + // as it was before we hid the parent.
4362 + const suspenseNode = reconcilingParentSuspenseNode;
4363 + const prevRects = suspenseNode.rects;
4364 + const nextRects = measureInstance(fiberInstance);
4365 + if (!areEqualRects(prevRects, nextRects)) {
4366 + suspenseNode.rects = nextRects;
4367 + recordSuspenseResize(suspenseNode);
4368 + }
4369 + }
4370 reconcilingParentSuspenseNode = stashedSuspenseParent;
4371 previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious;
4372 remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining;