Fix ViewTransition null stateNode with SuspenseList (#35520)

I was experimenting with animations in SuspenseList and hit a crash
using ViewTransition as a direct child with `revealOrder="together"`

```
    TypeError: Cannot read properties of null (reading 'autoName')

      33 |     return props.name;
      34 |   }
    > 35 |   if (instance.autoName !== null) {
         |                ^
      36 |     return instance.autoName;
      37 |   }
```

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.
This commit is contained in:
Jack Pope
2026-01-16 16:39:25 -05:00
committed by GitHub
parent 3fd9bf1728
commit 53ea70fbd2
2 changed files with 192 additions and 0 deletions
@@ -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 (
<div>
<AsyncText text={`Card ${id}`} />
</div>
);
}
function CardSkeleton({n}) {
return <Text text={`Skeleton ${n}`} />;
}
function App() {
return (
<div>
<SuspenseList revealOrder="together">
<ViewTransition>
<Suspense fallback={<CardSkeleton n={1} />}>
<Card id={1} />
</Suspense>
</ViewTransition>
<ViewTransition>
<Suspense fallback={<CardSkeleton n={2} />}>
<Card id={2} />
</Suspense>
</ViewTransition>
<ViewTransition>
<Suspense fallback={<CardSkeleton n={3} />}>
<Card id={3} />
</Suspense>
</ViewTransition>
</SuspenseList>
</div>
);
}
const root = ReactDOMClient.createRoot(container);
// Initial render - all cards should suspend
await act(() => {
root.render(<App />);
});
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');
});
});
+13
View File
@@ -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