phaserjs / phaserjs/phaser

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

Open Beginner friendly
#7,340 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
40.3k
Forks
7.2k
PR merge metrics
No merged PRs in 30d

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/input/mouse/MouseManager.js by comparing startListeners with stopListeners, focusing on the wheel registration and the related contextmenu handling. Verify the destroy path with a retained game canvas and confirm that game.destroy(true) leaves no Phaser wheel listener attached to the canvas.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
game-dev
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
78/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.