Affected packages: ra-ui-materialui (regression introduced between 5.14.7 → 5.15.1), ra-core 5.15.0
MUI version: @mui/material 7.3.2 (any MUI v6+ should reproduce, since the code path is gated by muiMajor >= 6)
Summary
When using mutationMode="undoable" (the default for ), saving a record triggers the real network mutation only after the confirmation Snackbar finishes its exit transition (Notification.tsx →
handleExited). Since 5.15.0/5.15.1, this handleExited callback fires twice for a single Snackbar close, causing the underlying dataProvider.update() call — and therefore the network request — to be sent
twice with identical payloads, even though only one toast is ever visible to the user.
For forms that upload files (multipart FormData), this results in the same file/record being persisted twice server-side.
Root cause
packages/ra-ui-materialui/src/layout/Notification.tsx builds the same handleExited reference and passes it to through two different prop channels simultaneously:
const transitionProps = { onExited: handleExited };
const contentProps = { className: /* ... */ };
const mergedSlotProps = {
...rest.slotProps,
transition: { ...transitionProps, ...rest.slotProps?.transition },
content: { ...contentProps, ...rest.slotProps?.content },
};
// ...
<StyledSnackbar
TransitionProps={transitionProps} // <-- legacy prop, still carries onExited
ContentProps={contentProps}
{...rest}
{...options}
{...(muiMajor >= 6 ? { slotProps: mergedSlotProps } : {})} // <-- new prop, ALSO carries onExited
@mui/material's Snackbar (Snackbar.js) does not treat these as mutually exclusive:
const { onExited, ...TransitionPropsProp } = TransitionProps ?? {};
const handleExited = node => {
setExited(true);
if (onExited) onExited(node); // (A) legacy TransitionProps.onExited
};
const [TransitionSlot, transitionProps] = useSlot('transition', {
externalForwardedProps, // includes slotProps.transition.onExited
getSlotProps: handlers => ({
onExited: (...params) => {
handlers.onExited?.(...params); // (B) slotProps.transition.onExited
handleExited(...params); // calls (A) as well
},
}),
// ...
});
The final onExited handler bound to the actual Grow transition ends up invoking both (A) and (B) — which, in react-admin's case, are the same handleExited function passed through two different props.
Net result: one Snackbar exit → handleExited called twice.
In useMutationWithMutationMode.ts, handleExited is what dequeues and commits the pending undoable mutation:
const handleExited = useCallback(() => {
if (currentNotification?.notificationOptions?.undoable) {
const mutation = takeMutation();
if (mutation) mutation({ isUndo: false }); // <-- real network call
}
setCurrentNotification(undefined);
}, [currentNotification, takeMutation]);
If two separate undoable mutations happen to be queued around the same time, both get committed by the double-fire — sending duplicate requests for what the user perceives as a single save action and a
single toast.
Steps to reproduce
-
Use (default mutationMode="undoable") with a SaveButton.
-
Open the Network tab, click Save once.
-
Observe two identical PUT/network requests to the update endpoint, triggered from handleExited (verified via the browser's Initiator stack trace, which shows both going through
Notification.js:handleExited → useMutationWithMutationMode.js → useUpdate.js → mutate).
Suggested fix
Don't pass onExited (and generally the transition callbacks) through both TransitionProps and slotProps.transition at the same time. Since TransitionProps is deprecated in favor of slotProps.transition,
Notification.tsx should drop TransitionProps={transitionProps} entirely once slotProps is used (i.e. for muiMajor >= 6), keeping only:
{...(muiMajor >= 6
? { slotProps: mergedSlotProps }
: { TransitionProps: transitionProps, ContentProps: contentProps })}
Workaround we applied
We couldn't downgrade ra-core/ra-ui-materialui back to 5.14.7 (blocked by our dependency vulnerability scan), so instead we set mutationMode="pessimistic" explicitly on every in our app. This
bypasses the undoable-mutation queue entirely (no deferred commit through Notification), at the cost of losing the "Undo" snackbar action.
Affected packages: ra-ui-materialui (regression introduced between 5.14.7 → 5.15.1), ra-core 5.15.0
MUI version: @mui/material 7.3.2 (any MUI v6+ should reproduce, since the code path is gated by muiMajor >= 6)
Summary
When using mutationMode="undoable" (the default for ), saving a record triggers the real network mutation only after the confirmation Snackbar finishes its exit transition (Notification.tsx →
handleExited). Since 5.15.0/5.15.1, this handleExited callback fires twice for a single Snackbar close, causing the underlying dataProvider.update() call — and therefore the network request — to be sent
twice with identical payloads, even though only one toast is ever visible to the user.
For forms that upload files (multipart FormData), this results in the same file/record being persisted twice server-side.
Root cause
packages/ra-ui-materialui/src/layout/Notification.tsx builds the same handleExited reference and passes it to through two different prop channels simultaneously:
const transitionProps = { onExited: handleExited };
const contentProps = { className: /* ... */ };
const mergedSlotProps = {
};
// ...
<StyledSnackbar
@mui/material's Snackbar (Snackbar.js) does not treat these as mutually exclusive:
const { onExited, ...TransitionPropsProp } = TransitionProps ?? {};
const handleExited = node => {
};
const [TransitionSlot, transitionProps] = useSlot('transition', {
});
The final onExited handler bound to the actual Grow transition ends up invoking both (A) and (B) — which, in react-admin's case, are the same handleExited function passed through two different props.
Net result: one Snackbar exit → handleExited called twice.
In useMutationWithMutationMode.ts, handleExited is what dequeues and commits the pending undoable mutation:
const handleExited = useCallback(() => {
}, [currentNotification, takeMutation]);
If two separate undoable mutations happen to be queued around the same time, both get committed by the double-fire — sending duplicate requests for what the user perceives as a single save action and a
single toast.
Steps to reproduce
Use (default mutationMode="undoable") with a SaveButton.
Open the Network tab, click Save once.
Observe two identical PUT/network requests to the update endpoint, triggered from handleExited (verified via the browser's Initiator stack trace, which shows both going through
Notification.js:handleExited → useMutationWithMutationMode.js → useUpdate.js → mutate).
Suggested fix
Don't pass onExited (and generally the transition callbacks) through both TransitionProps and slotProps.transition at the same time. Since TransitionProps is deprecated in favor of slotProps.transition,
Notification.tsx should drop TransitionProps={transitionProps} entirely once slotProps is used (i.e. for muiMajor >= 6), keeping only:
{...(muiMajor >= 6
Workaround we applied
We couldn't downgrade ra-core/ra-ui-materialui back to 5.14.7 (blocked by our dependency vulnerability scan), so instead we set mutationMode="pessimistic" explicitly on every in our app. This
bypasses the undoable-mutation queue entirely (no deferred commit through Notification), at the cost of losing the "Undo" snackbar action.