Skip to content

fix(core): release native plugin listeners on remove and removeAllLis… - #8653

Closed
CristianDavideConte wants to merge 1 commit into
ionic-team:mainfrom
CristianDavideConte:fix/core-release-native-listeners
Closed

CristianDavideConte wants to merge 1 commit into
ionic-team:mainfrom
CristianDavideConte:fix/core-release-native-listeners

Conversation

@CristianDavideConte

Copy link
Copy Markdown
Contributor

Capacitor Version

@capacitor/core 8.5.3 (also present on main at eb06606)

Platforms Affected

  • iOS
  • Android
  • Web

Current Behavior

A listener added to a native plugin with addListener() is never released, even after handle.remove() or removeAllListeners(). Everything the listener captures stays in memory for the life of the app.

Why:

  • native-bridge.ts stores every callback it is given in its callbacks map (toNative), and only deletes an entry on a promise result or when native answers with save: false. Native never does that for listeners.
  • addListenerNative in runtime.ts passes the same callback to removeListener, so the bridge stores it a second time. Native never answers removeListener, so that entry is never deleted either.
  • removeAllListeners() clears the native listeners, but the bridge keeps the JS callbacks.

Real-world impact: a listener that captures an iframe's window keeps that iframe's whole JS realm alive after the iframe is removed. In our app this kept hundreds of MB per closed iframe.

Expected Behavior

After handle.remove() or removeAllListeners(), the bridge no longer references the listener, so it can be garbage collected.

Project Reproduction

Minimal repro (core unit test, no native project needed):

const handle = await Plugin.addListener('event', () => capturedObject);
await handle.remove();
// force GC: capturedObject is still reachable through the bridge's `callbacks` map

A runnable version is the lets a removed native listener be garbage collected test in the linked PR (core/src/tests/plugin.spec.ts). It fails on main and passes with the fix.

Additional Information

Fix proposed in the linked PR: hand the bridge a forwarder and detach the listener from it on remove() / removeAllListeners(), and call removeListener without a callback.

cursor Bot pushed a commit to Cap-go/capacitor-plus that referenced this pull request Oct 18, 2026
…onic-team#8653

The bot sync commit upstream-preferred @capacitor/* package.json names and
peer deps, which made bun install --frozen-lockfile fail in lint before ESLint ran.

Keep only ionic-team/capacitor ionic-team#8653 changes in core/src/runtime.ts and
core/src/tests/plugin.spec.ts; drop unrelated upstream-only test files.

Co-authored-by: Martin DONADIEU <martindonadieu@gmail.com>
riderx added a commit to Cap-go/capacitor-plus that referenced this pull request Nov 11, 2026
…lugin listeners on remove and removeAllLis… (#193)
@andredestro

Copy link
Copy Markdown
Contributor

Thanks for digging into this! We opened #8709, which also releases the callbacks on removeAllListeners and stops removeListener from storing the callback again. Could you take a look and let us know if it covers your case?

@CristianDavideConte

Copy link
Copy Markdown
Contributor Author

Hey André, I had a look at the PR, it indeed covers the usecases I encountered, thanks for addressing the issue!
Since 8709 superseeds this PR, feel free to close this one and the related ticket whenever that one is merged

@andredestro

Copy link
Copy Markdown
Contributor

Merged in #8709 (f3bba59), with you as co-author. Thanks again for the report and the fix, Cristian! Closing this one.

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.

2 participants