Skip to content

Commit 2223856

Browse files
committed
Fix useMutationWithMutationMode in optimistic and undoable mode may pass invalid parameters to the mutation
## Problem After #10857, when passing parameters at declaration time to a mutation hook in `optimistic` or `undoable` mode, those params may be modified by the side effects before the actual mutation is called. For instance, if you pass the `selectedIds` from the `ListContext` to a `useUpdateMany` hook and use the `useUnselectAll` hook to reset the `selectedIds` in the `onSuccess` side effect, then the actual mutation will be called with an empty array of ids. ## Solution Keep the params in an additional ref that is freezed once the `mutate` callback is called.
1 parent edbf37f commit 2223856

File tree

2 files changed

+99
-17
lines changed

2 files changed

+99
-17
lines changed

packages/ra-core/src/dataProvider/useMutationWithMutationMode.spec.tsx

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as React from 'react';
2-
import { render, waitFor } from '@testing-library/react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
33
import expect from 'expect';
4-
import { useMutationWithMutationMode } from './useMutationWithMutationMode';
4+
import {
5+
useMutationWithMutationMode,
6+
UseMutationWithMutationModeOptions,
7+
} from './useMutationWithMutationMode';
58
import { CoreAdminContext } from '../core/CoreAdminContext';
69
import { useDataProvider } from './useDataProvider';
710
import { DataProvider } from '../types';
@@ -12,7 +15,17 @@ describe('useMutationWithMutationMode', () => {
1215
updateUserProfile: ({ data }: { data: any }) => Promise<{ data: any }>;
1316
};
1417

15-
const useUpdateUserProfile = (args?: { data?: any }) => {
18+
const useUpdateUserProfile = (
19+
args?: { data?: any },
20+
options?: Pick<
21+
UseMutationWithMutationModeOptions<
22+
Error,
23+
{ data: any },
24+
{ data?: any }
25+
>,
26+
'mutationMode'
27+
>
28+
) => {
1629
const dataProvider = useDataProvider<MyDataProvider>();
1730
return useMutationWithMutationMode<
1831
Error,
@@ -33,6 +46,7 @@ describe('useMutationWithMutationMode', () => {
3346
getSnapshot: () => {
3447
return [];
3548
},
49+
...options,
3650
});
3751
};
3852

@@ -91,4 +105,51 @@ describe('useMutationWithMutationMode', () => {
91105
});
92106
});
93107
});
108+
109+
it('uses the latest params at execution time in optimistic mode', async () => {
110+
const dataProvider = testDataProvider({
111+
updateUserProfile: jest.fn(({ data }) =>
112+
Promise.resolve({ data: { id: 1, ...data } } as any)
113+
),
114+
}) as MyDataProvider;
115+
const Dummy = () => {
116+
const [data, setData] = React.useState({ value: 'value1' });
117+
const [update] = useUpdateUserProfile(
118+
{
119+
data,
120+
},
121+
{ mutationMode: 'optimistic' }
122+
);
123+
return (
124+
<>
125+
<button onClick={() => setData({ value: 'value2' })}>
126+
Update data
127+
</button>
128+
<button onClick={() => update()}>Update</button>
129+
</>
130+
);
131+
};
132+
133+
render(
134+
<CoreAdminContext dataProvider={dataProvider}>
135+
<Dummy />
136+
</CoreAdminContext>
137+
);
138+
fireEvent.click(screen.getByText('Update'));
139+
// In case of undoable, clicking the _Update data_ button would trigger the mutation
140+
fireEvent.click(screen.getByText('Update data'));
141+
await waitFor(() => {
142+
expect(dataProvider.updateUserProfile).toHaveBeenCalledWith({
143+
data: { value: 'value1' },
144+
});
145+
});
146+
147+
// Ensure the next call uses the latest data
148+
fireEvent.click(screen.getByText('Update'));
149+
await waitFor(() => {
150+
expect(dataProvider.updateUserProfile).toHaveBeenCalledWith({
151+
data: { value: 'value2' },
152+
});
153+
});
154+
});
94155
});

packages/ra-core/src/dataProvider/useMutationWithMutationMode.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export const useMutationWithMutationMode = <
5959
useEffect(() => {
6060
paramsRef.current = params;
6161
}, [params]);
62+
// This ref won't be updated when params change in an effect, only when the mutate callback is called (See L247)
63+
// This ensures that for undoable and optimistic mutations, the params are not changed by side effects (unselectAll for instance)
64+
// _after_ the mutate function has been called, while keeping the ability to change declaration time params _until_ the mutation is called.
65+
const paramsAtExecutionTimeRef = useRef<Partial<TVariables>>(params);
6266

