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)',