@samitouri / QOS-React-2 / commits / 5bbf9be246

[DevTools] Model Hidden Offscreen Boundaries as Unmounts (#34062)

This is modeling Offscreen boundaries as the thing that unmounts a tree in the frontend. This will let us model this as a "hide" that preserves state instead in a follow up but not yet. By doing it this way, we don't have to special case suspended Suspense boundaries, at least not for the modern versions that use Offscreen as the internal node. It's still special cased for the old React versions. Instead, this is handled by the Offscreen fiber getting hidden. By giving this fiber an FilteredFiberInstance, we also have somewhere to store the children on (separately from the parent children set which can include other siblings too like the loading state). One consequence is that Activity boundary content now disappears when they're hidden which is probably a good thing since otherwise it would be confusing and noisy when it's used to render multiple pages at once.

Sebastian Markbåge committed Jul 31, 2025 at 10:30 UTC 5bbf9be2468ee80ee4558d4afa8570d8582866bd
2 files changed +142 -96
packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js
+31 -34
@@ -207,12 +207,11 @@ describe('Store component filters', () => {
207 );
208
209 expect(store).toMatchInlineSnapshot(`
210 - [root]
211 - ▾ <Activity>
212 - <div>
213 - ▾ <Activity>
214 - <div>
215 - `);
210 + [root]
211 + ▾ <Activity>
212 + <div>
213 + <Activity>
214 + `);
215
216 await actAsync(
217 async () =>
@@ -222,10 +221,9 @@ describe('Store component filters', () => {
221 );
222
223 expect(store).toMatchInlineSnapshot(`
225 - [root]
226 - <div>
227 - <div>
228 - `);
224 + [root]
225 + <div>
226 + `);
227
228 await actAsync(
229 async () =>
@@ -235,12 +233,11 @@ describe('Store component filters', () => {
233 );
234
235 expect(store).toMatchInlineSnapshot(`
238 - [root]
239 - ▾ <Activity>
240 - <div>
241 - ▾ <Activity>
242 - <div>
243 - `);
236 + [root]
237 + ▾ <Activity>
238 + <div>
239 + <Activity>
240 + `);
241 }
242 });
243
@@ -262,12 +259,12 @@ describe('Store component filters', () => {
259 );
260
261 expect(store).toMatchInlineSnapshot(`
265 - [root]
266 - ▾ <ViewTransition>
267 - <div>
268 - ▾ <ViewTransition>
269 - <div>
270 - `);
262 + [root]
263 + ▾ <ViewTransition>
264 + <div>
265 + ▾ <ViewTransition>
266 + <div>
267 + `);
268
269 await actAsync(
270 async () =>
@@ -277,12 +274,12 @@ describe('Store component filters', () => {
274 );
275
276 expect(store).toMatchInlineSnapshot(`
280 - [root]
281 - ▾ <ViewTransition>
282 - <div>
283 - ▾ <ViewTransition>
284 - <div>
285 - `);
277 + [root]
278 + ▾ <ViewTransition>
279 + <div>
280 + ▾ <ViewTransition>
281 + <div>
282 + `);
283
284 await actAsync(
285 async () =>
@@ -292,12 +289,12 @@ describe('Store component filters', () => {
289 );
290
291 expect(store).toMatchInlineSnapshot(`
295 - [root]
296 - ▾ <ViewTransition>
297 - <div>
298 - ▾ <ViewTransition>
299 - <div>
300 - `);
292 + [root]
293 + ▾ <ViewTransition>
294 + <div>
295 + ▾ <ViewTransition>
296 + <div>
297 + `);
298 }
299 });
300
packages/react-devtools-shared/src/backend/fiber/renderer.js
+111 -62
@@ -205,7 +205,7 @@ type FilteredFiberInstance = {
205 source: null | string | Error | ReactFunctionLocation, // always null here.
206 logCount: number, // total number of errors/warnings last seen
207 treeBaseDuration: number, // the profiled time of the last render of this subtree
208 - suspendedBy: null | Array<ReactAsyncInfo>, // not used
208 + suspendedBy: null | Array<ReactAsyncInfo>, // only used at the root
209 suspenseNode: null | SuspenseNode,
210 data: Fiber, // one of a Fiber pair
211 };
@@ -2236,13 +2236,17 @@ export function attach(
2236 // the debugStack will be a stack frame inside the ownerInstance's source.
2237 ownerInstance.source = fiber._debugStack;
2238 }
2239 +
2240 + let unfilteredParent = parentInstance;
2241 + while (
2242 + unfilteredParent !== null &&
2243 + unfilteredParent.kind === FILTERED_FIBER_INSTANCE
2244 + ) {
2245 + unfilteredParent = unfilteredParent.parent;
2246 + }
2247 +
2248 const ownerID = ownerInstance === null ? 0 : ownerInstance.id;
2240 - const parentID = parentInstance
2241 - ? parentInstance.kind === FILTERED_FIBER_INSTANCE
2242 - ? // A Filtered Fiber Instance will always have a Virtual Instance as a parent.
2243 - ((parentInstance.parent: any): VirtualInstance).id
2244 - : parentInstance.id
2245 - : 0;
2249 + const parentID = unfilteredParent === null ? 0 : unfilteredParent.id;
2250
2251 const displayNameStringID = getStringID(displayName);
2252
@@ -2331,13 +2335,17 @@ export function attach(
2335 // the debugStack will be a stack frame inside the ownerInstance's source.
2336 ownerInstance.source = componentInfo.debugStack;
2337 }
2338 +
2339 + let unfilteredParent = parentInstance;
2340 + while (
2341 + unfilteredParent !== null &&
2342 + unfilteredParent.kind === FILTERED_FIBER_INSTANCE
2343 + ) {
2344 + unfilteredParent = unfilteredParent.parent;
2345 + }
2346 +
2347 const ownerID = ownerInstance === null ? 0 : ownerInstance.id;
2335 - const parentID = parentInstance
2336 - ? parentInstance.kind === FILTERED_FIBER_INSTANCE
2337 - ? // A Filtered Fiber Instance will always have a Virtual Instance as a parent.
2338 - ((parentInstance.parent: any): VirtualInstance).id
2339 - : parentInstance.id
2340 - : 0;
2348 + const parentID = unfilteredParent === null ? 0 : unfilteredParent.id;
2349
2350 const displayNameStringID = getStringID(displayName);
2351
@@ -2436,13 +2444,22 @@ export function attach(
2444 // we bubble that up to the nearest parent Suspense boundary that isn't in fallback mode.
2445 parentSuspenseNode = parentSuspenseNode.parent;
2446 }
2439 - const parentInstance = reconcilingParent;
2440 - if (parentInstance === null || parentSuspenseNode === null) {
2447 + if (reconcilingParent === null || parentSuspenseNode === null) {
2448 throw new Error(
2449 'It should not be possible to have suspended data outside the root. ' +
2450 'Even suspending at the first position is still a child of the root.',
2451 );
2452 }
2453 + // Use the nearest unfiltered parent so that there's always some component that has
2454 + // the entry on it even if you filter, or the root if all are filtered.
2455 + let parentInstance = reconcilingParent;
2456 + while (
2457 + parentInstance.kind === FILTERED_FIBER_INSTANCE &&
2458 + parentInstance.parent !== null
2459 + ) {
2460 + parentInstance = parentInstance.parent;
2461 + }
2462 +
2463 const suspenseNodeSuspendedBy = parentSuspenseNode.suspendedBy;
2464 const ioInfo = asyncInfo.awaited;
2465 let suspendedBySet = suspenseNodeSuspendedBy.get(ioInfo);
@@ -2778,7 +2795,7 @@ export function attach(
2795 // so we assume insertSuspendedBy dedupes.
2796 insertSuspendedBy(asyncInfo);
2797 }
2781 - if (previousVirtualInstance) continue;
2798 + continue;
2799 }
2800 if (typeof debugEntry.name !== 'string') {
2801 // Not a Component. Some other Debug Info.
@@ -2893,7 +2910,8 @@ export function attach(
2910 } else if (
2911 (reconcilingParent !== null &&
2912 reconcilingParent.kind === VIRTUAL_INSTANCE) ||
2896 - fiber.tag === SuspenseComponent
2913 + fiber.tag === SuspenseComponent ||
2914 + fiber.tag === OffscreenComponent // Use to keep resuspended instances alive inside a SuspenseComponent.
2915 ) {
2916 // If the parent is a Virtual Instance and we filtered this Fiber we include a
2917 // hidden node. We also include this if it's a Suspense boundary so we can track those
@@ -2977,7 +2995,11 @@ export function attach(
2995 aquireHostInstance(nearestInstance, fiber.stateNode);
2996 }
2997
2980 - if (fiber.tag === SuspenseComponent) {
2998 + if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
2999 + // If an Offscreen component is hidden, don't mount its children yet.
3000 + } else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) {
3001 + // Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the
3002 + // Offscreen wrapper itself specially.
3003 const isTimedOut = fiber.memoizedState !== null;
3004 if (isTimedOut) {
3005 // Special case: if Suspense mounts in a timed-out state,
@@ -2999,15 +3021,7 @@ export function attach(
3021 }
3022 // TODO: Track SuspenseNode in resuspended trees.
3023 } else {
3002 - let primaryChild: Fiber | null = null;
3003 - const areSuspenseChildrenConditionallyWrapped =
3004 - OffscreenComponent === -1;
3005 - if (areSuspenseChildrenConditionallyWrapped) {
3006 - primaryChild = fiber.child;
3007 - } else if (fiber.child !== null) {
3008 - primaryChild = fiber.child.child;
3009 - updateTrackedPathStateBeforeMount(fiber.child, null);
3010 - }
3024 + const primaryChild: Fiber | null = fiber.child;
3025 if (primaryChild !== null) {
3026 mountChildrenRecursively(
3027 primaryChild,
@@ -3209,6 +3223,21 @@ export function attach(
3223 virtualInstance.treeBaseDuration = treeBaseDuration;
3224 }
3225
3226 + function addUnfilteredChildrenIDs(
3227 + parentInstance: DevToolsInstance,
3228 + nextChildren: Array<number>,
3229 + ): void {
3230 + let child: null | DevToolsInstance = parentInstance.firstChild;
3231 + while (child !== null) {
3232 + if (child.kind === FILTERED_FIBER_INSTANCE) {
3233 + addUnfilteredChildrenIDs(child, nextChildren);
3234 + } else {
3235 + nextChildren.push(child.id);
3236 + }
3237 + child = child.nextSibling;
3238 + }
3239 + }
3240 +
3241 function recordResetChildren(
3242 parentInstance: FiberInstance | VirtualInstance,
3243 ) {
@@ -3226,21 +3255,7 @@ export function attach(
3255 // This is trickier than a simple comparison though, since certain types of fibers are filtered.
3256 const nextChildren: Array<number> = [];
3257
3229 - let child: null | DevToolsInstance = parentInstance.firstChild;
3230 - while (child !== null) {
3231 - if (child.kind === FILTERED_FIBER_INSTANCE) {
3232 - for (
3233 - let innerChild: null | DevToolsInstance = parentInstance.firstChild;
3234 - innerChild !== null;
3235 - innerChild = innerChild.nextSibling
3236 - ) {
3237 - nextChildren.push((innerChild: any).id);
3238 - }
3239 - } else {
3240 - nextChildren.push(child.id);
3241 - }
3242 - child = child.nextSibling;
3243 - }
3258 + addUnfilteredChildrenIDs(parentInstance, nextChildren);
3259
3260 const numChildren = nextChildren.length;
3261 if (numChildren < 2) {
@@ -3336,7 +3351,7 @@ export function attach(
3351 // so we assume insertSuspendedBy dedupes.
3352 insertSuspendedBy(asyncInfo);
3353 }
3339 - if (previousVirtualInstance) continue;
3354 + continue;
3355 }
3356 if (typeof debugEntry.name !== 'string') {
3357 // Not a Component. Some other Debug Info.
@@ -3496,7 +3511,8 @@ export function attach(
3511 }
3512 if (existingInstance !== null) {
3513 // Common case. Match in the same parent.
3499 - const fiberInstance: FiberInstance = (existingInstance: any); // Only matches if it's a Fiber.
3514 + const fiberInstance: FiberInstance | FilteredFiberInstance =
3515 + (existingInstance: any); // Only matches if it's a Fiber.
3516
3517 // We keep track if the order of the children matches the previous order.
3518 // They are always different referentially, but if the instances line up
@@ -3604,7 +3620,7 @@ export function attach(
3620
3621 // Returns whether closest unfiltered fiber parent needs to reset its child list.
3622 function updateFiberRecursively(
3607 - fiberInstance: null | FiberInstance, // null if this should be filtered
3623 + fiberInstance: null | FiberInstance | FilteredFiberInstance, // null if this should be filtered
3624 nextFiber: Fiber,
3625 prevFiber: Fiber,
3626 traceNearestHostComponentUpdate: boolean,
@@ -3703,9 +3719,9 @@ export function attach(
3719 aquireHostInstance(nearestInstance, nextFiber.stateNode);
3720 }
3721
3706 - const isSuspense = nextFiber.tag === SuspenseComponent;
3722 let shouldResetChildren = false;
3708 - // The behavior of timed-out Suspense trees is unique.
3723 +
3724 + // The behavior of timed-out legacy Suspense trees is unique. Without the Offscreen wrapper.
3725 // Rather than unmount the timed out content (and possibly lose important state),
3726 // React re-parents this content within a hidden Fragment while the fallback is showing.
3727 // This behavior doesn't need to be observable in the DevTools though.
@@ -3713,8 +3729,17 @@ export function attach(
3729 // The easiest fix is to strip out the intermediate Fragment fibers,
3730 // so the Elements panel and Profiler don't need to special case them.
3731 // Suspense components only have a non-null memoizedState if they're timed-out.
3716 - const prevDidTimeout = isSuspense && prevFiber.memoizedState !== null;
3717 - const nextDidTimeOut = isSuspense && nextFiber.memoizedState !== null;
3732 + const isLegacySuspense =
3733 + nextFiber.tag === SuspenseComponent && OffscreenComponent === -1;
3734 + const prevDidTimeout =
3735 + isLegacySuspense && prevFiber.memoizedState !== null;
3736 + const nextDidTimeOut =
3737 + isLegacySuspense && nextFiber.memoizedState !== null;
3738 +
3739 + const isOffscreen = nextFiber.tag === OffscreenComponent;
3740 + const prevWasHidden = isOffscreen && prevFiber.memoizedState !== null;
3741 + const nextIsHidden = isOffscreen && nextFiber.memoizedState !== null;
3742 +
3743 // The logic below is inspired by the code paths in updateSuspenseComponent()
3744 // inside ReactFiberBeginWork in the React source code.
3745 if (prevDidTimeout && nextDidTimeOut) {
@@ -3781,6 +3806,25 @@ export function attach(
3806 );
3807 shouldResetChildren = true;
3808 }
3809 + } else if (prevWasHidden && nextIsHidden) {
3810 + // We don't update any children while they're still hidden.
3811 + } else if (prevWasHidden && !nextIsHidden) {
3812 + // We're revealing the hidden children. We now need to update them to the latest state.
3813 + if (nextFiber.child !== null) {
3814 + mountChildrenRecursively(
3815 + nextFiber.child,
3816 + traceNearestHostComponentUpdate,
3817 + );
3818 + shouldResetChildren = true;
3819 + }
3820 + } else if (!prevWasHidden && nextIsHidden) {
3821 + // We're hiding the children. We really just unmount them for now.
3822 + updateChildrenRecursively(
3823 + null,
3824 + prevFiber.child,
3825 + traceNearestHostComponentUpdate,
3826 + );
3827 + shouldResetChildren = true;
3828 } else {
3829 // Common case: Primary -> Primary.
3830 // This is the same code path as for non-Suspense fibers.
@@ -3830,26 +3874,31 @@ export function attach(
3874 if (fiberInstance !== null) {
3875 removePreviousSuspendedBy(fiberInstance, previousSuspendedBy);
3876
3833 - let componentLogsEntry = fiberToComponentLogsMap.get(
3834 - fiberInstance.data,
3835 - );
3836 - if (componentLogsEntry === undefined && fiberInstance.data.alternate) {
3837 - componentLogsEntry = fiberToComponentLogsMap.get(
3838 - fiberInstance.data.alternate,
3877 + if (fiberInstance.kind === FIBER_INSTANCE) {
3878 + let componentLogsEntry = fiberToComponentLogsMap.get(
3879 + fiberInstance.data,
3880 );
3840 - }
3841 - recordConsoleLogs(fiberInstance, componentLogsEntry);
3881 + if (
3882 + componentLogsEntry === undefined &&
3883 + fiberInstance.data.alternate
3884 + ) {
3885 + componentLogsEntry = fiberToComponentLogsMap.get(
3886 + fiberInstance.data.alternate,
3887 + );
3888 + }
3889 + recordConsoleLogs(fiberInstance, componentLogsEntry);
3890
3843 - const isProfilingSupported =
3844 - nextFiber.hasOwnProperty('treeBaseDuration');
3845 - if (isProfilingSupported) {
3846 - recordProfilingDurations(fiberInstance, prevFiber);
3891 + const isProfilingSupported =
3892 + nextFiber.hasOwnProperty('treeBaseDuration');
3893 + if (isProfilingSupported) {
3894 + recordProfilingDurations(fiberInstance, prevFiber);
3895 + }
3896 }
3897 }
3898 if (shouldResetChildren) {
3899 // We need to crawl the subtree for closest non-filtered Fibers
3900 // so that we can display them in a flat children set.
3852 - if (fiberInstance !== null) {
3901 + if (fiberInstance !== null && fiberInstance.kind === FIBER_INSTANCE) {
3902 recordResetChildren(fiberInstance);
3903 // We've handled the child order change for this Fiber.
3904 // Since it's included, there's no need to invalidate parent child order.