Skip to content
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

Add section on avoiding memory leaks to events.md #1240

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

chrispaterson
Copy link

One of the most common causes of detached HTML Elements remaining in memory after a developer expects them to be removed and cleaned up is attributed to them not realizing that the Lit reactive lifecycle methods can be called again after disconnectedCallback. I think it might be prudent to add a section to the documentation that calls out the issue and gives a warning. This is my first attempt, if edits are needed please advise.

Copy link
Member

@augustjk augustjk left a comment

Choose a reason for hiding this comment

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

Thank you! This is a great thing to point out. I do wonder if we should provide an alternative for the presented bad code but I'm not sure what the best fix for that would be... Maybe to add the event listener in connectedCallback and add the condition inside the handler callback?

@@ -98,6 +98,32 @@ disconnectedCallback() {

See the MDN documentation on using custom elements [lifecycle callbacks](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks) for more information on `connectedCallback` and `disconnectedCallback`.

### Avoiding Memory Leaks

Avoid mixing and matching adding and removing event listeners between the [Lit reactive lifecycle methods](/docs/components/lifecycle/#reactive-update-cycle) and custom element [lifecycle callbacks](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks). These two lifecycles, in some cases, operate independently of one another. For example, just because `disconnectedCallback` has been called does not mean that `shouldUpdate`, `willUpdate`, `update`, `render`, and `updated` will not be called again if a [reactive property](/docs/components/properties/) is changed after the element is removed from the DOM. This can result in memory leaks when listeners attached to active objects contain back references to the component that set them.
Copy link
Member

Choose a reason for hiding this comment

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

This might be blasphemous but we do a single space after periods.

Suggested change
Avoid mixing and matching adding and removing event listeners between the [Lit reactive lifecycle methods](/docs/components/lifecycle/#reactive-update-cycle) and custom element [lifecycle callbacks](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks). These two lifecycles, in some cases, operate independently of one another. For example, just because `disconnectedCallback` has been called does not mean that `shouldUpdate`, `willUpdate`, `update`, `render`, and `updated` will not be called again if a [reactive property](/docs/components/properties/) is changed after the element is removed from the DOM. This can result in memory leaks when listeners attached to active objects contain back references to the component that set them.
Avoid mixing and matching adding and removing event listeners between the [Lit reactive lifecycle methods](/docs/components/lifecycle/#reactive-update-cycle) and custom element [lifecycle callbacks](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks). These two lifecycles, in some cases, operate independently of one another. For example, just because `disconnectedCallback` has been called does not mean that `shouldUpdate`, `willUpdate`, `update`, `render`, and `updated` will not be called again if a [reactive property](/docs/components/properties/) is changed after the element is removed from the DOM. This can result in memory leaks when listeners attached to active objects contain back references to the component that set them.

Comment on lines +106 to +122
connectedCallback () {
super.connectedCallback();
this.listeningForResize = false;
}
disconnectedCallback() {
window.removeEventListener("resize", this.handleWindowResize);
super.disconnectedCallback();
}
handleWindowResize = (event) => {
this.style.width = "880px";
}
willUpdate(changedProperties) {
if (!this.listeningForResize) {
window.addEventListener("resize", this.handleWindowResize);
this.listeningForResize = true;
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Nit around code style – single quotes for strings and underscore for private method and unused param. Also inline comment to make it clear this shouldn't be done.

Suggested change
connectedCallback () {
super.connectedCallback();
this.listeningForResize = false;
}
disconnectedCallback() {
window.removeEventListener("resize", this.handleWindowResize);
super.disconnectedCallback();
}
handleWindowResize = (event) => {
this.style.width = "880px";
}
willUpdate(changedProperties) {
if (!this.listeningForResize) {
window.addEventListener("resize", this.handleWindowResize);
this.listeningForResize = true;
}
}
connectedCallback () {
super.connectedCallback();
this.listeningForResize = false;
}
disconnectedCallback() {
window.removeEventListener('resize', this._handleWindowResize);
super.disconnectedCallback();
}
_handleWindowResize = (_event) => {
this.style.width = '880px';
}
willUpdate(_changedProperties) {
if (!this.listeningForResize) {
// This might never get removed and cause a memory leak!
window.addEventListener('resize', this._handleWindowResize);
this.listeningForResize = true;
}
}

@@ -98,6 +98,32 @@ disconnectedCallback() {

See the MDN documentation on using custom elements [lifecycle callbacks](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks) for more information on `connectedCallback` and `disconnectedCallback`.

### Avoiding Memory Leaks

Avoid mixing and matching adding and removing event listeners between the [Lit reactive lifecycle methods](/docs/components/lifecycle/#reactive-update-cycle) and custom element [lifecycle callbacks](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#Using_the_lifecycle_callbacks). These two lifecycles, in some cases, operate independently of one another. For example, just because `disconnectedCallback` has been called does not mean that `shouldUpdate`, `willUpdate`, `update`, `render`, and `updated` will not be called again if a [reactive property](/docs/components/properties/) is changed after the element is removed from the DOM. This can result in memory leaks when listeners attached to active objects contain back references to the component that set them.
Copy link
Contributor

Choose a reason for hiding this comment

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

I would love to see a description of the problematic structure so that readers can develop a mental model of when event listeners will hold on to the element.

Something like:

Event listeners can sometimes cause memory leaks if they're not removed, depending on the objects or elements they're added to and the objects they reference in the body of the event handler.

Listener functions hold a JavaScript reference to variables closed over in the function body. This behaves as if you added a property to the event target that references the objects used in the handler.

When this can often cause a leak is when a listener is added to an object outside of the custom element or it's children, that references back to the element:

class LeakyElement extends LitElement {
  constructor() {
    super();
    // This will cause LeakyElement instances to never be GC'ed if not removed:
    window.addEventListener('mousemove', this.#onMouseMove);
  }
  #onMouseMove = (e) => { /* ... */ };
}

To prevent a memory leak, you should added these listeners in connectedCallback() and remove them in disconnectedCallback():

class LeakyElement extends LitElement {
  connectedCallback() {
    super.connectedCallback();
    window.addEventListener('mousemove', this.#onMouseMove);
  }
  disconnectedCallback() {
    super.disconnectedCallback();
    window.removeEventListener('mousemove', this.#onMouseMove);
  }
  #onMouseMove = (e) => { /* ... */ };
}

Note that the reference to the event listener needs to be the same in the addEventListener() and removeEventListener() calls. Using a class field initialized to an arrow function is a convenient way to do this.

Event listeners added by an element to itself or a child do not usually cause a leak because they create a cycle that GCs can collect:

class LeakyElement extends LitElement {
  constructor() {
    super();
    // This will *not* cause a leak:
    this.addEventListener('click', this.#onClick);
  }
  #onClick = (e) => { /* ... */ };
}

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.

None yet

3 participants