Skip to content

Commit 468828c

Browse files
authored
Merge pull request #10977 from marmelab/fix-use-mutation-with-mutation-modes
Fix `useMutationWithMutationMode` in `optimistic` and `undoable` mode may pass invalid parameters to the mutation
2 parents edbf37f + ea98356 commit 468828c

File tree

2 files changed

+94
-22
lines changed

2 files changed

+94
-22
lines changed

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

Lines changed: 66 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,53 @@ 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+
<p>{data.value}</p>
126+
<button onClick={() => setData({ value: 'value2' })}>
127+
Update data
128+
</button>
129+
<button onClick={() => update()}>Update</button>
130+
</>
131+
);
132+
};
133+
134+
render(
135+
<CoreAdminContext dataProvider={dataProvider}>
136+
<Dummy />
137+
</CoreAdminContext>
138+
);
139+
fireEvent.click(screen.getByText('Update'));
140+
// In case of undoable, clicking the _Update data_ button would trigger the mutation
141+
fireEvent.click(screen.getByText('Update data'));
142+
await screen.findByText('value2');
143+
fireEvent.click(screen.getByText('Update'));
144+
await waitFor(() => {
145+
expect(dataProvider.updateUserProfile).toHaveBeenCalledWith({
146+
data: { value: 'value1' },
147+
});
148+
});
149+
150+
// Ensure the next call uses the latest data
151+
await waitFor(() => {
152+
expect(dataProvider.updateUserProfile).toHaveBeenCalledWith({
153+
data: { value: 'value2' },
154+
});
155+
});
156+
});
94157
});

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

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ export const useMutationWithMutationMode = <
5555
mode.current = mutationMode;
5656
}, [mutationMode]);
5757

58+
// This ref won't be updated when params change in an effect, only when the mutate callback is called (See L247)
59+
// This ensures that for undoable and optimistic mutations, the params are not changed by side effects (unselectAll for instance)
60+
// _after_ the mutate function has been called, while keeping the ability to change declaration time params _until_ the mutation is called.
5861
const paramsRef = useRef<Partial<TVariables>>(params);
59-
useEffect(() => {
60-
paramsRef.current = params;
61-
}, [params]);
6262

6363
// Ref that stores the snapshot of the state before the mutation to allow reverting it
6464
const snapshot = useRef<Snapshot>([]);
@@ -96,7 +96,6 @@ export const useMutationWithMutationMode = <
9696
{
9797
mutationKey,
9898
mutationFn: async params => {
99-
const callTimeParams = { ...paramsRef.current, ...params };
10099
if (params == null) {
101100
throw new Error(
102101
'useMutationWithMutationMode mutation requires parameters'
@@ -105,7 +104,7 @@ export const useMutationWithMutationMode = <
105104

106105
return (
107106
mutateWithMiddlewares
108-
.current(callTimeParams as TVariables)
107+
.current(params as TVariables)
109108
// Middlewares expect the data property of the dataProvider response
110109
.then(({ data }) => data)
111110
);
@@ -210,12 +209,15 @@ export const useMutationWithMutationMode = <
210209
...otherCallTimeOptions
211210
} = callTimeOptions;
212211

212+
// store the hook time params *at the moment of the call*
213+
// because they may change afterwards, which would break the undoable mode
214+
// as the previousData would be overwritten by the optimistic update
215+
paramsRef.current = params;
216+
213217
// Store the mutation with middlewares to avoid losing them if the calling component is unmounted
214218
if (getMutateWithMiddlewares) {
215219
mutateWithMiddlewares.current = getMutateWithMiddlewaresEvent(
216220
(params: TVariables) => {
217-
// Store the final parameters which might have been changed by middlewares
218-
paramsRef.current = params;
219221
return mutationFnEvent(params);
220222
}
221223
);
@@ -230,11 +232,6 @@ export const useMutationWithMutationMode = <
230232
callTimeOnError.current = onError;
231233
callTimeOnSettled.current = onSettled;
232234

233-
// store the hook time params *at the moment of the call*
234-
// because they may change afterwards, which would break the undoable mode
235-
// as the previousData would be overwritten by the optimistic update
236-
paramsRef.current = params;
237-
238235
if (mutationMode) {
239236
mode.current = mutationMode;
240237
}
@@ -288,7 +285,7 @@ export const useMutationWithMutationMode = <
288285
if (onSuccess) {
289286
onSuccess(
290287
optimisticResult,
291-
callTimeParams,
288+
{ ...paramsRef.current, ...callTimeParams },
292289
{
293290
snapshot: snapshot.current,
294291
},
@@ -304,7 +301,7 @@ export const useMutationWithMutationMode = <
304301
) {
305302
mutationOptions.onSuccess(
306303
optimisticResult,
307-
callTimeParams,
304+
{ ...paramsRef.current, ...callTimeParams },
308305
{
309306
snapshot: snapshot.current,
310307
},
@@ -319,24 +316,36 @@ export const useMutationWithMutationMode = <
319316

320317
if (mode.current === 'optimistic') {
321318
// call the mutate method without success side effects
322-
return mutation.mutate(callTimeParams);
319+
return mutation.mutate({
320+
...paramsRef.current,
321+
...callTimeParams,
322+
});
323323
} else {
324324
// Undoable mutation: add the mutation to the undoable queue.
325325
// The Notification component will dequeue it when the user confirms or cancels the message.
326326
addUndoableMutation(({ isUndo }) => {
327327
if (isUndo) {
328328
if (onUndo) {
329-
onUndoEvent(callTimeParams, {
330-
mutationMode: mode.current,
331-
});
329+
onUndoEvent(
330+
{
331+
...paramsRef.current,
332+
...callTimeParams,
333+
},
334+
{
335+
mutationMode: mode.current,
336+
}
337+
);
332338
}
333339
// rollback
334340
snapshot.current.forEach(([key, value]) => {
335341
queryClient.setQueryData(key, value);
336342
});
337343
} else {
338344
// call the mutate method without success side effects
339-
mutation.mutate(callTimeParams);
345+
mutation.mutate({
346+
...paramsRef.current,
347+
...callTimeParams,
348+
});
340349
}
341350
});
342351
}

0 commit comments

Comments
 (0)