#7340·phaser

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

Author: CasCrayCreated Jul 18, 2026Updated Jul 18, 2026

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

xml
<script src="https://cdn.jsdelivr.net/npm/[email protected]/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:

javascript
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):

javascript
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).