@samitouri / QOS-React / commits / cbb046ab92

[Fiber] Warn for Conditional Use of use() Based on Cache (#37104)

This is a cherry-pick of https://github.com/react/react/pull/34030, with a feature flag gating and a test coverage. The flag is disabled by default and dynamic for FB builds to understand first how noisy this warning can be. --- See https://github.com/react/react/pull/34030 for more context on the change.

Ruslan Lesiutin committed Jul 31, 2026 at 15:24 UTC cbb046ab92b66dfc4ad1e1ea30d4b8beae6f2c24
16 files changed +303 -31
packages/react-reconciler/src/ReactChildFiber.js
+1 -1
@@ -285,7 +285,7 @@ function unwrapThenable<T>(thenable: Thenable<T>): T {
285 if (thenableState === null) {
286 thenableState = createThenableState();
287 }
288 - return trackUsedThenable(thenableState, thenable, index);
288 + return trackUsedThenable(thenableState, thenable, index, null);
289 }
290
291 function coerceRef(workInProgress: Fiber, element: ReactElement): void {
packages/react-reconciler/src/ReactFiberHooks.js
+8 -1
@@ -144,6 +144,7 @@ import {now} from './Scheduler';
144 import {
145 trackUsedThenable,
146 checkIfUseWrappedInTryCatch,
147 + checkIfUseWasUsedBefore,
148 createThenableState,
149 SuspenseException,
150 SuspenseActionException,
@@ -651,6 +652,7 @@ function finishRenderingHooks<Props, SecondArg>(
652 } else {
653 workInProgress.dependencies._debugThenableState = thenableState;
654 }
655 + checkIfUseWasUsedBefore(workInProgress, thenableState);
656 }
657
658 // We can assume the previous dispatcher is always this one, since we set it
@@ -1100,7 +1102,12 @@ function useThenable<T>(thenable: Thenable<T>): T {
1102 if (thenableState === null) {
1103 thenableState = createThenableState();
1104 }
1103 - const result = trackUsedThenable(thenableState, thenable, index);
1105 + const result = trackUsedThenable(
1106 + thenableState,
1107 + thenable,
1108 + index,
1109 + __DEV__ ? currentlyRenderingFiber : null,
1110 + );
1111
1112 // When something suspends with `use`, we replay the component with the
1113 // "re-render" dispatcher instead of the "mount" or "update" dispatcher.
packages/react-reconciler/src/ReactFiberThenable.js
+88 -1
@@ -16,6 +16,7 @@ import type {
16 } from 'shared/ReactTypes';
17
18 import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
19 +import type {Fiber} from './ReactInternalTypes';
20
21 import {callLazyInitInDEV} from './ReactFiberCallUserSpace';
22
@@ -23,10 +24,15 @@ import {getWorkInProgressRoot} from './ReactFiberWorkLoop';
24
25 import ReactSharedInternals from 'shared/ReactSharedInternals';
26
26 -import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags';
27 +import {
28 + enableAsyncDebugInfo,
29 + enableConditionalUseWarning,
30 +} from 'shared/ReactFeatureFlags';
31
32 import noop from 'shared/noop';
33
34 +import {HostRoot} from './ReactWorkTags';
35 +
36 opaque type ThenableStateDev = {
37 didWarnAboutUncachedPromise: boolean,
38 thenables: Array<Thenable<any>>,
@@ -104,10 +110,23 @@ export function isThenableResolved(thenable: Thenable<mixed>): boolean {
110 return status === 'fulfilled' || status === 'rejected';
111 }
112
113 +// DEV-only
114 +let lastSuspendedFiber: null | Fiber = null;
115 +let lastSuspendedStack: null | Error = null;
116 +let didIssueUseWarning = false;
117 +
118 +export function hasPotentialUseWarnings(): boolean {
119 + return enableConditionalUseWarning && lastSuspendedFiber !== null;
120 +}
121 +export function clearUseWarnings() {
122 + lastSuspendedFiber = null;
123 +}
124 +
125 export function trackUsedThenable<T>(
126 thenableState: ThenableState,
127 thenable: Thenable<T>,
128 index: number,
129 + fiber: null | Fiber, // DEV-only
130 ): T {
131 if (__DEV__ && ReactSharedInternals.actQueue !== null) {
132 ReactSharedInternals.didUsePromise = true;
@@ -298,6 +317,23 @@ export function trackUsedThenable<T>(
317 suspendedThenable = thenable;
318 if (__DEV__) {
319 needsToResetSuspendedThenableDEV = true;
320 + if (
321 + enableConditionalUseWarning &&
322 + !didIssueUseWarning &&
323 + fiber !== null &&
324 + // Only track initial mount for now to avoid warning too much for updates.
325 + fiber.alternate === null
326 + ) {
327 + lastSuspendedFiber = fiber;
328 + // Stash an error in case we end up triggering the use() warning.
329 + // This ensures that we have a stack trace at the location of the first use()
330 + // call since there won't be a second one we have to do that eagerly.
331 + lastSuspendedStack = new Error(
332 + 'This library called use() to suspend in a previous render but ' +
333 + 'did not call use() when it finished. This indicates an incorrect use of use(). ' +
334 + 'Learn more: https://react.dev/warnings/conditional-use-of-use',
335 + );
336 + }
337 }
338 throw SuspenseException;
339 }
@@ -390,3 +426,54 @@ export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
426 );
427 }
428 }
429 +
430 +function areSameKeyPath(a: Fiber, b: Fiber): boolean {
431 + if (a === b) {
432 + return true;
433 + }
434 + if (
435 + a.tag !== b.tag ||
436 + a.type !== b.type ||
437 + a.key !== b.key ||
438 + a.index !== b.index
439 + ) {
440 + return false;
441 + }
442 + if (a.tag === HostRoot && a.stateNode !== b.stateNode) {
443 + // These are both roots but they're different roots so they're not in the same tree.
444 + return false;
445 + }
446 + if (a.return === null || b.return === null) {
447 + return false;
448 + }
449 + return areSameKeyPath(a.return, b.return);
450 +}
451 +
452 +export function checkIfUseWasUsedBefore(
453 + unsuspendedFiber: Fiber,
454 + thenableState: null | ThenableState,
455 +): void {
456 + if (__DEV__ && enableConditionalUseWarning) {
457 + if (
458 + lastSuspendedFiber !== null &&
459 + areSameKeyPath(lastSuspendedFiber, unsuspendedFiber)
460 + ) {
461 + if (thenableState !== null) {
462 + // It's still using use() ever after resolving. We could warn for different number of them but for
463 + // now we treat this as ok and clear the state.
464 + lastSuspendedFiber = null;
465 + lastSuspendedStack = null;
466 + } else {
467 + // The last suspended Fiber using use() is no longer using use() in the same position.
468 + // That's suspicious. Likely it was unblocked by conditionally using use() which is incorrect.
469 + if (lastSuspendedStack !== null && !didIssueUseWarning) {
470 + didIssueUseWarning = true;
471 + // We pass the error object instead of custom message so that the browser displays the error natively.
472 + console['error'](lastSuspendedStack);
473 + }
474 + lastSuspendedFiber = null;
475 + lastSuspendedStack = null;
476 + }
477 + }
478 + }
479 +}
packages/react-reconciler/src/ReactFiberWorkLoop.js
+15 -1
@@ -392,6 +392,8 @@ import {
392 SuspenseyCommitException,
393 getSuspendedThenable,
394 isThenableResolved,
395 + hasPotentialUseWarnings,
396 + clearUseWarnings,
397 } from './ReactFiberThenable';
398 import {schedulePostPaintCallback} from './ReactPostPaintCallback';
399 import {
@@ -845,12 +847,24 @@ export function requestUpdateLane(fiber: Fiber): Lane {
847 transition._updatedFibers = new Set();
848 }
849 transition._updatedFibers.add(fiber);
850 + if (
851 + hasPotentialUseWarnings() &&
852 + resolveUpdatePriority() === DiscreteEventPriority
853 + ) {
854 + // If we're updating inside a discrete event, then this might be a new user interaction
855 + // and not just an automatically resolved loading sequence. Don't warn unless it happens again.
856 + clearUseWarnings();
857 + }
858 }
859
860 return requestTransitionLane(transition);
861 }
862
853 - return eventPriorityToLane(resolveUpdatePriority());
863 + const priority = resolveUpdatePriority();
864 + if (__DEV__ && priority === DiscreteEventPriority) {
865 + clearUseWarnings();
866 + }
867 + return eventPriorityToLane(priority);
868 }
869
870 function requestRetryLane(fiber: Fiber) {
packages/react-reconciler/src/__tests__/ActivitySuspense-test.js
+18 -26
@@ -41,47 +41,39 @@ describe('Activity Suspense', () => {
41 function resolveText(text) {
42 const record = textCache.get(text);
43 if (record === undefined) {
44 + const promise = Promise.resolve(text);
45 + promise.status = 'fulfilled';
46 + promise.value = text;
47 const newRecord = {
45 - status: 'resolved',
46 - value: text,
48 + promise,
49 };
50 textCache.set(text, newRecord);
49 - } else if (record.status === 'pending') {
51 + } else if (record.promise.status === 'pending') {
52 const resolve = record.resolve;
51 - record.status = 'resolved';
52 - record.value = text;
53 - resolve();
53 + record.promise.status = 'fulfilled';
54 + record.promise.value = text;
55 + resolve(text);
56 }
57 }
58
59 function readText(text) {
58 - const record = textCache.get(text);
59 - if (record !== undefined) {
60 - switch (record.status) {
61 - case 'pending':
62 - Scheduler.log(`Suspend! [${text}]`);
63 - return use(record.value);
64 - case 'rejected':
65 - throw record.value;
66 - case 'resolved':
67 - return record.value;
68 - }
69 - } else {
70 - Scheduler.log(`Suspend! [${text}]`);
60 + let record = textCache.get(text);
61 + if (record === undefined) {
62 let resolve;
63 const promise = new Promise(_resolve => {
64 resolve = _resolve;
65 });
75 -
76 - const newRecord = {
77 - status: 'pending',
78 - value: promise,
66 + promise.status = 'pending';
67 + record = {
68 + promise,
69 resolve,
70 };
81 - textCache.set(text, newRecord);
82 -
83 - return use(promise);
71 + textCache.set(text, record);
72 + }
73 + if (record.promise.status === 'pending') {
74 + Scheduler.log(`Suspend! [${text}]`);
75 }
76 + return use(record.promise);
77 }
78
79 function Text({text}) {
packages/react-reconciler/src/__tests__/ReactConditionalUseWarning-test.js new
+161
@@ -0,0 +1,161 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + */
7 +
8 +'use strict';
9 +
10 +let React;
11 +let ReactNoop;
12 +let Scheduler;
13 +let act;
14 +let assertLog;
15 +let use;
16 +let Suspense;
17 +let startTransition;
18 +
19 +describe('conditional use warning', () => {
20 + beforeEach(() => {
21 + jest.resetModules();
22 +
23 + React = require('react');
24 + ReactNoop = require('react-noop-renderer');
25 + Scheduler = require('scheduler');
26 + act = require('internal-test-utils').act;
27 + assertLog = require('internal-test-utils').assertLog;
28 + use = React.use;
29 + Suspense = React.Suspense;
30 + startTransition = React.startTransition;
31 + });
32 +
33 + // @gate __DEV__ && enableConditionalUseWarning
34 + it('warns if use(promise) is called conditionally based on a cache', async () => {
35 + let cachedValue;
36 + let resolve;
37 + const promise = new Promise(r => {
38 + resolve = value => {
39 + cachedValue = value;
40 + r(value);
41 + };
42 + });
43 +
44 + function Text({text}) {
45 + Scheduler.log(text);
46 + return text;
47 + }
48 +
49 + function Async() {
50 + if (cachedValue !== undefined) {
51 + return <Text text={cachedValue} />;
52 + }
53 + return <Text text={use(promise)} />;
54 + }
55 +
56 + const root = ReactNoop.createRoot();
57 + await act(() => {
58 + root.render(
59 + <Suspense fallback={<Text text="Loading..." />}>
60 + <Text text="Initial" />
61 + </Suspense>,
62 + );
63 + });
64 + assertLog(['Initial']);
65 + expect(root).toMatchRenderedOutput('Initial');
66 +
67 + spyOnDev(console, 'error').mockImplementation(() => {});
68 + try {
69 + await act(() => {
70 + startTransition(() => {
71 + root.render(
72 + <Suspense fallback={<Text text="Loading..." />}>
73 + <Async />
74 + </Suspense>,
75 + );
76 + });
77 + });
78 + assertLog(['Loading...']);
79 + expect(root).toMatchRenderedOutput('Initial');
80 +
81 + await act(() => resolve('Async'));
82 + assertLog(['Async']);
83 + expect(root).toMatchRenderedOutput('Async');
84 +
85 + expect(console.error).toHaveBeenCalledTimes(1);
86 + const warning = console.error.mock.calls[0][0];
87 + expect(warning).toBeInstanceOf(Error);
88 + expect(warning.message).toBe(
89 + 'This library called use() to suspend in a previous render but ' +
90 + 'did not call use() when it finished. This indicates an incorrect use of use(). ' +
91 + 'Learn more: https://react.dev/warnings/conditional-use-of-use',
92 + );
93 +
94 + await act(() => {
95 + root.render(
96 + <Suspense fallback={<Text text="Loading..." />}>
97 + <Async />
98 + </Suspense>,
99 + );
100 + });
101 + assertLog(['Async']);
102 + expect(root).toMatchRenderedOutput('Async');
103 + expect(console.error).toHaveBeenCalledTimes(1);
104 + } finally {
105 + if (__DEV__) {
106 + console.error.mockRestore();
107 + }
108 + }
109 + });
110 +
111 + it('does not warn if use(promise) is called unconditionally', async () => {
112 + let resolve;
113 + const promise = new Promise(r => {
114 + resolve = r;
115 + });
116 +
117 + function Text({text}) {
118 + Scheduler.log(text);
119 + return text;
120 + }
121 +
122 + function Async() {
123 + return <Text text={use(promise)} />;
124 + }
125 +
126 + const root = ReactNoop.createRoot();
127 + spyOnDev(console, 'error').mockImplementation(() => {});
128 + try {
129 + await act(() => {
130 + root.render(
131 + <Suspense fallback={<Text text="Loading..." />}>
132 + <Async />
133 + </Suspense>,
134 + );
135 + });
136 + assertLog(['Loading...']);
137 + expect(root).toMatchRenderedOutput('Loading...');
138 +
139 + await act(() => resolve('Async'));
140 + assertLog(['Async']);
141 + expect(root).toMatchRenderedOutput('Async');
142 +
143 + await act(() => {
144 + root.render(
145 + <Suspense fallback={<Text text="Loading..." />}>
146 + <Async />
147 + </Suspense>,
148 + );
149 + });
150 + assertLog(['Async']);
151 + expect(root).toMatchRenderedOutput('Async');
152 + if (__DEV__) {
153 + expect(console.error).not.toHaveBeenCalled();
154 + }
155 + } finally {
156 + if (__DEV__) {
157 + console.error.mockRestore();
158 + }
159 + }
160 + });
161 +});
packages/shared/ReactFeatureFlags.js
+2
@@ -154,6 +154,8 @@ export const enableInfiniteRenderLoopDetection: boolean = false;
154 */
155 export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
156
157 +export const enableConditionalUseWarning: boolean = false;
158 +
159 export const enableFragmentRefs: boolean = true;
160 export const enableFragmentRefsScrollIntoView: boolean = true;
161 export const enableFragmentRefsInstanceHandles: boolean = true;
packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js
+1
@@ -25,3 +25,4 @@ export const enableFragmentRefsScrollIntoView = __VARIANT__;
25 export const enableFragmentRefsInstanceHandles = __VARIANT__;
26 export const enableFragmentRefsTextNodes = __VARIANT__;
27 export const enableViewTransitionForPersistenceMode = __VARIANT__;
28 +export const enableConditionalUseWarning = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -27,6 +27,7 @@ export const {
27 enableFragmentRefsInstanceHandles,
28 enableFragmentRefsTextNodes,
29 enableViewTransitionForPersistenceMode,
30 + enableConditionalUseWarning,
31 } = dynamicFlags;
32
33 // The rest of the flags are static for better dead code elimination.
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -33,6 +33,7 @@ export const enableMoveBefore: boolean = true;
33 export const enableFizzExternalRuntime: boolean = true;
34 export const enableInfiniteRenderLoopDetection: boolean = false;
35 export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
36 +export const enableConditionalUseWarning: boolean = false;
37 export const enableLegacyCache: boolean = false;
38 export const enableLegacyFBSupport: boolean = false;
39 export const enableLegacyHidden: boolean = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -54,6 +54,7 @@ export const disableClientCache: boolean = true;
54
55 export const enableInfiniteRenderLoopDetection: boolean = false;
56 export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
57 +export const enableConditionalUseWarning: boolean = false;
58
59 export const enableEffectEventMutationPhase: boolean = true;
60
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -28,6 +28,7 @@ export const enableMoveBefore = false;
28 export const enableFizzExternalRuntime = true;
29 export const enableInfiniteRenderLoopDetection = false;
30 export const enableInfiniteRenderLoopDetectionForceThrow = false;
31 +export const enableConditionalUseWarning = false;
32 export const enableLegacyCache = false;
33 export const enableLegacyFBSupport = false;
34 export const enableLegacyHidden = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -56,6 +56,7 @@ export const disableClientCache: boolean = true;
56
57 export const enableInfiniteRenderLoopDetection: boolean = false;
58 export const enableInfiniteRenderLoopDetectionForceThrow: boolean = false;
59 +export const enableConditionalUseWarning: boolean = false;
60
61 export const enableReactTestRendererWarning: boolean = false;
62 export const disableLegacyMode: boolean = true;
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -28,6 +28,7 @@ export const enableSchedulingProfiler: boolean = __VARIANT__;
28
29 export const enableInfiniteRenderLoopDetection: boolean = __VARIANT__;
30 export const enableInfiniteRenderLoopDetectionForceThrow: boolean = __VARIANT__;
31 +export const enableConditionalUseWarning: boolean = __VARIANT__;
32
33 export const enableFastAddPropertiesInDiffing: boolean = __VARIANT__;
34 export const enableSuspenseyImages: boolean = __VARIANT__;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -20,6 +20,7 @@ export const {
20 disableSchedulerTimeoutInWorkLoop,
21 enableInfiniteRenderLoopDetection,
22 enableInfiniteRenderLoopDetectionForceThrow,
23 + enableConditionalUseWarning,
24 enableNoCloningMemoCache,
25 enableObjectFiber,
26 enableRetryLaneExpiration,
scripts/error-codes/codes.json
+2 -1
@@ -591,5 +591,6 @@
591 "603": "Recoverable Exception: This is not a real error! It's an implementation detail of `use(browser())` to defer rendering to the browser. `use(browser())` can only be used inside a `<Suspense>` boundary. If a server render errors with this as its cause, the component that called `use(browser())` does not have a `<Suspense>` boundary above it.",
592 "604": "The server render could not complete because client rendering was requested outside a Suspense boundary. See this error's cause for additional details.",
593 "605": "Recoverable Exception: This is not a real error! It's an implementation detail of `use` to interrupt the current render so a downstream renderer can recover it. You must either rethrow it immediately, or move the `use` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.",
594 - "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue."
594 + "606": "Expected a suspended recoverable. This is a bug in React. Please file an issue.",
595 + "607": "This library called use() to suspend in a previous render but did not call use() when it finished. This indicates an incorrect use of use(). Learn more: https://react.dev/warnings/conditional-use-of-use"
596 }