diff --git a/packages/react-reconciler/src/ReactFiberLane.js b/packages/react-reconciler/src/ReactFiberLane.js
index 987f0338ad1a..dd30fef63e2c 100644
--- a/packages/react-reconciler/src/ReactFiberLane.js
+++ b/packages/react-reconciler/src/ReactFiberLane.js
@@ -619,6 +619,10 @@ export function includesSyncLane(lanes: Lanes): boolean {
export function includesNonIdleWork(lanes: Lanes): boolean {
return (lanes & NonIdleLanes) !== NoLanes;
}
+export function includesTransitionDeferredLanes(lanes: Lanes): boolean {
+ return (lanes & TransitionDeferredLanes) !== NoLanes;
+}
+
export function includesOnlyRetries(lanes: Lanes): boolean {
return (lanes & RetryLanes) === lanes;
}
diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js
index d055b271ad77..5a34784540f0 100644
--- a/packages/react-reconciler/src/ReactFiberWorkLoop.js
+++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js
@@ -189,6 +189,7 @@ import {
includesNonIdleWork,
includesOnlyRetries,
includesOnlyTransitions,
+ includesTransitionDeferredLanes,
includesBlockingLane,
includesTransitionLane,
includesRetryLane,
@@ -2866,6 +2867,32 @@ function renderRootConcurrent(root: FiberRoot, lanes: Lanes): RootExitStatus {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
+ } else if (
+ includesTransitionDeferredLanes(
+ workInProgressRootRenderLanes,
+ )
+ ) {
+ // For transition/deferred renders, wait for the data to
+ // resolve instead of immediately unwinding. This prevents
+ // an infinite retry loop where hooks like useMemo create a
+ // new promise on each retry because the committed hook
+ // state is stale. Since transition renders are not yet
+ // visible, waiting is safe — the committed UI stays on
+ // screen. New urgent updates will properly interrupt this
+ // render via scheduleUpdateOnFiber.
+ const onResolution = () => {
+ if (
+ workInProgressSuspendedReason === SuspendedOnData &&
+ workInProgressRoot === root
+ ) {
+ workInProgressSuspendedReason =
+ SuspendedAndReadyToContinue;
+ }
+ ensureRootIsScheduled(root);
+ };
+ thenable.then(onResolution, onResolution);
+ workInProgressSuspendedReason = SuspendedOnData;
+ break outer;
} else {
// Otherwise, unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
diff --git a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
index 3a348307f4cd..1247c0bf1f12 100644
--- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
+++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
@@ -1061,6 +1061,107 @@ describe('ReactDeferredValue', () => {
},
);
+ // Regression test for https://github.com/facebook/react/issues/35821
+ it('useDeferredValue with a promise catches up after rapid updates', async () => {
+ const use = React.use;
+ let setPromise;
+ const resolvers = new Map();
+
+ function createPromise(value) {
+ const promise = new Promise(resolve => {
+ resolvers.set(value, resolve);
+ });
+ // Instrument the promise so React can synchronously read the value
+ // once it resolves (similar to what a framework like Next.js does).
+ promise.then(
+ v => {
+ promise.status = 'fulfilled';
+ promise.value = v;
+ },
+ e => {
+ promise.status = 'rejected';
+ promise.reason = e;
+ },
+ );
+ return promise;
+ }
+
+ function resolvePromise(value) {
+ const resolve = resolvers.get(value);
+ if (resolve) {
+ resolvers.delete(value);
+ resolve(value);
+ }
+ }
+
+ function Resolved({promise}) {
+ const value = use(promise);
+ Scheduler.log('Resolved: ' + value);
+ return value;
+ }
+
+ // Create initial promise outside the component, matching the original
+ // issue pattern where promises come from external sources (e.g. server
+ // actions). Creating promises in state initializers is not supported.
+ const initialPromise = createPromise('initial');
+
+ function App() {
+ const [promise, _setPromise] = useState(initialPromise);
+ setPromise = _setPromise;
+ const deferred = useDeferredValue(promise);
+ Scheduler.log('Render');
+ return (
+ }>
+
+
+ );
+ }
+
+ const root = ReactNoop.createRoot();
+
+ // Initial render: promise is pending, shows loading
+ await act(() => root.render());
+ assertLog(['Render', 'Loading...']);
+ expect(root).toMatchRenderedOutput('Loading...');
+
+ // Resolve initial promise
+ await act(() => resolvePromise('initial'));
+ assertLog(['Resolved: initial']);
+ expect(root).toMatchRenderedOutput('initial');
+
+ // Simulate rapid typing: multiple setState calls in quick succession.
+ // Each one creates a new unresolved promise. Use act to flush all
+ // work between keystrokes.
+ await act(() => {
+ setPromise(createPromise('a'));
+ });
+ // Urgent render shows old resolved value; deferred render suspends
+ // on 'a' (pending). The exact log depends on how far the deferred
+ // render gets before suspending.
+ Scheduler.unstable_clearLog();
+ expect(root).toMatchRenderedOutput('initial');
+
+ await act(() => {
+ setPromise(createPromise('b'));
+ });
+ Scheduler.unstable_clearLog();
+ expect(root).toMatchRenderedOutput('initial');
+
+ await act(() => {
+ setPromise(createPromise('c'));
+ });
+ Scheduler.unstable_clearLog();
+ expect(root).toMatchRenderedOutput('initial');
+
+ // Now resolve only the latest promise and let act flush everything.
+ await act(() => {
+ resolvePromise('c');
+ });
+ Scheduler.unstable_clearLog();
+ // The deferred value should eventually catch up to 'c'
+ expect(root).toMatchRenderedOutput('c');
+ });
+
it(
'useDeferredValue does not show "previous" value when revealing a hidden ' +
'tree (no initial value)',