Skip to content

Commit 67b05be

Browse files
authored
useActionState: Transfer transition context (#29694)
Mini-refactor of useActionState to only wrap the action in a transition context if the dispatch is called during a transition. Conceptually, the action starts as soon as the dispatch is called, even if the action is queued until earlier ones finish. We will also warn if an async action is dispatched outside of a transition, since that is almost certainly a mistake. Ideally we would automatically upgrade these to a transition, but we don't have a great way to tell if the action is async until after it's already run.
1 parent def67b9 commit 67b05be

File tree

2 files changed

+282
-139
lines changed

2 files changed

+282
-139
lines changed

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

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,15 +1020,15 @@ describe('ReactDOMForm', () => {
10201020
assertLog(['0']);
10211021
expect(container.textContent).toBe('0');
10221022

1023-
await act(() => dispatch('increment'));
1023+
await act(() => startTransition(() => dispatch('increment')));
10241024
assertLog(['Async action started [1]', 'Pending 0']);
10251025
expect(container.textContent).toBe('Pending 0');
10261026

10271027
// Dispatch a few more actions. None of these will start until the previous
10281028
// one finishes.
1029-
await act(() => dispatch('increment'));
1030-
await act(() => dispatch('decrement'));
1031-
await act(() => dispatch('increment'));
1029+
await act(() => startTransition(() => dispatch('increment')));
1030+
await act(() => startTransition(() => dispatch('decrement')));
1031+
await act(() => startTransition(() => dispatch('increment')));
10321032
assertLog([]);
10331033

10341034
// Each action starts as soon as the previous one finishes.
@@ -1067,7 +1067,7 @@ describe('ReactDOMForm', () => {
10671067

10681068
// Perform an action. This will increase the state by 1, as defined by the
10691069
// stepSize prop.
1070-
await act(() => increment());
1070+
await act(() => startTransition(() => increment()));
10711071
assertLog(['Pending 0', '1']);
10721072

10731073
// Now increase the stepSize prop to 10. Subsequent steps will increase
@@ -1076,7 +1076,7 @@ describe('ReactDOMForm', () => {
10761076
assertLog(['1']);
10771077

10781078
// Increment again. The state should increase by 10.
1079-
await act(() => increment());
1079+
await act(() => startTransition(() => increment()));
10801080
assertLog(['Pending 1', '11']);
10811081
});
10821082

@@ -1113,11 +1113,11 @@ describe('ReactDOMForm', () => {
11131113
await act(() => root.render(<App />));
11141114
assertLog(['A']);
11151115

1116-
await act(() => action('B'));
1116+
await act(() => startTransition(() => action('B')));
11171117
// The first dispatch will update the pending state.
11181118
assertLog(['Pending A']);
1119-
await act(() => action('C'));
1120-
await act(() => action('D'));
1119+
await act(() => startTransition(() => action('C')));
1120+
await act(() => startTransition(() => action('D')));
11211121
assertLog([]);
11221122

11231123
await act(() => resolveText('B'));
@@ -1151,10 +1151,10 @@ describe('ReactDOMForm', () => {
11511151

11521152
// Dispatch two actions. The first one is async, so it forces the second
11531153
// one into an async queue.
1154-
await act(() => action('First action'));
1154+
await act(() => startTransition(() => action('First action')));
11551155
assertLog(['Initial (pending)']);
11561156
// This action won't run until the first one finishes.
1157-
await act(() => action('Second action'));
1157+
await act(() => startTransition(() => action('Second action')));
11581158

11591159
// While the first action is still pending, update a prop. This causes the
11601160
// inline action implementation to change, but it should not affect the
@@ -1169,7 +1169,9 @@ describe('ReactDOMForm', () => {
11691169

11701170
// Confirm that if we dispatch yet another action, it uses the updated
11711171
// action implementation.
1172-
await expect(act(() => action('Third action'))).rejects.toThrow('Oops!');
1172+
await expect(
1173+
act(() => startTransition(() => action('Third action'))),
1174+
).rejects.toThrow('Oops!');
11731175
},
11741176
);
11751177

@@ -1192,7 +1194,7 @@ describe('ReactDOMForm', () => {
11921194

11931195
// Perform an action. This will increase the state by 1, as defined by the
11941196
// stepSize prop.
1195-
await act(() => increment());
1197+
await act(() => startTransition(() => increment()));
11961198
assertLog(['Pending 0', '1']);
11971199

11981200
// Now increase the stepSize prop to 10. Subsequent steps will increase
@@ -1201,7 +1203,7 @@ describe('ReactDOMForm', () => {
12011203
assertLog(['1']);
12021204

12031205
// Increment again. The state should increase by 10.
1204-
await act(() => increment());
1206+
await act(() => startTransition(() => increment()));
12051207
assertLog(['Pending 1', '11']);
12061208
});
12071209

@@ -1219,12 +1221,12 @@ describe('ReactDOMForm', () => {
12191221
await act(() => root.render(<App />));
12201222
assertLog(['A']);
12211223

1222-
await act(() => action(getText('B')));
1224+
await act(() => startTransition(() => action(getText('B'))));
12231225
// The first dispatch will update the pending state.
12241226
assertLog(['Pending A']);
1225-
await act(() => action('C'));
1226-
await act(() => action(getText('D')));
1227-
await act(() => action('E'));
1227+
await act(() => startTransition(() => action('C')));
1228+
await act(() => startTransition(() => action(getText('D'))));
1229+
await act(() => startTransition(() => action('E')));
12281230
assertLog([]);
12291231

12301232
await act(() => resolveText('B'));
@@ -1273,7 +1275,7 @@ describe('ReactDOMForm', () => {
12731275
);
12741276
assertLog(['A']);
12751277

1276-
await act(() => action('Oops!'));
1278+
await act(() => startTransition(() => action('Oops!')));
12771279
assertLog([
12781280
// Action begins, error has not thrown yet.
12791281
'Pending A',
@@ -1290,8 +1292,8 @@ describe('ReactDOMForm', () => {
12901292
// Trigger an error again, but this time, perform another action that
12911293
// overrides the first one and fixes the error
12921294
await act(() => {
1293-
action('Oops!');
1294-
action('B');
1295+
startTransition(() => action('Oops!'));
1296+
startTransition(() => action('B'));
12951297
});
12961298
assertLog(['Pending A', 'B']);
12971299
expect(container.textContent).toBe('B');
@@ -1338,7 +1340,7 @@ describe('ReactDOMForm', () => {
13381340
);
13391341
assertLog(['A']);
13401342

1341-
await act(() => action('Oops!'));
1343+
await act(() => startTransition(() => action('Oops!')));
13421344
// The first dispatch will update the pending state.
13431345
assertLog(['Pending A']);
13441346
await act(() => resolveText('Oops!'));
@@ -1352,8 +1354,8 @@ describe('ReactDOMForm', () => {
13521354
// Trigger an error again, but this time, perform another action that
13531355
// overrides the first one and fixes the error
13541356
await act(() => {
1355-
action('Oops!');
1356-
action('B');
1357+
startTransition(() => action('Oops!'));
1358+
startTransition(() => action('B'));
13571359
});
13581360
assertLog(['Pending A']);
13591361
await act(() => resolveText('B'));
@@ -1399,7 +1401,7 @@ describe('ReactDOMForm', () => {
13991401
assertLog(['0']);
14001402
expect(container.textContent).toBe('0');
14011403

1402-
await act(() => dispatch('increment'));
1404+
await act(() => startTransition(() => dispatch('increment')));
14031405
assertLog(['Async action started [1]', 'Pending 0']);
14041406
expect(container.textContent).toBe('Pending 0');
14051407

@@ -1408,6 +1410,77 @@ describe('ReactDOMForm', () => {
14081410
expect(container.textContent).toBe('1');
14091411
});
14101412

1413+
test('useActionState does not wrap action in a transition unless dispatch is in a transition', async () => {
1414+
let dispatch;
1415+
function App() {
1416+
const [state, _dispatch] = useActionState(() => {
1417+
return state + 1;
1418+
}, 0);
1419+
dispatch = _dispatch;
1420+
return <AsyncText text={'Count: ' + state} />;
1421+
}
1422+
1423+
const root = ReactDOMClient.createRoot(container);
1424+
await act(() =>
1425+
root.render(
1426+
<Suspense fallback={<Text text="Loading..." />}>
1427+
<App />
1428+
</Suspense>,
1429+
),
1430+
);
1431+
assertLog(['Suspend! [Count: 0]', 'Loading...']);
1432+
await act(() => resolveText('Count: 0'));
1433+
assertLog(['Count: 0']);
1434+
1435+
// Dispatch outside of a transition. This will trigger a loading state.
1436+
await act(() => dispatch());
1437+
assertLog(['Suspend! [Count: 1]', 'Loading...']);
1438+
expect(container.textContent).toBe('Loading...');
1439+
1440+
await act(() => resolveText('Count: 1'));
1441+
assertLog(['Count: 1']);
1442+
expect(container.textContent).toBe('Count: 1');
1443+
1444+
// Now dispatch inside of a transition. This one does not trigger a
1445+
// loading state.
1446+
await act(() => startTransition(() => dispatch()));
1447+
assertLog(['Count: 1', 'Suspend! [Count: 2]', 'Loading...']);
1448+
expect(container.textContent).toBe('Count: 1');
1449+
1450+
await act(() => resolveText('Count: 2'));
1451+
assertLog(['Count: 2']);
1452+
expect(container.textContent).toBe('Count: 2');
1453+
});
1454+
1455+
test('useActionState warns if async action is dispatched outside of a transition', async () => {
1456+
let dispatch;
1457+
function App() {
1458+
const [state, _dispatch] = useActionState(async () => {
1459+
return state + 1;
1460+
}, 0);
1461+
dispatch = _dispatch;
1462+
return <AsyncText text={'Count: ' + state} />;
1463+
}
1464+
1465+
const root = ReactDOMClient.createRoot(container);
1466+
await act(() => root.render(<App />));
1467+
assertLog(['Suspend! [Count: 0]']);
1468+
await act(() => resolveText('Count: 0'));
1469+
assertLog(['Count: 0']);
1470+
1471+
// Dispatch outside of a transition.
1472+
await act(() => dispatch());
1473+
assertConsoleErrorDev([
1474+
[
1475+
'An async function was passed to useActionState, but it was ' +
1476+
'dispatched outside of an action context',
1477+
{withoutStack: true},
1478+
],
1479+
]);
1480+
assertLog(['Suspend! [Count: 1]']);
1481+
expect(container.textContent).toBe('Count: 0');
1482+
});
1483+
14111484
test('uncontrolled form inputs are reset after the action completes', async () => {
14121485
const formRef = React.createRef();
14131486
const inputRef = React.createRef();

0 commit comments

Comments
 (0)