From 1d68bce19c9409ed70604d1d16b70b68ce71dc4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 12 Oct 2025 19:50:06 -0400 Subject: [PATCH] [Fiber] Don't unhide a node if a direct parent offscreen is still hidden (#34821) If an inner Offscreen commits an unhide, but an outer Offscreen is still hidden but they're controlling the same DOM node then we shouldn't unhide the DOM node yet. This keeps track of whether we're directly inside a hidden offscreen. It might be better to just do the tree search instead of keeping the stack state since it's a rare case. Although this hide/unhide path does trigger a lot of times even when there's no change. This was technically a bug with Suspense too but it doesn't appear because a suspended Suspense boundary never commits its partial state. If it did, it would trigger this same path. But it can happen with an outer Activity and inner Suspense. --- .../src/ReactFiberCommitWork.js | 20 +++- .../src/__tests__/Activity-test.js | 68 +++++++++++++ .../ReactSuspenseEffectsSemantics-test.js | 99 +++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index c98edc7a0bc57..825b814db5103 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -292,6 +292,9 @@ import type {Flags} from './ReactFiberFlags'; // Allows us to avoid traversing the return path to find the nearest Offscreen ancestor. let offscreenSubtreeIsHidden: boolean = false; let offscreenSubtreeWasHidden: boolean = false; +// Track whether there's a hidden offscreen above with no HostComponent between. If so, +// it overrides the hiddenness of the HostComponent below. +let offscreenDirectParentIsHidden: boolean = false; // Used to track if a form needs to be reset at the end of the mutation phase. let needsFormReset = false; @@ -2141,8 +2144,14 @@ function commitMutationEffectsOnFiber( // Fall through } case HostComponent: { + // We've hit a host component, so it's no longer a direct parent. + const prevOffscreenDirectParentIsHidden = offscreenDirectParentIsHidden; + offscreenDirectParentIsHidden = false; + recursivelyTraverseMutationEffects(root, finishedWork, lanes); + offscreenDirectParentIsHidden = prevOffscreenDirectParentIsHidden; + commitReconciliationEffects(finishedWork, lanes); if (flags & Ref) { @@ -2422,10 +2431,14 @@ function commitMutationEffectsOnFiber( // effects again. const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden; const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; + const prevOffscreenDirectParentIsHidden = offscreenDirectParentIsHidden; offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden || isHidden; + offscreenDirectParentIsHidden = + prevOffscreenDirectParentIsHidden || isHidden; offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden; recursivelyTraverseMutationEffects(root, finishedWork, lanes); offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; + offscreenDirectParentIsHidden = prevOffscreenDirectParentIsHidden; offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden; if ( @@ -2504,9 +2517,10 @@ function commitMutationEffectsOnFiber( } if (supportsMutation) { - // TODO: This needs to run whenever there's an insertion or update - // inside a hidden Offscreen tree. - hideOrUnhideAllChildren(finishedWork, isHidden); + // If it's trying to unhide but the parent is still hidden, then we should not unhide. + if (isHidden || !offscreenDirectParentIsHidden) { + hideOrUnhideAllChildren(finishedWork, isHidden); + } } } diff --git a/packages/react-reconciler/src/__tests__/Activity-test.js b/packages/react-reconciler/src/__tests__/Activity-test.js index 01311217e2444..2fdcc16701dbc 100644 --- a/packages/react-reconciler/src/__tests__/Activity-test.js +++ b/packages/react-reconciler/src/__tests__/Activity-test.js @@ -14,6 +14,7 @@ let waitForPaint; let waitFor; let assertLog; let assertConsoleErrorDev; +let Suspense; describe('Activity', () => { beforeEach(() => { @@ -25,6 +26,7 @@ describe('Activity', () => { act = require('internal-test-utils').act; LegacyHidden = React.unstable_LegacyHidden; Activity = React.Activity; + Suspense = React.Suspense; useState = React.useState; useInsertionEffect = React.useInsertionEffect; useLayoutEffect = React.useLayoutEffect; @@ -1424,6 +1426,72 @@ describe('Activity', () => { ); }); + // @gate enableActivity + it('reveal an inner Activity boundary without revealing an outer one on the same host child', async () => { + // This ensures that no update is scheduled, which would cover up the bug if the parent + // then re-hides the child on the way up. + const memoizedElement =
; + function App({showOuter, showInner}) { + return ( + + + {memoizedElement} + + + ); + } + + const root = ReactNoop.createRoot(); + + // Prerender the whole tree. + await act(() => { + root.render(); + }); + expect(root).toMatchRenderedOutput(