From 98785739062e13b608533824e2aee58a22547823 Mon Sep 17 00:00:00 2001 From: fresh3nough Date: Sat, 28 Feb 2026 18:16:29 +0000 Subject: [PATCH 1/2] fix: useDeferredValue gets stuck with stale value when used with use() and Suspense (#35821) When a deferred render suspends on a promise created by useMemo, each retry starts from the committed fiber state where the deps are stale. This causes useMemo to re-execute and create a new pending promise every retry, leading to an infinite suspend-retry loop. The thenableState that would allow trackUsedThenable to reuse the previous promise is cleared on unwind and not preserved across render attempts. For deferred lane renders (TransitionDeferredLanes), instead of immediately unwinding when SuspendedAndReadyToContinue finds the thenable unresolved, transition to SuspendedOnData and register an onResolution listener. This keeps the work-in-progress hooks intact so that when the promise resolves, replaySuspendedUnitOfWork can replay with the same (now-resolved) thenable via the preserved thenableState. The scheduler properly skips the root while suspended on data (isWorkLoopSuspendedOnData), and new urgent updates correctly interrupt the waiting render via scheduleUpdateOnFiber. --- .../react-reconciler/src/ReactFiberLane.js | 4 + .../src/ReactFiberWorkLoop.js | 27 ++++++ .../src/__tests__/ReactDeferredValue-test.js | 96 +++++++++++++++++++ 3 files changed, 127 insertions(+) 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..2b1886c35ec0 100644 --- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js +++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js @@ -1061,6 +1061,102 @@ 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; + } + + function App() { + const [promise, _setPromise] = useState(() => createPromise('initial')); + 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)', From b6240c47a59abf2296c0bee9a7eb8f888b369dd2 Mon Sep 17 00:00:00 2001 From: fresh3nough Date: Sat, 28 Feb 2026 19:50:53 +0000 Subject: [PATCH 2/2] fix: hoist initial promise out of useState initializer in test --- .../src/__tests__/ReactDeferredValue-test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js index 2b1886c35ec0..1247c0bf1f12 100644 --- a/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js +++ b/packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js @@ -1100,8 +1100,13 @@ describe('ReactDeferredValue', () => { 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(() => createPromise('initial')); + const [promise, _setPromise] = useState(initialPromise); setPromise = _setPromise; const deferred = useDeferredValue(promise); Scheduler.log('Render');