Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 9f8376c

Browse files
committed
useActionState: On error, cancel remaining actions
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.
1 parent 67b05be commit 9f8376c

File tree

2 files changed

+77
-49
lines changed

2 files changed

+77
-49
lines changed

packages/react-dom/src/__tests__/ReactDOMForm-test.js

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,14 +1237,12 @@ describe('ReactDOMForm', () => {
12371237

12381238
// @gate enableAsyncActions
12391239
test('useActionState: error handling (sync action)', async () => {
1240-
let resetErrorBoundary;
12411240
class ErrorBoundary extends React.Component {
12421241
state = {error: null};
12431242
static getDerivedStateFromError(error) {
12441243
return {error};
12451244
}
12461245
render() {
1247-
resetErrorBoundary = () => this.setState({error: null});
12481246
if (this.state.error !== null) {
12491247
return <Text text={'Caught an error: ' + this.state.error.message} />;
12501248
}
@@ -1284,31 +1282,16 @@ describe('ReactDOMForm', () => {
12841282
'Caught an error: Oops!',
12851283
]);
12861284
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');
13001285
});
13011286

13021287
// @gate enableAsyncActions
13031288
test('useActionState: error handling (async action)', async () => {
1304-
let resetErrorBoundary;
13051289
class ErrorBoundary extends React.Component {
13061290
state = {error: null};
13071291
static getDerivedStateFromError(error) {
13081292
return {error};
13091293
}
13101294
render() {
1311-
resetErrorBoundary = () => this.setState({error: null});
13121295
if (this.state.error !== null) {
13131296
return <Text text={'Caught an error: ' + this.state.error.message} />;
13141297
}
@@ -1346,21 +1329,65 @@ describe('ReactDOMForm', () => {
13461329
await act(() => resolveText('Oops!'));
13471330
assertLog(['Caught an error: Oops!', 'Caught an error: Oops!']);
13481331
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+
}
13491362

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+
);
13521371
assertLog(['A']);
13531372

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!');
13641391
});
13651392

13661393
// @gate enableAsyncActions

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1994,7 +1994,9 @@ type ActionStateQueue<S, P> = {
19941994
dispatch: Dispatch<P>,
19951995
// This is the most recent action function that was rendered. It's updated
19961996
// 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,
19982000
// This is a circular linked list of pending action payloads. It incudes the
19992001
// action that is currently running.
20002002
pending: ActionStateQueueNode<S, P> | null,
@@ -2033,9 +2035,15 @@ function dispatchActionState<S, P>(
20332035
throw new Error('Cannot update form state while rendering.');
20342036
}
20352037

2038+
const currentAction = actionQueue.action;
2039+
if (currentAction === null) {
2040+
// An earlier action errored. Subsequent actions should not run.
2041+
return;
2042+
}
2043+
20362044
const actionNode: ActionStateQueueNode<S, P> = {
20372045
payload,
2038-
action: actionQueue.action,
2046+
action: currentAction,
20392047
next: (null: any), // circular
20402048

20412049
isTransition: true,
@@ -2218,28 +2226,21 @@ function onActionError<S, P>(
22182226
actionNode: ActionStateQueueNode<S, P>,
22192227
error: mixed,
22202228
) {
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.
22282230
const last = actionQueue.pending;
2231+
actionQueue.pending = null;
22292232
if (last !== null) {
22302233
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);
22422240
}
2241+
2242+
// Prevent subsequent actions from being dispatched.
2243+
actionQueue.action = null;
22432244
}
22442245

22452246
function notifyActionListeners<S, P>(actionNode: ActionStateQueueNode<S, P>) {

0 commit comments

Comments
 (0)