@samitouri / QOS-React-2 / commits / c0cd4d5d30

Throw special error if rejected Promises are incorrectly instrumented (#36328)

When a rejected `Promise` is instrumented in userspace while setting the rejection reason in the wrong field (e.g. `error` instead of `reason`), React will throw undefined (because `usable.reason` doesn't exist). This makes it incredibly hard to find the actual rejection reason. React is now throwing a generic error if we couldn't find the rejection reason. That will produce a callstack pointing into the problematic Promise from where you can hopefully extract the real rejection reason (alongside fixing the bad instrumentation). We're doing this in prod since this is unlikely to surface in dev. We're only doing runtime type-checking for the rejected case. That should be hit rarely and therefore hopefully have negligible runtime impact.

Sebastian "Sebbie" Silbermann committed May 27, 2026 at 12:00 UTC c0cd4d5d30f3ad56380c14ac53c6f50d96e38814
5 files changed +185 -2
packages/react-reconciler/src/ReactFiberThenable.js
+18
@@ -192,12 +192,30 @@ export function trackUsedThenable<T>(
192 // a listener that will update its status and result when it resolves.
193 switch (thenable.status) {
194 case 'fulfilled': {
195 + // This could be a bad instrumentation that doesn't set .value.
196 + // We're not type-checking since this is a hot path where you can
197 + // track down easily when something becomes `undefined` unexpectedly.
198 const fulfilledValue: T = thenable.value;
199 return fulfilledValue;
200 }
201 case 'rejected': {
202 const rejectedError = thenable.reason;
203 checkIfUseWrappedInAsyncCatch(rejectedError);
204 +
205 + // Rejected Promises are rarer so we're doing an extra type-check in
206 + // case of a bad instrumentation that doesn't set .reason
207 + // If we end up throwing `undefined` it becomes hard to track down
208 + // where that throw originated because no callstack would exist.
209 + // React would still have a Component stack but that could only be used
210 + // as an approximation.
211 + if (rejectedError === undefined && !('reason' in thenable)) {
212 + throw new Error(
213 + 'A rejected Promise was passed to React without a `reason` property. ' +
214 + 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' +
215 + "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.",
216 + );
217 + }
218 +
219 throw rejectedError;
220 }
221 default: {
packages/react-reconciler/src/__tests__/ReactUse-test.js
+128
@@ -81,6 +81,30 @@ describe('ReactUse', () => {
81 });
82 }
83
84 + function normalizeCodeLocInfo(str) {
85 + return (
86 + str &&
87 + str.replace(
88 + /^ +(?:at|in) ([\S]+)([^\n]*)(\n?)/gm,
89 + function (m, name, location, eol) {
90 + if (location.indexOf(__filename) === -1) {
91 + // ignore frames from library code
92 + return '';
93 + }
94 + return (
95 + ' in ' +
96 + name +
97 + (/:\d+:\d+/.test(m)
98 + ? ' ' +
99 + location.replace(__dirname, '~').replace(/:\d+:\d+/, ':*:*')
100 + : '') +
101 + eol
102 + );
103 + },
104 + )
105 + );
106 + }
107 +
108 function Text({text}) {
109 Scheduler.log(text);
110 return text;
@@ -2189,4 +2213,108 @@ describe('ReactUse', () => {
2213 expect(root).toMatchRenderedOutput('Updated');
2214 },
2215 );
2216 +
2217 + it('throws a descriptive error when a rejected promise without a reason property is passed to use()', async () => {
2218 + const badThenable = Promise.resolve();
2219 + // Simulate bad instrumentation: status is 'rejected' but the reason is not
2220 + // in the `reason` property as intended.
2221 + badThenable.status = 'rejected';
2222 + badThenable.error = new Error('Something went wrong');
2223 +
2224 + function Child() {
2225 + return use(badThenable);
2226 + }
2227 +
2228 + function App({
2229 + // Intentionally destrucutring a prop here so that our production error
2230 + // stack trick is triggered at the beginning of the function
2231 + prop,
2232 + }) {
2233 + return <Child />;
2234 + }
2235 +
2236 + const uncaughtErrors = [];
2237 + const root = ReactNoop.createRoot({
2238 + onUncaughtError(error, errorInfo) {
2239 + uncaughtErrors.push({
2240 + callStack: normalizeCodeLocInfo(error.stack),
2241 + ownerStack: React.captureOwnerStack
2242 + ? normalizeCodeLocInfo(React.captureOwnerStack())
2243 + : null,
2244 + componentStack: normalizeCodeLocInfo(errorInfo.componentStack),
2245 + });
2246 + },
2247 + });
2248 +
2249 + await act(() => {
2250 + root.render(<App />);
2251 + });
2252 +
2253 + expect(uncaughtErrors).toEqual([
2254 + {
2255 + callStack:
2256 + 'Error: A rejected Promise was passed to React without a `reason` property. ' +
2257 + 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' +
2258 + "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`." +
2259 + '\n in Child (~/ReactUse-test.js:*:*)' +
2260 + '\n in Object.<anonymous> (~/ReactUse-test.js:*:*)',
2261 + componentStack:
2262 + '\n in Child (~/ReactUse-test.js:*:*)' +
2263 + '\n in App (~/ReactUse-test.js:*:*)',
2264 + ownerStack: __DEV__ ? '\n in App (~/ReactUse-test.js:*:*)' : null,
2265 + },
2266 + ]);
2267 + });
2268 +
2269 + it('throws a descriptive error when a rejected promise without a reason property is used as a child', async () => {
2270 + const badThenable = Promise.resolve();
2271 + // Simulate bad instrumentation: status is 'rejected' but the reason is not
2272 + // in the `reason` property as intended.
2273 + badThenable.status = 'rejected';
2274 + badThenable.error = new Error('Something went wrong');
2275 +
2276 + function Child() {
2277 + return <div>{badThenable}</div>;
2278 + }
2279 +
2280 + function App({
2281 + // Intentionally destrucutring a prop here so that our production error
2282 + // stack trick is triggered at the beginning of the function
2283 + prop,
2284 + }) {
2285 + return <Child />;
2286 + }
2287 +
2288 + const uncaughtErrors = [];
2289 + const root = ReactNoop.createRoot({
2290 + onUncaughtError(error, errorInfo) {
2291 + uncaughtErrors.push({
2292 + callStack: normalizeCodeLocInfo(error.stack),
2293 + ownerStack: React.captureOwnerStack
2294 + ? normalizeCodeLocInfo(React.captureOwnerStack())
2295 + : null,
2296 + componentStack: normalizeCodeLocInfo(errorInfo.componentStack),
2297 + });
2298 + },
2299 + });
2300 +
2301 + await act(() => {
2302 + root.render(<App />);
2303 + });
2304 +
2305 + expect(uncaughtErrors).toEqual([
2306 + {
2307 + callStack:
2308 + 'Error: A rejected Promise was passed to React without a `reason` property. ' +
2309 + 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' +
2310 + "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`." +
2311 + '\n in Object.<anonymous> (~/ReactUse-test.js:*:*)',
2312 + componentStack: '\n in App (~/ReactUse-test.js:*:*)',
2313 + ownerStack: __DEV__
2314 + ? // TODO: Should start in Child since that's the owner.
2315 + '\n in App (~/ReactUse-test.js:*:*)'
2316 + : null,
2317 + },
2318 + ]);
2319 + });
2320 });
packages/react-server/src/ReactFizzThenable.js
+18
@@ -69,11 +69,29 @@ export function trackUsedThenable<T>(
69 // a listener that will update its status and result when it resolves.
70 switch (thenable.status) {
71 case 'fulfilled': {
72 + // This could be a bad instrumentation that doesn't set .value.
73 + // We're not type-checking since this is a hot path where you can
74 + // track down easily when something becomes `undefined` unexpectedly.
75 const fulfilledValue: T = thenable.value;
76 return fulfilledValue;
77 }
78 case 'rejected': {
79 const rejectedError = thenable.reason;
80 +
81 + // Rejected Promises are rarer so we're doing an extra type-check in
82 + // case of a bad instrumentation that doesn't set .reason
83 + // If we end up throwing `undefined` it becomes hard to track down
84 + // where that throw originated because no callstack would exist.
85 + // React would still have a Component stack but that could only be used
86 + // as an approximation.
87 + if (rejectedError === undefined && !('reason' in thenable)) {
88 + throw new Error(
89 + 'A rejected Promise was passed to React without a `reason` property. ' +
90 + 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' +
91 + "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.",
92 + );
93 + }
94 +
95 throw rejectedError;
96 }
97 default: {
packages/react-server/src/ReactFlightThenable.js
+18
@@ -77,11 +77,29 @@ export function trackUsedThenable<T>(
77 // a listener that will update its status and result when it resolves.
78 switch (thenable.status) {
79 case 'fulfilled': {
80 + // This could be a bad instrumentation that doesn't set .value.
81 + // We're not type-checking since this is a hot path where you can
82 + // track down easily when something becomes `undefined` unexpectedly.
83 const fulfilledValue: T = thenable.value;
84 return fulfilledValue;
85 }
86 case 'rejected': {
87 const rejectedError = thenable.reason;
88 +
89 + // Rejected Promises are rarer so we're doing an extra type-check in
90 + // case of a bad instrumentation that doesn't set .reason
91 + // If we end up throwing `undefined` it becomes hard to track down
92 + // where that throw originated because no callstack would exist.
93 + // React would still have a Component stack but that could only be used
94 + // as an approximation.
95 + if (rejectedError === undefined && !('reason' in thenable)) {
96 + throw new Error(
97 + 'A rejected Promise was passed to React without a `reason` property. ' +
98 + 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' +
99 + "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.",
100 + );
101 + }
102 +
103 throw rejectedError;
104 }
105 default: {
scripts/error-codes/codes.json
+3 -2
@@ -584,5 +584,6 @@
584 "596": "Could not find the module \"%s\" in the React Client Manifest. This is probably a bug in the React Server Components bundler.",
585 "597": "The module \"%s\" is marked as an async ESM module but was loaded as a CJS proxy. This is probably a bug in the React Server Components bundler.",
586 "598": "Maximum update depth exceeded. This could be an infinite loop. This can happen when a component repeatedly calls setState during render phase or inside useLayoutEffect, causing infinite render loop. React limits the number of nested updates to prevent infinite loops.",
587 - "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React."
588 -}
\ No newline at end of file
587 + "599": "Expected an initialized chunk but got an initialized stream chunk instead. This payload may have been submitted by an older version of React.",
588 + "600": "A rejected Promise was passed to React without a `reason` property. React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`."
589 +}