@samitouri / QOS-React-1 / commits / 60f190a559

Capture React.startTransition errors and pass to reportError (#28111)

To make React.startTransition more consistent with the hook form of startTransition, we capture errors thrown by the scope function and pass them to the global reportError function. (This is also what we do as a default for onRecoverableError.) This is a breaking change because it means that errors inside of startTransition will no longer bubble up to the caller. You can still catch the error by putting a try/catch block inside of the scope function itself. We do the same for async actions to prevent "unhandled promise rejection" warnings. The motivation is to avoid a refactor hazard when changing from a sync to an async action, or from useTransition to startTransition.

Andrew Clark committed Jan 26, 2024 at 12:10 UTC 60f190a55948a7512d4e2a336f03b45fd38d6a80
4 files changed +99 -31
packages/react-reconciler/src/ReactFiberHooks.js
+2 -3
@@ -1983,8 +1983,6 @@ function runFormStateAction<S, P>(
1983 }
1984 try {
1985 const returnValue = action(prevState, payload);
1986 - notifyTransitionCallbacks(currentTransition, returnValue);
1987 -
1986 if (
1987 returnValue !== null &&
1988 typeof returnValue === 'object' &&
@@ -1992,6 +1990,7 @@ function runFormStateAction<S, P>(
1990 typeof returnValue.then === 'function'
1991 ) {
1992 const thenable = ((returnValue: any): Thenable<Awaited<S>>);
1993 + notifyTransitionCallbacks(currentTransition, thenable);
1994
1995 // Attach a listener to read the return state of the action. As soon as
1996 // this resolves, we can run the next action in the sequence.
@@ -2854,7 +2853,6 @@ function startTransition<S>(
2853 try {
2854 if (enableAsyncActions) {
2855 const returnValue = callback();
2857 - notifyTransitionCallbacks(currentTransition, returnValue);
2856
2857 // Check if we're inside an async action scope. If so, we'll entangle
2858 // this new action with the existing scope.
@@ -2870,6 +2868,7 @@ function startTransition<S>(
2868 typeof returnValue.then === 'function'
2869 ) {
2870 const thenable = ((returnValue: any): Thenable<mixed>);
2871 + notifyTransitionCallbacks(currentTransition, thenable);
2872 // Create a thenable that resolves to `finishedState` once the async
2873 // action has completed.
2874 const thenableForFinishedState = chainThenableValue(
packages/react-reconciler/src/ReactFiberTransition.js
+4 -10
@@ -45,23 +45,17 @@ export function requestCurrentTransition(): BatchConfigTransition | null {
45 if (transition !== null) {
46 // Whenever a transition update is scheduled, register a callback on the
47 // transition object so we can get the return value of the scope function.
48 - transition._callbacks.add(handleTransitionScopeResult);
48 + transition._callbacks.add(handleAsyncAction);
49 }
50 return transition;
51 }
52
53 -function handleTransitionScopeResult(
53 +function handleAsyncAction(
54 transition: BatchConfigTransition,
55 - returnValue: mixed,
55 + thenable: Thenable<mixed>,
56 ): void {
57 - if (
58 - enableAsyncActions &&
59 - returnValue !== null &&
60 - typeof returnValue === 'object' &&
61 - typeof returnValue.then === 'function'
62 - ) {
57 + if (enableAsyncActions) {
58 // This is an async action.
64 - const thenable: Thenable<mixed> = (returnValue: any);
59 entangleAsyncAction(transition, thenable);
60 }
61 }
packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js
+31
@@ -12,6 +12,10 @@ describe('ReactAsyncActions', () => {
12 beforeEach(() => {
13 jest.resetModules();
14
15 + global.reportError = error => {
16 + Scheduler.log('reportError: ' + error.message);
17 + };
18 +
19 React = require('react');
20 ReactNoop = require('react-noop-renderer');
21 Scheduler = require('scheduler');
@@ -1726,4 +1730,31 @@ describe('ReactAsyncActions', () => {
1730 assertLog(['Async action ended', 'Updated']);
1731 expect(root).toMatchRenderedOutput(<span>Updated</span>);
1732 });
1733 +
1734 + test('React.startTransition captures async errors and passes them to reportError', async () => {
1735 + // NOTE: This is gated here instead of using the pragma because the failure
1736 + // happens asynchronously and the `gate` runtime doesn't capture it.
1737 + if (gate(flags => flags.enableAsyncActions)) {
1738 + await act(() => {
1739 + React.startTransition(async () => {
1740 + throw new Error('Oops');
1741 + });
1742 + });
1743 + assertLog(['reportError: Oops']);
1744 + }
1745 + });
1746 +
1747 + // @gate enableAsyncActions
1748 + test('React.startTransition captures sync errors and passes them to reportError', async () => {
1749 + await act(() => {
1750 + try {
1751 + React.startTransition(() => {
1752 + throw new Error('Oops');
1753 + });
1754 + } catch (e) {
1755 + throw new Error('Should not be reachable.');
1756 + }
1757 + });
1758 + assertLog(['reportError: Oops']);
1759 + });
1760 });
packages/react/src/ReactStartTransition.js
+62 -18
@@ -10,7 +10,10 @@ import type {BatchConfigTransition} from 'react-reconciler/src/ReactFiberTracing
10 import type {StartTransitionOptions} from 'shared/ReactTypes';
11
12 import ReactCurrentBatchConfig from './ReactCurrentBatchConfig';
13 -import {enableTransitionTracing} from 'shared/ReactFeatureFlags';
13 +import {
14 + enableAsyncActions,
15 + enableTransitionTracing,
16 +} from 'shared/ReactFeatureFlags';
17
18 export function startTransition(
19 scope: () => void,
@@ -39,24 +42,65 @@ export function startTransition(
42 }
43 }
44
42 - try {
43 - const returnValue = scope();
44 - callbacks.forEach(callback => callback(currentTransition, returnValue));
45 - } finally {
46 - ReactCurrentBatchConfig.transition = prevTransition;
47 -
48 - if (__DEV__) {
49 - if (prevTransition === null && currentTransition._updatedFibers) {
50 - const updatedFibersCount = currentTransition._updatedFibers.size;
51 - currentTransition._updatedFibers.clear();
52 - if (updatedFibersCount > 10) {
53 - console.warn(
54 - 'Detected a large number of updates inside startTransition. ' +
55 - 'If this is due to a subscription please re-write it to use React provided hooks. ' +
56 - 'Otherwise concurrent mode guarantees are off the table.',
57 - );
58 - }
45 + if (enableAsyncActions) {
46 + try {
47 + const returnValue = scope();
48 + if (
49 + typeof returnValue === 'object' &&
50 + returnValue !== null &&
51 + typeof returnValue.then === 'function'
52 + ) {
53 + callbacks.forEach(callback => callback(currentTransition, returnValue));
54 + returnValue.then(noop, onError);
55 + }
56 + } catch (error) {
57 + onError(error);
58 + } finally {
59 + warnAboutTransitionSubscriptions(prevTransition, currentTransition);
60 + ReactCurrentBatchConfig.transition = prevTransition;
61 + }
62 + } else {
63 + // When async actions are not enabled, startTransition does not
64 + // capture errors.
65 + try {
66 + scope();
67 + } finally {
68 + warnAboutTransitionSubscriptions(prevTransition, currentTransition);
69 + ReactCurrentBatchConfig.transition = prevTransition;
70 + }
71 + }
72 +}
73 +
74 +function warnAboutTransitionSubscriptions(
75 + prevTransition: BatchConfigTransition | null,
76 + currentTransition: BatchConfigTransition,
77 +) {
78 + if (__DEV__) {
79 + if (prevTransition === null && currentTransition._updatedFibers) {
80 + const updatedFibersCount = currentTransition._updatedFibers.size;
81 + currentTransition._updatedFibers.clear();
82 + if (updatedFibersCount > 10) {
83 + console.warn(
84 + 'Detected a large number of updates inside startTransition. ' +
85 + 'If this is due to a subscription please re-write it to use React provided hooks. ' +
86 + 'Otherwise concurrent mode guarantees are off the table.',
87 + );
88 }
89 }
90 }
91 }
92 +
93 +function noop() {}
94 +
95 +// Use reportError, if it exists. Otherwise console.error. This is the same as
96 +// the default for onRecoverableError.
97 +const onError =
98 + typeof reportError === 'function'
99 + ? // In modern browsers, reportError will dispatch an error event,
100 + // emulating an uncaught JavaScript error.
101 + reportError
102 + : (error: mixed) => {
103 + // In older browsers and test environments, fallback to console.error.
104 + // eslint-disable-next-line react-internal/no-production-logging
105 + console['error'](error);
106 + };