Skip to content

Notification component fires onExited (and the deferred undoable mutation) twice per Snackbar close, due to duplicate TransitionProps/slotProps.transition wiring #11334

Description

@sidona

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

  1. Use (default mutationMode="undoable") with a SaveButton.

  2. Open the Network tab, click Save once.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions