Fix useDeferredValue getting stuck (#36134)
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
dan committed
Mar 24, 2026 at 00:49 UTC
c0d218f0f3e02ed581f74096e56baa947213135d
3 files changed
+70
-2
packages/react-reconciler/src/ReactFiberWorkLoop.js
+7
@@ -5040,6 +5040,13 @@ function pingSuspendedRoot(
5040
// the special internal exception that we use to interrupt the stack for
5041
// selective hydration. That was temporarily reverted but we once we add
5042
// it back we can use it here.
5043
+ //
5044
+ // In the meantime, record the pinged lanes so markRootSuspended won't
5045
+ // mark them as suspended, allowing a retry.
5046
+ workInProgressRootPingedLanes = mergeLanes(
5047
+ workInProgressRootPingedLanes,
5048
+ pingedLanes,
5049
+ );
5050
}
5051
} else {
5052
// Even though we can't restart right now, we might get an
packages/react-reconciler/src/__tests__/ReactDeferredValue-test.js
+52
@@ -1097,4 +1097,56 @@ describe('ReactDeferredValue', () => {
1097
expect(root).toMatchRenderedOutput(<div>B</div>);
1098
},
1099
);
1100
+
1101
+ // Regression test for https://github.com/facebook/react/issues/35821
1102
+ it('deferred value catches up when a suspension is resolved during the same render', async () => {
1103
+ let setValue;
1104
+ function App() {
1105
+ const [value, _setValue] = useState('initial');
1106
+ setValue = _setValue;
1107
+ const deferred = useDeferredValue(value);
1108
+ return (
1109
+ <Suspense fallback={<Text text="Loading..." />}>
1110
+ <AsyncText text={'A:' + deferred} />
1111
+ <Sibling text={deferred} />
1112
+ </Suspense>
1113
+ );
1114
+ }
1115
+
1116
+ function Sibling({text}) {
1117
+ if (text !== 'initial') {
1118
+ // Resolve A during this render, simulating data arriving while
1119
+ // a render is already in progress.
1120
+ resolveText('A:' + text);
1121
+ }
1122
+ readText('B:' + text);
1123
+ Scheduler.log('B: ' + text);
1124
+ return text;
1125
+ }
1126
+
1127
+ const root = ReactNoop.createRoot();
1128
+
1129
+ resolveText('A:initial');
1130
+ resolveText('B:initial');
1131
+ await act(() => root.render(<App />));
1132
+ assertLog(['A:initial', 'B: initial']);
1133
+
1134
+ // Pre-resolve B so the sibling won't suspend on retry.
1135
+ resolveText('B:updated');
1136
+
1137
+ await act(() => setValue('updated'));
1138
+ assertLog([
1139
+ // Sync render defers the value.
1140
+ 'A:initial',
1141
+ 'B: initial',
1142
+ // Deferred render: A suspends, then Sibling resolves A mid-render.
1143
+ 'Suspend! [A:updated]',
1144
+ 'B: updated',
1145
+ 'Loading...',
1146
+ // React retries and the deferred value catches up.
1147
+ 'A:updated',
1148
+ 'B: updated',
1149
+ ]);
1150
+ expect(root).toMatchRenderedOutput('A:updatedupdated');
1151
+ });
1152
});
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js
+11
-2
@@ -4054,11 +4054,20 @@ describe('ReactSuspenseWithNoopRenderer', () => {
4054
// microtask). But this test shows an example where that's not the case.
4055
//
4056
// The fix was to check if we're in the render phase before calling
4057
- // `prepareFreshStack`.
4057
+ // `prepareFreshStack`. The synchronous ping is instead recorded so the
4058
+ // lane can be retried.
4059
await act(() => {
4060
startTransition(() => root.render(<App showMore={true} />));
4061
});
4061
- assertLog(['Suspend! [A]', 'Loading A...', 'Loading B...']);
4062
+ assertLog([
4063
+ 'Suspend! [A]',
4064
+ 'Loading A...',
4065
+ 'Loading B...',
4066
+ // The synchronous ping was recorded, so B retries and renders.
4067
+ 'Suspend! [A]',
4068
+ 'Loading A...',
4069
+ 'B',
4070
+ ]);
4071
expect(root).toMatchRenderedOutput(<div />);
4072
},
4073
);