Repository: facebook/react
Pull request: Fix ViewTransition null stateNode with SuspenseList

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.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMViewTransition-test.js
patch: @@ -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');
+  });
+});

filename: packages/react-reconciler/src/ReactFiberBeginWork.js
patch: @@ -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