Skip to content

Fix AnimationComponent leaking asset event handlers - #9141

Merged
mvaligursky merged 1 commit into
mainfrom
mv-animation-asset-unbind
Jul 30, 2026
Merged

Fix AnimationComponent leaking asset event handlers#9141
mvaligursky merged 1 commit into
mainfrom
mv-animation-asset-unbind

Conversation

@mvaligursky

Copy link
Copy Markdown
Contributor

Fixes #3972.

AnimationComponent registered its asset event handlers as closures, so they could never be unsubscribed:

assets.on(`add:${ids[i]}`, onAssetAdd);   // asset registry subscription, never removed
asset.once('load', onAssetReady, this);   // never removed

and onBeforeRemove() only unsubscribed from assets it could resolve through the registry at that moment (if (!asset) continue). So a component removed while its animation asset was missing from the registry stayed subscribed, and a later assets.add() re-subscribed the removed component to the asset's change/remove events. That keeps the component, its entity, model and animation resources alive, and dispatches events to it - including during app.destroy(), where Asset.unload() fires change on every asset. In 1.x this threw Cannot read properties of null (reading 'animations'), since a removed component's data-backed animations accessor reads from a store record that no longer exists.

Changes:

  • The onAssetAdd / onAssetReady closures become private methods (_bindAsset, _onAssetAdded, _onAssetReady), so they can be unsubscribed.
  • Subscriptions are tracked (bound assets and pending add:[id] registry events) and all removed by a new private _unbindAssets(), called from onBeforeRemove() and from the assets setter. This covers assets that are no longer in the registry, assets added after the component was removed, and loads still in flight.
  • The assets setter now resolves ids for entries holding Asset instances, which previously skipped their cleanup (registry.get(assetInstance) returns undefined).
  • Duplicate add:[id] subscriptions are no longer registered for the same asset id.
  • Adds regression tests covering the four unbinding paths, plus a control test for the already-working case.

No public API changes.

AnimationComponent registered its asset handlers as closures, so they could
never be unsubscribed:

- `assets.on('add:<id>', onAssetAdd)` on the asset registry
- `asset.once('load', onAssetReady)` on the asset

and `onBeforeRemove` only unsubscribed from assets it could resolve through
the registry at that moment (`if (!asset) continue`). A component removed
while its asset was missing from the registry therefore stayed subscribed,
and a later `assets.add()` re-subscribed the removed component to the
asset's `change`/`remove` events, keeping the component, its entity, model
and animation resources alive - and dispatching events to it when the app
was destroyed.

The handlers are now private methods, the subscriptions are tracked, and
they are all removed both when the component is removed and when the asset
array is replaced.

Fixes #3972
@github-actions

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2354.9 KB (+0.6 KB, +0.02%) 604.7 KB (+0.1 KB, +0.02%) 469.8 KB (+0.3 KB, +0.07%)
playcanvas.min.mjs 2352.3 KB (+0.6 KB, +0.02%) 603.7 KB (+0.1 KB, +0.02%) 469.1 KB (+0.1 KB, +0.03%)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a memory-leak / stale-callback problem in AnimationComponent by making asset-registry and asset load subscriptions fully unsubscribeable, ensuring removed components cannot remain referenced via event handlers (including during app.destroy() asset unload flows).

Changes:

  • Replaces closure-based asset callbacks with private instance methods so off() can reliably unsubscribe them.
  • Tracks both bound assets and pending add:[id] registry subscriptions, and centralizes cleanup in _unbindAssets() (called from onBeforeRemove() and the assets setter).
  • Adds regression tests covering multiple unbinding paths (in-registry, in-flight load, late registry add, registry removal, and asset-array replacement).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/framework/components/animation/component.js Introduces tracked asset binding/unbinding to prevent leaked event handlers and callbacks after component removal or asset-list changes.
test/framework/components/animation/component.test.mjs Adds regression tests verifying asset event handlers are correctly removed across several lifecycle and registry edge cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mvaligursky
mvaligursky merged commit 0fa0539 into main Jul 30, 2026
11 checks passed
@mvaligursky
mvaligursky deleted the mv-animation-asset-unbind branch July 30, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

When destroying application sends onAssetChanged and Data is null

2 participants