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).
Version
mastersrc/input/mouse/MouseManager.js)Description
MouseManager.startListenersattaches six listeners to the input target (the gamecanvas):
mousemove,mousedown,mouseup,mouseover,mouseout, andwheel.MouseManager.stopListenersremoves the first five, but there is noremoveEventListener('wheel', …)anywhere in the class — thewheellistenerstays on the canvas forever, including after
game.destroy(true).The
onMouseWheelclosure captures_this(the MouseManager) andmanager(theInputManager), 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
wheelEventListener.Retainer path from a Chrome heap snapshot (destroyed game, canvas still
referenced):
Every other input surface is balanced (TouchManager, KeyboardManager, and
GamepadPlugin all remove what they add), which suggests
wheelwas simply missedwhen it was added to
startListeners.Example Test Code
Expected: after
game.destroy(true)the canvas has no Phaser listeners, so aretained canvas costs a canvas.
Actual: the canvas keeps a
wheellistener whose closure retains the entiredestroyed Game.
Suggested fix
In
stopListeners(src/input/mouse/MouseManager.js), alongside the other targetremovals:
removeEventListenerignores thepassiveflag when matching, so the singlecall covers both the
{ passive: false }and passive registration branches.Related, much smaller:
disableContextMenuadds an anonymouscontextmenulistener that can never be removed. Its closure captures nothing, so it only
leaks the listener itself — but storing the handler and removing it in
stopListenerswould make destroy fully clean.Workaround (app side)
Before calling
game.destroy(true):Found while profiling arcade-game open/exit cycles on spacedread.tv (heap
snapshot + reverse-edge retainer analysis).