useActionState: On error, cancel remaining actions (#29695)
Based on - #29694 --- If an action in the useActionState queue errors, we shouldn't run any subsequent actions. The contract of useActionState is that the actions run in sequence, and that one action can assume that all previous actions have completed successfully. For example, in a shopping cart UI, you might dispatch an "Add to cart" action followed by a "Checkout" action. If the "Add to cart" action errors, the "Checkout" action should not run. An implication of this change is that once useActionState falls into an error state, the only way to recover is to reset the component tree, i.e. by unmounting and remounting. The way to customize the error handling behavior is to wrap the action body in a try/catch.
Andrew Clark committed
Jun 3, 2024 at 11:25 UTC
9598c41a20162c8a9d57ccf6a356aa183b00b61a
2 files changed
+77
-49
packages/react-dom/src/__tests__/ReactDOMForm-test.js
+56
-29
@@ -1237,14 +1237,12 @@ describe('ReactDOMForm', () => {
1237
1238
// @gate enableAsyncActions
1239
test('useActionState: error handling (sync action)', async () => {
1240
- let resetErrorBoundary;
1240
class ErrorBoundary extends React.Component {
1241
state = {error: null};
1242
static getDerivedStateFromError(error) {
1243
return {error};
1244
}
1245
render() {
1247
- resetErrorBoundary = () => this.setState({error: null});
1246
if (this.state.error !== null) {
1247
return <Text text={'Caught an error: ' + this.state.error.message} />;
1248
}
@@ -1284,31 +1282,16 @@ describe('ReactDOMForm', () => {
1282
'Caught an error: Oops!',
1283
]);
1284
expect(container.textContent).toBe('Caught an error: Oops!');
1287
-
1288
- // Reset the error boundary
1289
- await act(() => resetErrorBoundary());
1290
- assertLog(['A']);
1291
-
1292
- // Trigger an error again, but this time, perform another action that
1293
- // overrides the first one and fixes the error
1294
- await act(() => {
1295
- startTransition(() => action('Oops!'));
1296
- startTransition(() => action('B'));
1297
- });
1298
- assertLog(['Pending A', 'B']);
1299
- expect(container.textContent).toBe('B');
1285
});
1286
1287
// @gate enableAsyncActions
1288
test('useActionState: error handling (async action)', async () => {
1304
- let resetErrorBoundary;
1289
class ErrorBoundary extends React.Component {
1290
state = {error: null};
1291
static getDerivedStateFromError(error) {
1292
return {error};
1293
}
1294
render() {
1311
- resetErrorBoundary = () => this.setState({error: null});
1295
if (this.state.error !== null) {
1296
return <Text text={'Caught an error: ' + this.state.error.message} />;
1297
}
@@ -1346,21 +1329,65 @@ describe('ReactDOMForm', () => {
1329
await act(() => resolveText('Oops!'));
1330
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
1331
expect(container.textContent).toBe('Caught an error: Oops!');
1332
+ });
1333
+
1334
+ test('useActionState: when an action errors, subsequent actions are canceled', async () => {
1335
+ class ErrorBoundary extends React.Component {
1336
+ state = {error: null};
1337
+ static getDerivedStateFromError(error) {
1338
+ return {error};
1339
+ }
1340
+ render() {
1341
+ if (this.state.error !== null) {
1342
+ return <Text text={'Caught an error: ' + this.state.error.message} />;
1343
+ }
1344
+ return this.props.children;
1345
+ }
1346
+ }
1347
+
1348
+ let action;
1349
+ function App() {
1350
+ const [state, dispatch, isPending] = useActionState(async (s, a) => {
1351
+ Scheduler.log('Start action: ' + a);
1352
+ const text = await getText(a);
1353
+ if (text.endsWith('!')) {
1354
+ throw new Error(text);
1355
+ }
1356
+ return text;
1357
+ }, 'A');
1358
+ action = dispatch;
1359
+ const pending = isPending ? 'Pending ' : '';
1360
+ return <Text text={pending + state} />;
1361
+ }
1362
1350
- // Reset the error boundary
1351
- await act(() => resetErrorBoundary());
1363
+ const root = ReactDOMClient.createRoot(container);
1364
+ await act(() =>
1365
+ root.render(
1366
+ <ErrorBoundary>
1367
+ <App />
1368
+ </ErrorBoundary>,
1369
+ ),
1370
+ );
1371
assertLog(['A']);
1372
1354
- // Trigger an error again, but this time, perform another action that
1355
- // overrides the first one and fixes the error
1356
- await act(() => {
1357
- startTransition(() => action('Oops!'));
1358
- startTransition(() => action('B'));
1359
- });
1360
- assertLog(['Pending A']);
1361
- await act(() => resolveText('B'));
1362
- assertLog(['B']);
1363
- expect(container.textContent).toBe('B');
1373
+ await act(() => startTransition(() => action('Oops!')));
1374
+ assertLog(['Start action: Oops!', 'Pending A']);
1375
+
1376
+ // Queue up another action after the one will error.
1377
+ await act(() => startTransition(() => action('Should never run')));
1378
+ assertLog([]);
1379
+
1380
+ // The first dispatch will update the pending state.
1381
+ await act(() => resolveText('Oops!'));
1382
+ assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
1383
+ expect(container.textContent).toBe('Caught an error: Oops!');
1384
+
1385
+ // Attempt to dispatch another action. This should not run either.
1386
+ await act(() =>
1387
+ startTransition(() => action('This also should never run')),
1388
+ );
1389
+ assertLog([]);
1390
+ expect(container.textContent).toBe('Caught an error: Oops!');
1391
});
1392
1393
// @gate enableAsyncActions
packages/react-reconciler/src/ReactFiberHooks.js
+21
-20
@@ -1994,7 +1994,9 @@ type ActionStateQueue<S, P> = {
1994
dispatch: Dispatch<P>,
1995
// This is the most recent action function that was rendered. It's updated
1996
// during the commit phase.
1997
- action: (Awaited<S>, P) => S,
1997
+ // If it's null, it means the action queue errored and subsequent actions
1998
+ // should not run.
1999
+ action: ((Awaited<S>, P) => S) | null,
2000
// This is a circular linked list of pending action payloads. It incudes the
2001
// action that is currently running.
2002
pending: ActionStateQueueNode<S, P> | null,
@@ -2033,9 +2035,15 @@ function dispatchActionState<S, P>(
2035
throw new Error('Cannot update form state while rendering.');
2036
}
2037
2038
+ const currentAction = actionQueue.action;
2039
+ if (currentAction === null) {
2040
+ // An earlier action errored. Subsequent actions should not run.
2041
+ return;
2042
+ }
2043
+
2044
const actionNode: ActionStateQueueNode<S, P> = {
2045
payload,
2038
- action: actionQueue.action,
2046
+ action: currentAction,
2047
next: (null: any), // circular
2048
2049
isTransition: true,
@@ -2218,28 +2226,21 @@ function onActionError<S, P>(
2226
actionNode: ActionStateQueueNode<S, P>,
2227
error: mixed,
2228
) {
2221
- actionNode.status = 'rejected';
2222
- actionNode.reason = error;
2223
- notifyActionListeners(actionNode);
2224
-
2225
- // Pop the action from the queue and run the next pending action, if there
2226
- // are any.
2227
- // TODO: We should instead abort all the remaining actions in the queue.
2229
+ // Mark all the following actions as rejected.
2230
const last = actionQueue.pending;
2231
+ actionQueue.pending = null;
2232
if (last !== null) {
2233
const first = last.next;
2231
- if (first === last) {
2232
- // This was the last action in the queue.
2233
- actionQueue.pending = null;
2234
- } else {
2235
- // Remove the first node from the circular queue.
2236
- const next = first.next;
2237
- last.next = next;
2238
-
2239
- // Run the next action.
2240
- runActionStateAction(actionQueue, next);
2241
- }
2234
+ do {
2235
+ actionNode.status = 'rejected';
2236
+ actionNode.reason = error;
2237
+ notifyActionListeners(actionNode);
2238
+ actionNode = actionNode.next;
2239
+ } while (actionNode !== first);
2240
}
2241
+
2242
+ // Prevent subsequent actions from being dispatched.
2243
+ actionQueue.action = null;
2244
}
2245
2246
function notifyActionListeners<S, P>(actionNode: ActionStateQueueNode<S, P>) {