6367
// Ref that stores the snapshot of the state before the mutation to allow reverting it
6468
const snapshot = useRef<Snapshot>([]);
@@ -96,7 +100,6 @@ export const useMutationWithMutationMode = <
96100
{
97101
mutationKey,
98102
mutationFn: async params => {
99-
const callTimeParams = { ...paramsRef.current, ...params };
100103
if (params == null) {
101104
throw new Error(
102105
'useMutationWithMutationMode mutation requires parameters'
@@ -105,7 +108,7 @@ export const useMutationWithMutationMode = <
105108

106109
return (
107110
mutateWithMiddlewares
108-
.current(callTimeParams as TVariables)
111+
.current(params as TVariables)
109112
// Middlewares expect the data property of the dataProvider response
110113
.then(({ data }) => data)
111114
);
@@ -216,7 +219,10 @@ export const useMutationWithMutationMode = <
216219
(params: TVariables) => {
217220
// Store the final parameters which might have been changed by middlewares
218221
paramsRef.current = params;
219-
return mutationFnEvent(params);
222+
return mutationFnEvent({
223+
...params,
224+
...callTimeParams,
225+
});
220226
}
221227
);
222228
} else {
@@ -234,6 +240,9 @@ export const useMutationWithMutationMode = <
234240
// because they may change afterwards, which would break the undoable mode
235241
// as the previousData would be overwritten by the optimistic update
236242
paramsRef.current = params;
243+
// Also store them in a ref that will always be used for optimistic and undoable updates
244+
// as their actual mutation happens after their side effects which may change the params (unselectAll for instance)
245+
paramsAtExecutionTimeRef.current = params;
237246

238247
if (mutationMode) {
239248
mode.current = mutationMode;
@@ -246,7 +255,7 @@ export const useMutationWithMutationMode = <
246255
}
247256

248257
snapshot.current = getSnapshotEvent(
249-
{ ...paramsRef.current, ...callTimeParams },
258+
{ ...paramsAtExecutionTimeRef.current, ...callTimeParams },
250259
{
251260
mutationMode: mode.current,
252261
}
@@ -255,13 +264,13 @@ export const useMutationWithMutationMode = <
255264
if (mode.current === 'pessimistic') {
256265
if (returnPromise) {
257266
return mutation.mutateAsync(
258-
{ ...paramsRef.current, ...callTimeParams },
267+
{ ...paramsAtExecutionTimeRef.current, ...callTimeParams },
259268
// We don't pass onError and onSettled here as we will call them in the useMutation hook side effects
260269
{ onSuccess, ...otherCallTimeOptions }
261270
);
262271
}
263272
return mutation.mutate(
264-
{ ...paramsRef.current, ...callTimeParams },
273+
{ ...paramsAtExecutionTimeRef.current, ...callTimeParams },
265274
// We don't pass onError and onSettled here as we will call them in the useMutation hook side effects
266275
{ onSuccess, ...otherCallTimeOptions }
267276
);
@@ -276,7 +285,7 @@ export const useMutationWithMutationMode = <
276285

277286
// Optimistically update to the new value
278287
const optimisticResult = updateCacheEvent(
279-
{ ...paramsRef.current, ...callTimeParams },
288+
{ ...paramsAtExecutionTimeRef.current, ...callTimeParams },
280289
{
281290
mutationMode: mode.current,
282291
},
@@ -288,7 +297,7 @@ export const useMutationWithMutationMode = <
288297
if (onSuccess) {
289298
onSuccess(
290299
optimisticResult,
291-
callTimeParams,
300+
{ ...paramsAtExecutionTimeRef.current, ...callTimeParams },
292301
{
293302
snapshot: snapshot.current,
294303
},
@@ -304,7 +313,7 @@ export const useMutationWithMutationMode = <
304313
) {
305314
mutationOptions.onSuccess(
306315
optimisticResult,
307-
callTimeParams,
316+
{ ...paramsAtExecutionTimeRef.current, ...callTimeParams },
308317
{
309318
snapshot: snapshot.current,
310319
},
@@ -319,24 +328,36 @@ export const useMutationWithMutationMode = <
319328

320329
if (mode.current === 'optimistic') {
321330
// call the mutate method without success side effects
322-
return mutation.mutate(callTimeParams);
331+
return mutation.mutate({
332+
...paramsAtExecutionTimeRef.current,
333+
...callTimeParams,
334+
});
323335
} else {
324336
// Undoable mutation: add the mutation to the undoable queue.
325337
// The Notification component will dequeue it when the user confirms or cancels the message.
326338
addUndoableMutation(({ isUndo }) => {
327339
if (isUndo) {
328340
if (onUndo) {
329-
onUndoEvent(callTimeParams, {
330-
mutationMode: mode.current,
331-
});
341+
onUndoEvent(
342+
{
343+
...paramsAtExecutionTimeRef.current,
344+
...callTimeParams,
345+
},
346+
{
347+
mutationMode: mode.current,
348+
}
349+
);
332350
}
333351
// rollback
334352
snapshot.current.forEach(([key, value]) => {
335353
queryClient.setQueryData(key, value);
336354
});
337355
} else {
338356
// call the mutate method without success side effects
339-
mutation.mutate(callTimeParams);
357+
mutation.mutate({
358+
...paramsAtExecutionTimeRef.current,
359+
...callTimeParams,
360+
});
340361
}
341362
});
342363
}

0 commit comments

Comments
 (0)