Skip to content

MouseManager.stopListeners never removes the wheel listener — destroyed Game stays retained through the canvas #7340

Description

@CasCray

Version

  • Phaser Version: 4.1.0 (also present in 3.87.0 and in current master src/input/mouse/MouseManager.js)
  • Operating system: any (verified Windows 11)
  • Browser: any (verified Chromium 138, headed + headless)

Description

MouseManager.startListeners attaches six listeners to the input target (the game
canvas): mousemove, mousedown, mouseup, mouseover, mouseout, and wheel.

MouseManager.stopListeners removes the first five, but there is no
removeEventListener('wheel', …) anywhere in the class — the wheel listener
stays on the canvas forever, including after game.destroy(true).

The onMouseWheel closure captures _this (the MouseManager) and manager (the
InputManager), and through them the entire Phaser.Game — every scene, texture,
and renderer pipeline. So after destroy, the dead canvas carries a hidden strong
reference to the whole engine graph.

When nothing else references the canvas, the canvas + listener + Game all become
unreachable together and this is invisible. But any surviving reference to the
canvas element (DevTools element inspection, a stray element handle in test
tooling, an app that keeps the canvas for a screenshot/share feature, a detached
node captured by an extension) now retains a full Game instance — measured at
~8 MB per create/destroy cycle in our app. We root-caused a "heap climbs 11 MB →
380 MB over 40 game visits" report to exactly this: one complete Phaser Game
graph pinned per cycle, with the retainer path running through the canvas's
wheel EventListener.

Retainer path from a Chrome heap snapshot (destroyed game, canvas still
referenced):

HTMLCanvasElement → EventListener (wheel) → V8EventListener → onMouseWheel closure
  → context: MouseManager / InputManager → Phaser.Game → SceneManager, TextureManager, …

Every other input surface is balanced (TouchManager, KeyboardManager, and
GamepadPlugin all remove what they add), which suggests wheel was simply missed
when it was added to startListeners.

Example Test Code

<script src="https://cdn.jsdelivr.net/npm/phaser@4.1.0/dist/phaser.min.js"></script>
<script>
  const game = new Phaser.Game({ type: Phaser.AUTO, width: 640, height: 480 });

  setTimeout(() => {
    // Any surviving reference to the canvas — DevTools inspection, test
    // tooling, an app-level screenshot cache — plays this role in real apps.
    window.deadCanvas = game.canvas;
    game.destroy(true);

    setTimeout(() => {
      // In the DevTools console:
      //   getEventListeners(window.deadCanvas)
      // → { wheel: [ { listener: onMouseWheel, … } ] }   ← still attached
      //
      // Take a heap snapshot → filter "Game" → the destroyed Phaser.Game is
      // still reachable, retained via that wheel listener's closure.
      console.log('destroyed — inspect getEventListeners(window.deadCanvas)');
    }, 500);
  }, 1000);
</script>

Expected: after game.destroy(true) the canvas has no Phaser listeners, so a
retained canvas costs a canvas.
Actual: the canvas keeps a wheel listener whose closure retains the entire
destroyed Game.

Suggested fix

In stopListeners (src/input/mouse/MouseManager.js), alongside the other target
removals:

target.removeEventListener('wheel', this.onMouseWheel);

removeEventListener ignores the passive flag when matching, so the single
call covers both the { passive: false } and passive registration branches.

Related, much smaller: disableContextMenu adds an anonymous contextmenu
listener that can never be removed. Its closure captures nothing, so it only
leaks the listener itself — but storing the handler and removing it in
stopListeners would make destroy fully clean.

Workaround (app side)

Before calling game.destroy(true):

game.canvas.removeEventListener('wheel', game.input.mouse.onMouseWheel);

Found while profiling arcade-game open/exit cycles on spacedread.tv (heap
snapshot + reverse-edge retainer analysis).

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