Skip to content

Commit 28fc3f0

Browse files
committed
Fix: Use action implementation at time of dispatch
Fixes the behavior of actions that are queued by useActionState to use the action function that was current at the time it was dispatched, not at the time it eventually executes. The conceptual model is that the action is immediately dispatched, as if it were sent to a remote server/worker. It's the remote worker that maintains the queue, not the client. This is another property of actions makes them more like event handlers than like reducers.
1 parent 681a4aa commit 28fc3f0

File tree

2 files changed

+67
-12
lines changed

2 files changed

+67
-12
lines changed

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1097,7 +1097,7 @@ describe('ReactDOMForm', () => {
10971097
});
10981098

10991099
// @gate enableAsyncActions
1100-
test('queues multiple actions and runs them in order', async () => {
1100+
test('useActionState: queues multiple actions and runs them in order', async () => {
11011101
let action;
11021102
function App() {
11031103
const [state, dispatch, isPending] = useActionState(
@@ -1128,6 +1128,51 @@ describe('ReactDOMForm', () => {
11281128
expect(container.textContent).toBe('D');
11291129
});
11301130

1131+
// @gate enableAsyncActions
1132+
test(
1133+
'useActionState: when calling a queued action, uses the implementation ' +
1134+
'that was current at the time it was dispatched, not the most recent one',
1135+
async () => {
1136+
let action;
1137+
function App({throwIfActionIsDispatched}) {
1138+
const [state, dispatch, isPending] = useActionState(async (s, a) => {
1139+
if (throwIfActionIsDispatched) {
1140+
throw new Error('Oops!');
1141+
}
1142+
return await getText(a);
1143+
}, 'Initial');
1144+
action = dispatch;
1145+
return <Text text={state + (isPending ? ' (pending)' : '')} />;
1146+
}
1147+
1148+
const root = ReactDOMClient.createRoot(container);
1149+
await act(() => root.render(<App throwIfActionIsDispatched={false} />));
1150+
assertLog(['Initial']);
1151+
1152+
// Dispatch two actions. The first one is async, so it forces the second
1153+
// one into an async queue.
1154+
await act(() => action('First action'));
1155+
assertLog(['Initial (pending)']);
1156+
// This action won't run until the first one finishes.
1157+
await act(() => action('Second action'));
1158+
1159+
// While the first action is still pending, update a prop. This causes the
1160+
// inline action implementation to change, but it should not affect the
1161+
// behavior of the action that is already queued.
1162+
await act(() => root.render(<App throwIfActionIsDispatched={true} />));
1163+
assertLog(['Initial (pending)']);
1164+
1165+
// Finish both of the actions.
1166+
await act(() => resolveText('First action'));
1167+
await act(() => resolveText('Second action'));
1168+
assertLog(['Second action']);
1169+
1170+
// Confirm that if we dispatch yet another action, it uses the updated
1171+
// action implementation.
1172+
await expect(act(() => action('Third action'))).rejects.toThrow('Oops!');
1173+
},
1174+
);
1175+
11311176
// @gate enableAsyncActions
11321177
test('useActionState: works if action is sync', async () => {
11331178
let increment;

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1966,13 +1966,15 @@ type ActionStateQueue<S, P> = {
19661966
action: (Awaited<S>, P) => S,
19671967
// This is a circular linked list of pending action payloads. It incudes the
19681968
// action that is currently running.
1969-
pending: ActionStateQueueNode<P> | null,
1969+
pending: ActionStateQueueNode<S, P> | null,
19701970
};
19711971

1972-
type ActionStateQueueNode<P> = {
1972+
type ActionStateQueueNode<S, P> = {
19731973
payload: P,
1974+
// This is the action implementation at the time it was dispatched.
1975+
action: (Awaited<S>, P) => S,
19741976
// This is never null because it's part of a circular linked list.
1975-
next: ActionStateQueueNode<P>,
1977+
next: ActionStateQueueNode<S, P>,
19761978
};
19771979

19781980
function dispatchActionState<S, P>(
@@ -1989,8 +1991,9 @@ function dispatchActionState<S, P>(
19891991
if (last === null) {
19901992
// There are no pending actions; this is the first one. We can run
19911993
// it immediately.
1992-
const newLast: ActionStateQueueNode<P> = {
1994+
const newLast: ActionStateQueueNode<S, P> = {
19931995
payload,
1996+
action: actionQueue.action,
19941997
next: (null: any), // circular
19951998
};
19961999
newLast.next = actionQueue.pending = newLast;
@@ -1999,13 +2002,14 @@ function dispatchActionState<S, P>(
19992002
actionQueue,
20002003
(setPendingState: any),
20012004
(setState: any),
2002-
payload,
2005+
newLast,
20032006
);
20042007
} else {
20052008
// There's already an action running. Add to the queue.
20062009
const first = last.next;
2007-
const newLast: ActionStateQueueNode<P> = {
2010+
const newLast: ActionStateQueueNode<S, P> = {
20082011
payload,
2012+
action: actionQueue.action,
20092013
next: first,
20102014
};
20112015
actionQueue.pending = last.next = newLast;
@@ -2016,11 +2020,8 @@ function runActionStateAction<S, P>(
20162020
actionQueue: ActionStateQueue<S, P>,
20172021
setPendingState: boolean => void,
20182022
setState: Dispatch<S | Awaited<S>>,
2019-
payload: P,
2023+
node: ActionStateQueueNode<S, P>,
20202024
) {
2021-
const action = actionQueue.action;
2022-
const prevState = actionQueue.state;
2023-
20242025
// This is a fork of startTransition
20252026
const prevTransition = ReactSharedInternals.T;
20262027
const currentTransition: BatchConfigTransition = {};
@@ -2033,6 +2034,15 @@ function runActionStateAction<S, P>(
20332034
// This will be reverted automatically when all actions are finished.
20342035
setPendingState(true);
20352036

2037+
// `node.action` represents the action function at the time it was dispatched.
2038+
// If this action was queued, it might be stale, i.e. it's not necessarily the
2039+
// most current implementation of the action, stored on `actionQueue`. This is
2040+
// intentional. The conceptual model for queued actions is that they are
2041+
// queued in a remote worker; the dispatch happens immediately, only the
2042+
// execution is delayed.
2043+
const action = node.action;
2044+
const payload = node.payload;
2045+
const prevState = actionQueue.state;
20362046
try {
20372047
const returnValue = action(prevState, payload);
20382048
const onStartTransitionFinish = ReactSharedInternals.S;
@@ -2136,7 +2146,7 @@ function finishRunningActionStateAction<S, P>(
21362146
actionQueue,
21372147
(setPendingState: any),
21382148
(setState: any),
2139-
next.payload,
2149+
next,
21402150
);
21412151
}
21422152
}

0 commit comments

Comments
 (0)