From dcb84100f09d94f2ba9f31293dc2e9db53e19a94 Mon Sep 17 00:00:00 2001 From: MohammadYusif Date: Sun, 14 Jun 2026 06:20:02 +0300 Subject: [PATCH] fix(input): clear PointerEventReceiver events independently of PointerSystem (#3356) The PointerEventReceiver accumulates native pointer events in its currentFrame* arrays. These were only ever updated and cleared by PointerSystem.update() each frame. If a scene removed the PointerSystem (e.g. world.remove(world.get(PointerSystem))), nothing flushed those arrays, so they grew without bound on every pointer event - a memory leak. The InputHost now guarantees the receiver is flushed exactly once per frame regardless of whether a PointerSystem is active: the PointerSystem marks each receiver it processes, and the InputHost flushes any receiver that wasn't processed. This avoids double-emitting raw pointer events when the system is present while preventing the leak when it is absent. --- CHANGELOG.md | 1 + src/engine/input/input-host.ts | 11 ++++++++ src/engine/input/pointer-event-receiver.ts | 11 ++++++++ src/engine/input/pointer-system.ts | 8 +++++- src/spec/vitest/pointer-input-spec.ts | 29 ++++++++++++++++++++++ 5 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50edfb4df0..bcd6ea37e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fixed issue where `scaleTo({…})` and `scaleBy({…})` actions used a live reference to the entity's scale vector as the interpolation start point, causing the easing curve to be corrupted if the entity's scale changed during the action - Fixed issue where the first action in a sequence would not execute after calling `clearActions()` mid-execution. All action types now properly reset their initialization state when stopped, resolving issue #3468 - Performance: Font/Text now use smaller texture sizes, improving performance on Safari especially when rendering text +- Fixed issue #3356 where removing the `PointerSystem` from a scene caused the `PointerEventReceiver`'s per-frame event arrays to grow unbounded (a memory leak), because clearing those arrays was only performed by the `PointerSystem`. The receiver is now always flushed once per frame by the `InputHost`, independent of whether a `PointerSystem` is active. ### Updates diff --git a/src/engine/input/input-host.ts b/src/engine/input/input-host.ts index 87faba4949..d76d399873 100644 --- a/src/engine/input/input-host.ts +++ b/src/engine/input/input-host.ts @@ -52,6 +52,17 @@ export class InputHost { this.keyboard.update(); this.gamepads.update(); } + + // The PointerSystem normally updates and clears the pointer receiver each frame. If a scene has no + // active PointerSystem (e.g. it was removed via `world.remove(world.get(PointerSystem))`), the + // receiver's per-frame event arrays would otherwise grow unbounded. Flush here as a fallback so the + // receiver is always reset exactly once per frame regardless of the PointerSystem (see issue #3356). + if (this.pointers._processedThisFrame) { + this.pointers._processedThisFrame = false; + } else { + this.pointers.update(); + this.pointers.clear(); + } } clear() { diff --git a/src/engine/input/pointer-event-receiver.ts b/src/engine/input/pointer-event-receiver.ts index da84af3e9e..14e1ae5897 100644 --- a/src/engine/input/pointer-event-receiver.ts +++ b/src/engine/input/pointer-event-receiver.ts @@ -78,6 +78,17 @@ export class PointerEventReceiver { private _enabled = true; + /** + * Tracks whether the {@apilink PointerSystem} has already processed (updated and cleared) this + * receiver during the current frame. + * + * This is used by the {@apilink InputHost} to guarantee the receiver's per-frame events are always + * flushed, even when no {@apilink PointerSystem} is active in the current scene. Without this, removing + * the PointerSystem would cause the `currentFrame*` arrays to grow unbounded on every native pointer event. + * @internal + */ + public _processedThisFrame = false; + constructor( public readonly target: GlobalEventHandlers & EventTarget, public engine: Engine diff --git a/src/engine/input/pointer-system.ts b/src/engine/input/pointer-system.ts index 6de75e887f..845d1a1ae6 100644 --- a/src/engine/input/pointer-system.ts +++ b/src/engine/input/pointer-system.ts @@ -163,6 +163,12 @@ export class PointerSystem extends System { // Clear last frame's events this._pointerEventDispatcher.clear(); - this._receivers.forEach((r) => r.clear()); + // Flag receivers as processed so the InputHost doesn't double-process them this frame. + // If the PointerSystem is removed from a scene, the InputHost flush guarantees the receiver + // events are still cleared and the currentFrame* arrays don't leak (see issue #3356). + this._receivers.forEach((r) => { + r.clear(); + r._processedThisFrame = true; + }); } } diff --git a/src/spec/vitest/pointer-input-spec.ts b/src/spec/vitest/pointer-input-spec.ts index 94a1bb545e..f9086ae668 100644 --- a/src/spec/vitest/pointer-input-spec.ts +++ b/src/spec/vitest/pointer-input-spec.ts @@ -446,4 +446,33 @@ describe('A pointer', () => { removeEventListenerSpy.mockRestore(); }); }); + + describe('without a PointerSystem', () => { + it('should still clear per-frame events so the receiver arrays do not leak (#3356)', () => { + const receiver = engine.input.pointers as any; + + // Remove the PointerSystem from the scene - previously this meant nothing cleared the + // receiver's currentFrame* arrays, leaking memory on every native pointer event. + const pointerSystem = engine.currentScene.world.get(ex.PointerSystem); + engine.currentScene.world.remove(pointerSystem); + expect(engine.currentScene.world.get(ex.PointerSystem)).toBeFalsy(); + + const clock = engine.clock as ex.TestClock; + + for (let i = 0; i < 5; i++) { + executeMouseEvent('pointerdown', document, ex.NativePointerButton.Left, 10, 10); + executeMouseEvent('pointermove', document, null, 15, 15); + executeMouseEvent('pointerup', document, ex.NativePointerButton.Left, 10, 10); + // advance a full frame + clock.step(16); + } + + // The arrays must be flushed each frame even though no PointerSystem is present + expect(receiver.currentFrameDown.length).toBe(0); + expect(receiver.currentFrameMove.length).toBe(0); + expect(receiver.currentFrameUp.length).toBe(0); + expect(receiver.currentFrameCancel.length).toBe(0); + expect(receiver.currentFrameWheel.length).toBe(0); + }); + }); });