Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

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

Đang mở Phù hợp với người mới
#7,340 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 2 ngày

@schulerj89 đang làm issue này rồi.

Từ ngày 27/7/2026.

  • #7344 của @schulerj89 — đang mở

Đánh giá

Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức phù hợp với người mới
78/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Ít trao đổi
Công nghệ
javascript
Lĩnh vực
game-dev

Hướng nghiên cứu

Bắt đầu trong src/input/mouse/MouseManager.js bằng cách so sánh startListeners với stopListeners, tập trung vào việc đăng ký wheel và phần xử lý contextmenu liên quan. Kiểm tra đường đi của quá trình hủy với canvas của game được giữ lại và xác nhận rằng game.destroy(true) không để lại listener wheel của Phaser nào được gắn vào canvas.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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/[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:

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

Ngôn ngữ chính
JavaScript
Star
40.5k
Fork
7.2k
Chỉ số merge pull request
Không có pull request nào được merge trong 30 ngày

Chuẩn bị môi trường

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của phaserjs/phaser

Tất cả issue của phaserjs/phaser

Issue tương tự

Thêm issue về JavaScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.