[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
}