Skip to content

Commit 63f6dec

Browse files
committed
React.startTransition error handling
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.
1 parent 6c64428 commit 63f6dec

File tree

4 files changed

+97
-31
lines changed

4 files changed

+97
-31
lines changed

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1983,15 +1983,14 @@ function runFormStateAction<S, P>(
19831983
}
19841984
try {
19851985
const returnValue = action(prevState, payload);
1986-
notifyTransitionCallbacks(currentTransition, returnValue);
1987-
19881986
if (
19891987
returnValue !== null &&
19901988
typeof returnValue === 'object' &&
19911989
// $FlowFixMe[method-unbinding]
19921990
typeof returnValue.then === 'function'
19931991
) {
19941992
const thenable = ((returnValue: any): Thenable<Awaited<S>>);
1993+
notifyTransitionCallbacks(currentTransition, thenable);
19951994

19961995
// Attach a listener to read the return state of the action. As soon as
19971996
// this resolves, we can run the next action in the sequence.
@@ -2854,7 +2853,6 @@ function startTransition<S>(
28542853
try {
28552854
if (enableAsyncActions) {
28562855
const returnValue = callback();
2857-
notifyTransitionCallbacks(currentTransition, returnValue);
28582856

28592857
// Check if we're inside an async action scope. If so, we'll entangle
28602858
// this new action with the existing scope.
@@ -2870,6 +2868,7 @@ function startTransition<S>(
28702868
typeof returnValue.then === 'function'
28712869
) {
28722870
const thenable = ((returnValue: any): Thenable<mixed>);
2871+
notifyTransitionCallbacks(currentTransition, thenable);
28732872
// Create a thenable that resolves to `finishedState` once the async
28742873
// action has completed.
28752874
const thenableForFinishedState = chainThenableValue(

packages/react-reconciler/src/ReactFiberTransition.js

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,23 +45,17 @@ export function requestCurrentTransition(): BatchConfigTransition | null {
4545
if (transition !== null) {
4646
// Whenever a transition update is scheduled, register a callback on the
4747
// transition object so we can get the return value of the scope function.
48-
transition._callbacks.add(handleTransitionScopeResult);
48+
transition._callbacks.add(handleAsyncAction);
4949
}
5050
return transition;
5151
}
5252

53-
function handleTransitionScopeResult(
53+
function handleAsyncAction(
5454
transition: BatchConfigTransition,
55-
returnValue: mixed,
55+
thenable: Thenable<mixed>,
5656
): void {
57-
if (
58-
enableAsyncActions &&
59-
returnValue !== null &&
60-
typeof returnValue === 'object' &&
61-
typeof returnValue.then === 'function'
62-
) {
57+
if (enableAsyncActions) {
6358
// This is an async action.
64-
const thenable: Thenable<mixed> = (returnValue: any);
6559
entangleAsyncAction(transition, thenable);
6660
}
6761
}

packages/react-reconciler/src/__tests__/ReactAsyncActions-test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ describe('ReactAsyncActions', () => {
1212
beforeEach(() => {
1313
jest.resetModules();
1414

15+
global.reportError = error => {
16+
Scheduler.log('reportError: ' + error.message);
17+
};
18+
1519
React = require('react');
1620
ReactNoop = require('react-noop-renderer');
1721
Scheduler = require('scheduler');
@@ -1726,4 +1730,29 @@ describe('ReactAsyncActions', () => {
17261730
assertLog(['Async action ended', 'Updated']);
17271731
expect(root).toMatchRenderedOutput(<span>Updated</span>);
17281732
});
1733+
1734+
test('React.startTransition captures async errors and passes them to reportError', async () => {
1735+
if (gate(flags => flags.enableAsyncActions)) {
1736+
await act(async () => {
1737+
await React.startTransition(async () => {
1738+
throw new Error('Oops');
1739+
});
1740+
});
1741+
assertLog(['reportError: Oops']);
1742+
}
1743+
});
1744+
1745+
// @gate enableAsyncActions
1746+
test('React.startTransition captures sync errors and passes them to reportError', async () => {
1747+
await act(() => {
1748+
try {
1749+
React.startTransition(() => {
1750+
throw new Error('Oops');
1751+
});
1752+
} catch (e) {
1753+
throw new Error('Should not be reachable.');
1754+
}
1755+
});
1756+
assertLog(['reportError: Oops']);
1757+
});
17291758
});

packages/react/src/ReactStartTransition.js

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import type {BatchConfigTransition} from 'react-reconciler/src/ReactFiberTracing
1010
import type {StartTransitionOptions} from 'shared/ReactTypes';
1111

1212
import ReactCurrentBatchConfig from './ReactCurrentBatchConfig';
13-
import {enableTransitionTracing} from 'shared/ReactFeatureFlags';
13+
import {
14+
enableAsyncActions,
15+
enableTransitionTracing,
16+
} from 'shared/ReactFeatureFlags';
1417

1518
export function startTransition(
1619
scope: () => void,
@@ -39,24 +42,65 @@ export function startTransition(
3942
}
4043
}
4144

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+
);
5988
}
6089
}
6190
}
6291
}
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+
};

0 commit comments

Comments
 (0)