From fff8811efcd5c452218086eee0177352e344f8e8 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Thu, 15 Jan 2026 18:50:35 -0500 Subject: [PATCH] Fix ViewTransition null stateNode with SuspenseList When ViewTransition is direct child of SuspenseList, the second render pass calls resetChildFibers, setting stateNode to null. Other fibers create stateNode in completeWork. ViewTransition does not, so stateNode is lost. Followed the pattern used for Offscreen to update stateNode in beginWork if it is null. Also added a regression test. --- .../__tests__/ReactDOMViewTransition-test.js | 179 ++++++++++++++++++ .../src/ReactFiberBeginWork.js | 13 ++ 2 files changed, 192 insertions(+) create mode 100644 packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js diff --git a/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js new file mode 100644 index 00000000000..1c5b43a18ac --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js @@ -0,0 +1,179 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOMClient; +let Suspense; +let SuspenseList; +let ViewTransition; +let act; +let assertLog; +let Scheduler; +let textCache; + +describe('ReactDOMViewTransition', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + Scheduler = require('scheduler'); + act = require('internal-test-utils').act; + assertLog = require('internal-test-utils').assertLog; + Suspense = React.Suspense; + ViewTransition = React.ViewTransition; + if (gate(flags => flags.enableSuspenseList)) { + SuspenseList = React.unstable_SuspenseList; + } + container = document.createElement('div'); + document.body.appendChild(container); + + textCache = new Map(); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function resolveText(text) { + const record = textCache.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + textCache.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + } + + function readText(text) { + const record = textCache.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.log(`Suspend! [${text}]`); + throw record.value; + case 'rejected': + throw record.value; + case 'resolved': + return record.value; + } + } else { + Scheduler.log(`Suspend! [${text}]`); + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.set(text, newRecord); + + throw thenable; + } + } + + function Text({text}) { + Scheduler.log(text); + return text; + } + + function AsyncText({text}) { + readText(text); + Scheduler.log(text); + return text; + } + + // @gate enableSuspenseList + it('handles ViewTransition wrapping Suspense inside SuspenseList', async () => { + function Card({id}) { + return ( +
+ +
+ ); + } + + function CardSkeleton({n}) { + return ; + } + + function App() { + return ( +
+ + + }> + + + + + }> + + + + + }> + + + + +
+ ); + } + + const root = ReactDOMClient.createRoot(container); + + // Initial render - all cards should suspend + await act(() => { + root.render(); + }); + + assertLog([ + 'Suspend! [Card 1]', + 'Skeleton 1', + 'Suspend! [Card 2]', + 'Skeleton 2', + 'Suspend! [Card 3]', + 'Skeleton 3', + 'Skeleton 1', + 'Skeleton 2', + 'Skeleton 3', + ]); + + await act(() => { + resolveText('Card 1'); + resolveText('Card 2'); + resolveText('Card 3'); + }); + + assertLog(['Card 1', 'Card 2', 'Card 3']); + + // All cards should be visible + expect(container.textContent).toContain('Card 1'); + expect(container.textContent).toContain('Card 2'); + expect(container.textContent).toContain('Card 3'); + }); +}); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index ba401a55d4b..2cbd670a7ff 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -46,6 +46,7 @@ import type { import type {UpdateQueue} from './ReactFiberClassUpdateQueue'; import type {RootState} from './ReactFiberRoot'; import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent'; +import type {ViewTransitionState} from './ReactFiberViewTransitionComponent'; import { markComponentRenderStarted, @@ -3566,6 +3567,18 @@ function updateViewTransition( workInProgress: Fiber, renderLanes: Lanes, ) { + if (workInProgress.stateNode === null) { + // We previously reset the work-in-progress. + // We need to create a new ViewTransitionState instance. + const instance: ViewTransitionState = { + autoName: null, + paired: null, + clones: null, + ref: null, + }; + workInProgress.stateNode = instance; + } + const pendingProps: ViewTransitionProps = workInProgress.pendingProps; if (pendingProps.name != null && pendingProps.name !== 'auto') { // Explicitly named boundary. We track it so that we can pair it up with another explicit