diff --git a/packages/react-reconciler/src/ReactFiberConcurrentUpdates.new.js b/packages/react-reconciler/src/ReactFiberConcurrentUpdates.new.js index 74e19f40b4982..4114da9dd226c 100644 --- a/packages/react-reconciler/src/ReactFiberConcurrentUpdates.new.js +++ b/packages/react-reconciler/src/ReactFiberConcurrentUpdates.new.js @@ -193,8 +193,25 @@ function markUpdateLaneFromFiberToRoot( } if (parent.tag === OffscreenComponent) { - const offscreenInstance: OffscreenInstance = parent.stateNode; - if (offscreenInstance.isHidden) { + // Check if this offscreen boundary is currently hidden. + // + // The instance may be null if the Offscreen parent was unmounted. Usually + // the parent wouldn't be reachable in that case because we disconnect + // fibers from the tree when they are deleted. However, there's a weird + // edge case where setState is called on a fiber that was interrupted + // before it ever mounted. Because it never mounts, it also never gets + // deleted. Because it never gets deleted, its return pointer never gets + // disconnected. Which means it may be attached to a deleted Offscreen + // parent node. (This discovery suggests it may be better for memory usage + // if we don't attach the `return` pointer until the commit phase, though + // in order to do that we'd need some other way to track the return + // pointer during the initial render, like on the stack.) + // + // This case is always accompanied by a warning, but we still need to + // account for it. (There may be other cases that we haven't discovered, + // too.) + const offscreenInstance: OffscreenInstance | null = parent.stateNode; + if (offscreenInstance !== null && offscreenInstance.isHidden) { isHidden = true; } } diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js index 0f9fd6dd5b217..bed5691426a4a 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js @@ -7,6 +7,7 @@ let Offscreen; let useState; let useLayoutEffect; let useEffect; +let startTransition; describe('ReactOffscreen', () => { beforeEach(() => { @@ -21,6 +22,7 @@ describe('ReactOffscreen', () => { useState = React.useState; useLayoutEffect = React.useLayoutEffect; useEffect = React.useEffect; + startTransition = React.startTransition; }); function Text(props) { @@ -591,4 +593,53 @@ describe('ReactOffscreen', () => { , ); }); + + // TODO: Create TestFlag alias for Offscreen + // @gate experimental || www + it('regression: Offscreen instance is sometimes null during setState', async () => { + let setState; + function Child() { + const [state, _setState] = useState('Initial'); + setState = _setState; + return ; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(