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