@samitouri / QOS-React-1 / commits / 9598c41a20

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>) {