Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions src/engine/input/input-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
11 changes: 11 additions & 0 deletions src/engine/input/pointer-event-receiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/engine/input/pointer-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
}
29 changes: 29 additions & 0 deletions src/spec/vitest/pointer-input-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', <any>document, ex.NativePointerButton.Left, 10, 10);
executeMouseEvent('pointermove', <any>document, null, 15, 15);
executeMouseEvent('pointerup', <any>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);
});
});
});