@samitouri / QOS-React / commits / 012b371cde

[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 {