-
Notifications
You must be signed in to change notification settings - Fork 426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Clear dangling EventListeners and Detached Nodes when a controller is removed from the DOM #592
Conversation
Could this be solved by only changing stimulus/src/core/dispatcher.ts Line 13 in 9686943
|
I thought about this too but, unfortunately, I don't think it would work: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Amazing work tracking this one down @intrip 🙌!
@@ -7,7 +7,7 @@ import { Token, ValueListObserver, ValueListObserverDelegate } from "../mutation | |||
|
|||
export interface BindingObserverDelegate extends ErrorHandler { | |||
bindingConnected(binding: Binding): void | |||
bindingDisconnected(binding: Binding): void | |||
bindingDisconnected(binding: Binding, clearEventListeners?: boolean): void |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a scenario where we want to invoke this bindingDisconnected
method without clearing the listeners?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question! I added this conditional after I've seen this test to fail: When an attribute is replaced with a new one bindingDisconnected
is triggered (via tokensUnmatched) but if we clear the listener, in this case, we lose the options initially set ("once" in this case) so the test will fail.
|
||
if (eventListenerMap.size == 0) this.eventListenerMaps.delete(eventTarget) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe could extract a couple of methods to clarify logic. I also added a method EventListener.hasBindings
:
private clearEventListenersForBinding(binding: Binding) {
const eventListener = this.fetchEventListenerForBinding(binding)
if (!eventListener.hasBindings()) {
this.removeMappedEventListenerFor(binding)
eventListener.disconnect()
}
}
private removeMappedEventListenerFor(binding: Binding) {
const { eventTarget, eventName, eventOptions } = binding
const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget)
const cacheKey = this.cacheKey(eventName, eventOptions)
eventListenerMap.delete(cacheKey)
if (eventListenerMap.size == 0) this.eventListenerMaps.delete(eventTarget)
}
src/core/dispatcher.ts
Outdated
@@ -51,6 +52,19 @@ export class Dispatcher implements BindingObserverDelegate { | |||
this.application.handleError(error, `Error ${message}`, detail) | |||
} | |||
|
|||
private clearEventListenersForBinding(binding: Binding) { | |||
const eventListener = this.fetchEventListenerForBinding(binding) | |||
if (eventListener.bindings.length == 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need this check eventListener.bindings.length == 0
? In which scenario can this be invoked where this doesn't validate? I don't see anything wrong I just want to understand 😅.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please ask!
It's a rough edge but It's possible to have multiple bindings for the same EventListener
. Let's say we have this scenario:
<div data-controller="boosts">
<button data-action="click->boosts#doSomething click->boosts#doSomething">Hey!</button>
</div>
In this case, if change the "data-action" we don't want to clear the EventListener
unless we remove both click->boosts#doSomething
actions.
Right now we call this method only if the whole controller is removed so the safeguard is not a necessity, it was a necessity in the first version I made which didn't use the "clearEventListeners" flag. On the other hand, keeping it looks a bit safer to me so right now I'm keen on keeping it. No strong opinion at all though (can even add a force flag if needed at some point to skip the guard).
1dc5415
to
4a064a2
Compare
… removed from the DOM `eventListenerMaps` maps HTML elements handled by Stimulus to `eventListenerMap`, a map of Stimulus EventListeners in this way: `{ HTMLelement: { eventName: EventListener } }`. When a controller HTML is removed from the DOM the dangling EventListeners aren't removed, moreover, the removed HTML elements are still referenced via the `eventListenerMaps` keys. This leads to having multiple detached HTMLelements and eventListeners which can't be GCed hence a memory leak. The leak is fixed by removing the dangling eventListeners and clearing unused `eventListenerMaps` keys. When the HTMLelement is attached again to the DOM, the `eventListenerMaps` and the related `eventListeners` are automatically re-created by Stimulus via the MutationObservers, no data is lost by doing this. Below is an example to reproduce the issue: ``` <html> <head> <title>EventListenerMaps memory leak</title> <script type="module"> import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js" window.Stimulus = Application.start() Stimulus.register("boosts", class extends Controller { doClick() { process(500) } }) </script> <script> function process(count) { let i = 0 let handler = setInterval(function(){ if (++i > count) { clearInterval(handler) } else { document.body.replaceWith(document.body.cloneNode(true)) } }, 1) } </script> </head> <body> <div data-controller="boosts"> To reproduce: <ul> <li>Check heap snapshot</li> <li>Click "trigger leak" button</li> <li>Check heap snapshot again</li> </ul> <button data-action="click->boosts#doClick">trigger leak</button> </div> </body> </html> ```
4a064a2
to
a912acc
Compare
eventListenerMaps
maps HTML elements handled by Stimulus toeventListenerMap
, a map of Stimulus EventListeners in this way:{ HTMLelement: { eventName: EventListener } }
. When a controller HTML is removed from the DOM the dangling EventListeners aren't removed, moreover, the removed HTML elements are still referenced via theeventListenerMaps
keys. This leads to having multiple detached HTMLelements and eventListeners which can't be GCed hence a memory leak. The leak is fixed by removing the dangling eventListeners and clearing unusedeventListenerMaps
keys. When the HTMLelement is attached again to the DOM, theeventListenerMaps
and the relatedeventListeners
are automatically re-created by Stimulus via the MutationObservers, no data is lost by doing this.Below is an example to reproduce the issue:
heap snapshot before:
heap snapshot after:
Related #489 #547