Steps to reproduce
Live example: https://codesandbox.io/s/s44gdq
(the same app standalone, best for taking heap snapshots: https://s44gdq.csb.app)
The app is one Button and one Dialog under a theme with:
createTheme({
components: {
MuiButtonBase: { defaultProps: { disableRipple: true } },
},
});
- Open https://s44gdq.csb.app and DevTools → Memory.
- Take a heap snapshot; in the Summary view search
MouseEvent and note
the count (~0).
- Click Simulate 100 clicks (it dispatches 100 real
mousedown/mouseup pairs at the plain Button).
- Take a second snapshot and search
MouseEvent again: ~200 new objects
(one per mousedown/mouseup), and they are never collected. Each one's
retainer path is:
nativeEvent of SyntheticBaseEvent ← args of the queued closure ←
PromiseReaction ← reactions_or_result of Promise
(= LazyRipple.mounted) ← mounted of LazyRipple ← the live
button's fiber.
- To see the detached-DOM impact: open and close the dialog 3 times,
snapshot again, search Detached — 3 detached MuiDialog-container
trees are retained, one per open/close cycle, held by the same chain
(the opener button's focus/blur event references dialog content via
relatedTarget).
Current behavior
When ripples are disabled (e.g. theme-wide MuiButtonBase: { defaultProps: { disableRipple: true } }), every mousedown/mouseup/blur/touch on every ButtonBase queues a promise reaction that is never released, capturing the event object. Events whose target/relatedTarget sit in later-removed DOM (a closed Dialog, an unmounted view) pin those entire detached trees for the page lifetime. Long-lived SPAs accumulate this on every click, forever.
Closing a MUI Dialog focuses the opener button; the blur/focus event's relatedTarget is an element inside the dialog. Heap snapshots show one retained detached dialog tree per open/close cycle, each held by a chain similar to this one:
detached MuiDialog-container
<- relatedTarget of (FocusEvent)
<- (bound arguments) [event] of the queued reaction closure
<- PromiseReaction chain
<- reactions_or_result of Promise // LazyRipple.mounted
<- "mounted" of LazyRipple
<- __reactProps$….onFocus of the live opener <button>
4 opens → 4 retained dialog copies (~130 nodes each). Reproduced independently of the dialog: every ButtonBase interaction adds one pending reaction + event.
Expected behavior
Ripple actions should not retain event objects indefinitely when disableRipple is set; mounted should settle regardless of whether a TouchRipple mounts.
Context
Found while investigating a memory-growth issue in a long-lived SPA using a theme-wide disableRipple.
Suggested fix
In LazyRipple.mountEffect, settle the promise even when no ripple rendered — queued actions already no-op through ref.current?.:
mountEffect = () => {
if (this.shouldMount && !this.didMount) {
if (this.ref.current !== null) {
this.didMount = true;
- this.mounted.resolve();
}
+ this.mounted.resolve();
}
};
Would be happy to open a PR for this.
Your environment
npx @mui/envinfo from the reproduction project:
System:
OS: Linux 6.6 Ubuntu 24.04.1 LTS 24.04.1 LTS (Noble Numbat) (WSL2)
Binaries:
Node: 22.18.0 - /usr/bin/node
npm: 12.0.1 - /usr/bin/npm
pnpm: Not Found
npmPackages:
@emotion/react: latest => 11.14.0
@emotion/styled: latest => 11.14.1
@mui/core-downloads-tracker: 9.3.1
@mui/material: latest => 9.3.1
@mui/private-theming: 9.3.0
@mui/styled-engine: 9.3.0
@mui/system: 9.3.0
@mui/types: 9.3.0
@mui/utils: 9.3.0
@types/react: ^18.3.3 => 18.3.31
react: ^18.3.1 => 18.3.1
react-dom: ^18.3.1 => 18.3.1
typescript: ^5.5.3 => 5.9.3
Browser: Chrome 151.0.7922.76 (Windows), also reproduced in headless
Chromium. Originally found on @mui/material 6.1.1 + React 18 in a
production app; the useLazyRipple code is unchanged on current master
(checked 2026-08-19).
Search keywords: LazyRipple, disableRipple, mounted, ripple leak
Steps to reproduce
Live example: https://codesandbox.io/s/s44gdq
(the same app standalone, best for taking heap snapshots: https://s44gdq.csb.app)
The app is one
Buttonand oneDialogunder a theme with:MouseEventand notethe count (~0).
mousedown/mouseup pairs at the plain
Button).MouseEventagain: ~200 new objects(one per mousedown/mouseup), and they are never collected. Each one's
retainer path is:
nativeEventofSyntheticBaseEvent←argsof the queued closure ←PromiseReaction←reactions_or_resultof Promise(=
LazyRipple.mounted) ←mountedofLazyRipple← the livebutton's fiber.
snapshot again, search
Detached— 3 detachedMuiDialog-containertrees are retained, one per open/close cycle, held by the same chain
(the opener button's focus/blur event references dialog content via
relatedTarget).Current behavior
When ripples are disabled (e.g. theme-wide
MuiButtonBase: { defaultProps: { disableRipple: true } }), every mousedown/mouseup/blur/touch on every ButtonBase queues a promise reaction that is never released, capturing the event object. Events whosetarget/relatedTargetsit in later-removed DOM (a closed Dialog, an unmounted view) pin those entire detached trees for the page lifetime. Long-lived SPAs accumulate this on every click, forever.Closing a MUI Dialog focuses the opener button; the blur/focus event's
relatedTargetis an element inside the dialog. Heap snapshots show one retained detached dialog tree per open/close cycle, each held by a chain similar to this one:4 opens → 4 retained dialog copies (~130 nodes each). Reproduced independently of the dialog: every ButtonBase interaction adds one pending reaction + event.
Expected behavior
Ripple actions should not retain event objects indefinitely when
disableRippleis set;mountedshould settle regardless of whether aTouchRipplemounts.Context
Found while investigating a memory-growth issue in a long-lived SPA using a theme-wide
disableRipple.Suggested fix
In
LazyRipple.mountEffect, settle the promise even when no ripple rendered — queued actions already no-op throughref.current?.:mountEffect = () => { if (this.shouldMount && !this.didMount) { if (this.ref.current !== null) { this.didMount = true; - this.mounted.resolve(); } + this.mounted.resolve(); } };Would be happy to open a PR for this.
Your environment
npx @mui/envinfofrom the reproduction project:Browser: Chrome 151.0.7922.76 (Windows), also reproduced in headless
Chromium. Originally found on @mui/material 6.1.1 + React 18 in a
production app; the
useLazyRipplecode is unchanged on current master(checked 2026-08-19).
Search keywords: LazyRipple, disableRipple, mounted, ripple leak