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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions