Repository: facebook/react
Pull request: Fix useDeferredValue getting stuck

Fixes https://github.com/facebook/react/issues/35821

Written/debugged by Claude.

## Test Plan

- Verify undoing the source fix fails the newly added test
- Verify building a bundle with the fix solves https://github.com/gaearon/react-udv-bug/ repro

Changed files:
filename: packages/react-reconciler/src/ReactFiberWorkLoop.js
patch: @@ -5040,6 +5040,13 @@ function pingSuspendedRoot(
         // the special internal exception that we use to interrupt the stack for
         // selective hydration. That was temporarily reverted but we once we add
         // it back we can use it here.
+        //
+        // In the meantime, record the pinged lanes so markRootSuspended won't
+        // mark them as suspended, allowing a retry.
+        workInProgressRootPingedLanes = mergeLanes(
+          workInProgressRootPingedLanes,
+          pingedLanes,
+        );
       }
     } else {
       // Even though we can't restart right now, we might get an

filename: packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
patch: @@ -1097,4 +1097,56 @@ describe('ReactDeferredValue', () => {
       expect(root).toMatchRenderedOutput(<div>B</div>);
     },
   );
+
+  // Regression test for https://github.com/facebook/react/issues/35821
+  it('deferred value catches up when a suspension is resolved during the same render', async () => {
+    let setValue;
+    function App() {
+      const [value, _setValue] = useState('initial');
+      setValue = _setValue;
+      const deferred = useDeferredValue(value);
+      return (
+        <Suspense fallback={<Text text="Loading..." />}>
+          <AsyncText text={'A:' + deferred} />
+          <Sibling text={deferred} />
+        </Suspense>
+      );
+    }
+
+    function Sibling({text}) {
+      if (text !== 'initial') {
+        // Resolve A during this render, simulating data arriving while
+        // a render is already in progress.
+        resolveText('A:' + text);
+      }
+      readText('B:' + text);
+      Scheduler.log('B: ' + text);
+      return text;
+    }
+
+    const root = ReactNoop.createRoot();
+
+    resolveText('A:initial');
+    resolveText('B:initial');
+    await act(() => root.render(<App />));
+    assertLog(['A:initial', 'B: initial']);
+
+    // Pre-resolve B so the sibling won't suspend on retry.
+    resolveText('B:updated');
+
+    await act(() => setValue('updated'));
+    assertLog([
+      // Sync render defers the value.
+      'A:initial',
+      'B: initial',
+      // Deferred render: A suspends, then Sibling resolves A mid-render.
+      'Suspend! [A:updated]',
+      'B: updated',
+      'Loading...',
+      // React retries and the deferred value catches up.
+      'A:updated',
+      'B: updated',
+    ]);
+    expect(root).toMatchRenderedOutput('A:updatedupdated');
+  });
 });

filename: packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
patch: @@ -4054,11 +4054,20 @@ describe('ReactSuspenseWithNoopRenderer', () => {
       // microtask). But this test shows an example where that's not the case.
       //
       // The fix was to check if we're in the render phase before calling
-      // `prepareFreshStack`.
+      // `prepareFreshStack`. The synchronous ping is instead recorded so the
+      // lane can be retried.
       await act(() => {
         startTransition(() => root.render(<App showMore={true} />));
       });
-      assertLog(['Suspend! [A]', 'Loading A...', 'Loading B...']);
+      assertLog([
+        'Suspend! [A]',
+        'Loading A...',
+        'Loading B...',
+        // The synchronous ping was recorded, so B retries and renders.
+        'Suspend! [A]',
+        'Loading A...',
+        'B',
+      ]);
       expect(root).toMatchRenderedOutput(<div />);
     },
   );