[DevTools] Handle LegacyHidden Fibers like Offscreen Fibers. (#34564)
Sebastian "Sebbie" Silbermann committed
Sep 23, 2025 at 20:14 UTC
012b371cde3157a8dd46535839fbcda6c2bed1a0
4 files changed
+45
-74
packages/react-devtools-shared/src/__tests__/profilingCache-test.js
+3
-4
@@ -725,14 +725,14 @@ describe('ProfilingCache', () => {
725
const commitData = store.profilerStore.getDataForRoot(rootID).commitData;
726
expect(commitData).toHaveLength(2);
727
728
- const isLegacySuspense = React.version.startsWith('17');
729
- if (isLegacySuspense) {
728
+ if (React.version.startsWith('17')) {
729
+ // React 17 will mount all children until it suspends in a LegacyHidden
730
+ // The ID gap is from the Fiber for <Async> that's in the disconnected tree.
731
expect(commitData[0].fiberActualDurations).toMatchInlineSnapshot(`
732
Map {
733
1 => 15,
734
2 => 15,
735
3 => 5,
735
- 4 => 3,
736
5 => 2,
737
}
738
`);
@@ -741,7 +741,6 @@ describe('ProfilingCache', () => {
741
1 => 0,
742
2 => 10,
743
3 => 3,
744
- 4 => 3,
744
5 => 2,
745
}
746
`);
packages/react-devtools-shared/src/__tests__/profilingCommitTreeBuilder-test.js
+16
-52
@@ -19,8 +19,6 @@ describe('commit tree', () => {
19
let Scheduler;
20
let store: Store;
21
let utils;
22
- const isLegacySuspense =
23
- React.version.startsWith('16') || React.version.startsWith('17');
22
23
beforeEach(() => {
24
utils = require('./utils');
@@ -186,24 +184,13 @@ describe('commit tree', () => {
184
utils.act(() => store.profilerStore.startProfiling());
185
utils.act(() => legacyRender(<App renderChildren={true} />));
186
await Promise.resolve();
189
- if (isLegacySuspense) {
190
- expect(store).toMatchInlineSnapshot(`
191
- [root]
192
- ▾ <App>
193
- ▾ <Suspense>
194
- <Lazy>
195
- [suspense-root] rects={null}
196
- <Suspense name="App" rects={null}>
197
- `);
198
- } else {
199
- expect(store).toMatchInlineSnapshot(`
200
- [root]
201
- ▾ <App>
202
- <Suspense>
203
- [suspense-root] rects={null}
204
- <Suspense name="App" rects={null}>
205
- `);
206
- }
187
+ expect(store).toMatchInlineSnapshot(`
188
+ [root]
189
+ ▾ <App>
190
+ <Suspense>
191
+ [suspense-root] rects={null}
192
+ <Suspense name="App" rects={null}>
193
+ `);
194
utils.act(() => legacyRender(<App renderChildren={true} />));
195
expect(store).toMatchInlineSnapshot(`
196
[root]
@@ -231,13 +218,7 @@ describe('commit tree', () => {
218
);
219
}
220
234
- expect(commitTrees[0].nodes.size).toBe(
235
- isLegacySuspense
236
- ? // <Root> + <App> + <Suspense> + <Lazy>
237
- 4
238
- : // <Root> + <App> + <Suspense>
239
- 3,
240
- );
221
+ expect(commitTrees[0].nodes.size).toBe(3);
222
expect(commitTrees[1].nodes.size).toBe(4); // <Root> + <App> + <Suspense> + <LazyInnerComponent>
223
expect(commitTrees[2].nodes.size).toBe(2); // <Root> + <App>
224
});
@@ -291,24 +272,13 @@ describe('commit tree', () => {
272
it('should support Lazy components that are unmounted before resolving (legacy render)', async () => {
273
utils.act(() => store.profilerStore.startProfiling());
274
utils.act(() => legacyRender(<App renderChildren={true} />));
294
- if (isLegacySuspense) {
295
- expect(store).toMatchInlineSnapshot(`
296
- [root]
297
- ▾ <App>
298
- ▾ <Suspense>
299
- <Lazy>
300
- [suspense-root] rects={null}
301
- <Suspense name="App" rects={null}>
302
- `);
303
- } else {
304
- expect(store).toMatchInlineSnapshot(`
305
- [root]
306
- ▾ <App>
307
- <Suspense>
308
- [suspense-root] rects={null}
309
- <Suspense name="App" rects={null}>
310
- `);
311
- }
275
+ expect(store).toMatchInlineSnapshot(`
276
+ [root]
277
+ ▾ <App>
278
+ <Suspense>
279
+ [suspense-root] rects={null}
280
+ <Suspense name="App" rects={null}>
281
+ `);
282
utils.act(() => legacyRender(<App renderChildren={false} />));
283
expect(store).toMatchInlineSnapshot(`
284
[root]
@@ -327,13 +297,7 @@ describe('commit tree', () => {
297
);
298
}
299
330
- expect(commitTrees[0].nodes.size).toBe(
331
- isLegacySuspense
332
- ? // <Root> + <App> + <Suspense> + <Lazy>
333
- 4
334
- : // <Root> + <App> + <Suspense>
335
- 3,
336
- );
300
+ expect(commitTrees[0].nodes.size).toBe(3);
301
expect(commitTrees[1].nodes.size).toBe(2); // <Root> + <App>
302
});
303
packages/react-devtools-shared/src/__tests__/store-test.js
+2
-2
@@ -2828,7 +2828,7 @@ describe('Store', () => {
2828
`);
2829
});
2830
2831
- // @reactVersion >= 18.0
2831
+ // @reactVersion >= 17.0
2832
it('can reconcile Suspense in fallback positions', async () => {
2833
let resolveFallback;
2834
const fallbackPromise = new Promise(resolve => {
@@ -2907,7 +2907,7 @@ describe('Store', () => {
2907
`);
2908
});
2909
2910
- // @reactVersion >= 18.0
2910
+ // @reactVersion >= 17.0
2911
it('can reconcile resuspended Suspense with Suspense in fallback positions', async () => {
2912
let resolveHeadFallback;
2913
let resolveHeadContent;
packages/react-devtools-shared/src/backend/fiber/renderer.js
+24
-16
@@ -460,10 +460,10 @@ export function getInternalReactConstants(version: string): {
460
IncompleteFunctionComponent: 28,
461
IndeterminateComponent: 2, // removed in 19.0.0
462
LazyComponent: 16,
463
- LegacyHiddenComponent: 23,
463
+ LegacyHiddenComponent: 23, // Does not exist in 18+ OSS but exists in fb builds
464
MemoComponent: 14,
465
Mode: 8,
466
- OffscreenComponent: 22, // Experimental
466
+ OffscreenComponent: 22, // Experimental in 17. Stable in 18+
467
Profiler: 12,
468
ScopeComponent: 21, // Experimental
469
SimpleMemoComponent: 15,
@@ -3057,13 +3057,23 @@ export function attach(
3057
}
3058
}
3059
3060
+ function isHiddenOffscreen(fiber: Fiber): boolean {
3061
+ switch (fiber.tag) {
3062
+ case LegacyHiddenComponent:
3063
+ // fallthrough since all published implementations currently implement the same state as Offscreen.
3064
+ case OffscreenComponent:
3065
+ return fiber.memoizedState !== null;
3066
+ default:
3067
+ return false;
3068
+ }
3069
+ }
3070
+
3071
function unmountRemainingChildren() {
3072
if (
3073
reconcilingParent !== null &&
3074
(reconcilingParent.kind === FIBER_INSTANCE ||
3075
reconcilingParent.kind === FILTERED_FIBER_INSTANCE) &&
3065
- reconcilingParent.data.tag === OffscreenComponent &&
3066
- reconcilingParent.data.memoizedState !== null &&
3076
+ isHiddenOffscreen(reconcilingParent.data) &&
3077
!isInDisconnectedSubtree
3078
) {
3079
// This is a hidden offscreen, we need to execute this in the context of a disconnected subtree.
@@ -3170,8 +3180,7 @@ export function attach(
3180
if (
3181
(parent.kind === FIBER_INSTANCE ||
3182
parent.kind === FILTERED_FIBER_INSTANCE) &&
3173
- parent.data.tag === OffscreenComponent &&
3174
- parent.data.memoizedState !== null
3183
+ isHiddenOffscreen(parent.data)
3184
) {
3185
// We're inside a hidden offscreen Fiber. We're in a disconnected tree.
3186
return;
@@ -3819,7 +3828,9 @@ export function attach(
3828
(reconcilingParent !== null &&
3829
reconcilingParent.kind === VIRTUAL_INSTANCE) ||
3830
fiber.tag === SuspenseComponent ||
3822
- fiber.tag === OffscreenComponent // Use to keep resuspended instances alive inside a SuspenseComponent.
3831
+ // Use to keep resuspended instances alive inside a SuspenseComponent.
3832
+ fiber.tag === OffscreenComponent ||
3833
+ fiber.tag === LegacyHiddenComponent
3834
) {
3835
// If the parent is a Virtual Instance and we filtered this Fiber we include a
3836
// hidden node. We also include this if it's a Suspense boundary so we can track those
@@ -3939,7 +3950,7 @@ export function attach(
3950
trackDebugInfoFromHostComponent(nearestInstance, fiber);
3951
}
3952
3942
- if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
3953
+ if (isHiddenOffscreen(fiber)) {
3954
// If an Offscreen component is hidden, mount its children as disconnected.
3955
const stashedDisconnected = isInDisconnectedSubtree;
3956
isInDisconnectedSubtree = true;
@@ -4261,7 +4272,7 @@ export function attach(
4272
while (child !== null) {
4273
if (child.kind === FILTERED_FIBER_INSTANCE) {
4274
const fiber = child.data;
4264
- if (fiber.tag === OffscreenComponent && fiber.memoizedState !== null) {
4275
+ if (isHiddenOffscreen(fiber)) {
4276
// The children of this Offscreen are hidden so they don't get added.
4277
} else {
4278
addUnfilteredChildrenIDs(child, nextChildren);
@@ -4888,9 +4899,8 @@ export function attach(
4899
const nextDidTimeOut =
4900
isLegacySuspense && nextFiber.memoizedState !== null;
4901
4891
- const isOffscreen = nextFiber.tag === OffscreenComponent;
4892
- const prevWasHidden = isOffscreen && prevFiber.memoizedState !== null;
4893
- const nextIsHidden = isOffscreen && nextFiber.memoizedState !== null;
4902
+ const prevWasHidden = isHiddenOffscreen(prevFiber);
4903
+ const nextIsHidden = isHiddenOffscreen(nextFiber);
4904
4905
if (isLegacySuspense) {
4906
if (
@@ -5245,8 +5255,7 @@ export function attach(
5255
if (
5256
(child.kind === FIBER_INSTANCE ||
5257
child.kind === FILTERED_FIBER_INSTANCE) &&
5248
- child.data.tag === OffscreenComponent &&
5249
- child.data.memoizedState !== null
5258
+ isHiddenOffscreen(child.data)
5259
) {
5260
// This instance's children are already disconnected.
5261
} else {
@@ -5275,8 +5284,7 @@ export function attach(
5284
if (
5285
(child.kind === FIBER_INSTANCE ||
5286
child.kind === FILTERED_FIBER_INSTANCE) &&
5278
- child.data.tag === OffscreenComponent &&
5279
- child.data.memoizedState !== null
5287
+ isHiddenOffscreen(child.data)
5288
) {
5289
// This instance's children should remain disconnected.
5290
} else {