@samitouri / QOS-React / commits / c0d218f0f3

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 );