MouseManager.stopListeners never removes the wheel listener — destroyed Game stays retained through the canvas
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
mastersrc/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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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