Skip to content

[material-ui][ButtonBase] Every interaction on a disableRipple button permanently leaks its event object (LazyRipple mounted promise never settles) #48999

Description

@dizhurnikita

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 } },
  },
});
  1. Open https://s44gdq.csb.app and DevTools → Memory.
  2. Take a heap snapshot; in the Summary view search MouseEvent and note
    the count (~0).
  3. Click Simulate 100 clicks (it dispatches 100 real
    mousedown/mouseup pairs at the plain Button).
  4. 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 SyntheticBaseEventargs of the queued closure ←
    PromiseReactionreactions_or_result of Promise
    (= LazyRipple.mounted) ← mounted of LazyRipple ← the live
    button's fiber.
  5. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions