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

fix: Mitigate Safari memory leak for input element #4250

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

loredanaspataru
Copy link

@loredanaspataru loredanaspataru commented Apr 8, 2024

Description

This is an attempt to reduce the memory leak impact caused by the Safari bug with HTML input elements that aren't properly garbage collected.

Changes in this PR affect textfield, number-field and search:

  • form and input event listeners are added using addEventListener API instead of the lit declarative way
  • event listeners and form element are removed on disconnectedCallback
  • search has some extra logic to add/remove events on clear button, which was not cleaning properly when search has value and the button is rendered
  • search and combobox extend TextfieldBase instead of Textfield to avoid having an extra form element in search

The PR should be treated as POC to showcase the changes we need in SWC and to confirm the fix works as expected on our side. In case we want to proceed with the changes we can abstract the duplicated logic into a Mixin that could later be removed when the bug is fixed in Safari.

Related issue(s)

Motivation and context

How has this been tested?

  • Test case 1
    1. Go here
    2. Do this
  • Test case 2
    1. Go here
    2. Do this

Screenshots (if appropriate)

Here's a screenshot that compares DOM trees that use sp-search; left one is current SWC behavior, right one is patched using the changes in this PR. After detaching child nodes from their parent element, the ones in red are garbage collected, while the remaining ones are retained.

compare-patches

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Chore (minor updates related to the tooling or maintenance of the repository, does not impact compiled assets)

Checklist

  • I have signed the Adobe Open Source CLA.
  • My code follows the code style of this project.
  • If my change required a change to the documentation, I have updated the documentation in this pull request.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have reviewed at the Accessibility Practices for this feature, see: Aria Practices

Best practices

This repository uses conventional commit syntax for each commit message; note that the GitHub UI does not use this by default so be cautious when accepting suggested changes. Avoid the "Update branch" button on the pull request and opt instead for rebasing your branch against main.

Copy link

github-actions bot commented Apr 8, 2024

Lighthouse scores

Category Latest (report) Main (report) Branch (report)
Performance 0.97 0.97 0.98
Accessibility 1 1 1
Best Practices 1 1 1
SEO 1 0.92 0.92
PWA 1 1 1
What is this?

Lighthouse scores comparing the documentation site built from the PR ("Branch") to that of the production documentation site ("Latest") and the build currently on main ("Main"). Higher scores are better, but note that the SEO scores on Netlify URLs are artifically constrained to 0.92.

Transfer Size

Category Latest Main Branch
Total 228.986 kB 217.397 kB 217.364 kB 🏆
Scripts 60.889 kB 54.643 kB 54.459 kB 🏆
Stylesheet 35.427 kB 30.794 kB 🏆 30.94 kB
Document 5.958 kB 5.248 kB 🏆 5.253 kB
Third Party 126.712 kB 126.712 kB 126.712 kB

Request Count

Category Latest Main Branch
Total 43 43 43
Scripts 35 35 35
Stylesheet 5 5 5
Document 1 1 1
Third Party 2 2 2

Copy link

github-actions bot commented Apr 8, 2024

Tachometer results

Chrome

color-field permalink

basic-test

Version Bytes Avg Time vs remote vs branch
npm latest 479 kB 40.57ms - 42.83ms - unsure 🔍
-6% - +1%
-2.73ms - +0.32ms
branch 478 kB 41.88ms - 43.93ms unsure 🔍
-1% - +7%
-0.32ms - +2.73ms
-

combobox permalink

basic-test

Version Bytes Avg Time vs remote vs branch
npm latest 706 kB 36.45ms - 37.02ms - unsure 🔍
-1% - +1%
-0.49ms - +0.31ms
branch 699 kB 36.55ms - 37.11ms unsure 🔍
-1% - +1%
-0.31ms - +0.49ms
-

light-dom-test permalink

Version Bytes Avg Time vs remote vs branch
npm latest 706 kB 391.87ms - 399.09ms - unsure 🔍
-1% - +1%
-4.77ms - +4.86ms
branch 700 kB 392.24ms - 398.63ms unsure 🔍
-1% - +1%
-4.86ms - +4.77ms
-

number-field permalink

basic-test

Version Bytes Avg Time vs remote vs branch
npm latest 521 kB 65.93ms - 67.61ms - faster ✔
6% - 10%
4.59ms - 7.59ms
branch 515 kB 71.62ms - 74.10ms slower ❌
7% - 11%
4.59ms - 7.59ms
-

search permalink

test-basic

Version Bytes Avg Time vs remote vs branch
npm latest 471 kB 37.89ms - 38.73ms - faster ✔
4% - 7%
1.62ms - 3.01ms
branch 466 kB 40.07ms - 41.18ms slower ❌
4% - 8%
1.62ms - 3.01ms
-

slider permalink

test-basic

Version Bytes Avg Time vs remote vs branch
npm latest 478 kB 74.48ms - 76.43ms - unsure 🔍
-2% - +2%
-1.27ms - +1.21ms
branch 470 kB 74.72ms - 76.25ms unsure 🔍
-2% - +2%
-1.21ms - +1.27ms
-

textfield permalink

test-basic

Version Bytes Avg Time vs remote vs branch
npm latest 432 kB 23.11ms - 23.72ms - faster ✔
12% - 16%
3.35ms - 4.26ms
branch 425 kB 26.88ms - 27.56ms slower ❌
14% - 18%
3.35ms - 4.26ms
-
Firefox

color-field permalink

basic-test

Version Bytes Avg Time vs remote vs branch
npm latest 493 kB 76.51ms - 79.81ms - unsure 🔍
-2% - +2%
-1.88ms - +1.80ms
branch 486 kB 77.39ms - 79.01ms unsure 🔍
-2% - +2%
-1.80ms - +1.88ms
-

combobox permalink

basic-test

Version Bytes Avg Time vs remote vs branch
npm latest 706 kB 63.22ms - 68.46ms - slower ❌
4% - 15%
2.75ms - 8.73ms
branch 699 kB 58.65ms - 61.55ms faster ✔
4% - 13%
2.75ms - 8.73ms
-

light-dom-test permalink

Version Bytes Avg Time vs remote vs branch
npm latest 706 kB 703.96ms - 723.48ms - slower ❌
4% - 7%
27.01ms - 48.15ms
branch 700 kB 672.10ms - 680.18ms faster ✔
4% - 7%
27.01ms - 48.15ms
-

number-field permalink

basic-test

Version Bytes Avg Time vs remote vs branch
npm latest 521 kB 145.30ms - 153.14ms - unsure 🔍
-4% - +3%
-5.32ms - +4.72ms
branch 515 kB 146.40ms - 152.64ms unsure 🔍
-3% - +4%
-4.72ms - +5.32ms
-

search permalink

test-basic

Version Bytes Avg Time vs remote vs branch
npm latest 471 kB 66.90ms - 72.06ms - unsure 🔍
-5% - +5%
-3.74ms - +3.46ms
branch 466 kB 67.11ms - 72.13ms unsure 🔍
-5% - +5%
-3.46ms - +3.74ms
-

slider permalink

test-basic

Version Bytes Avg Time vs remote vs branch
npm latest 478 kB 159.58ms - 164.26ms - unsure 🔍
-2% - +3%
-4.01ms - +4.21ms
branch 470 kB 158.44ms - 165.20ms unsure 🔍
-3% - +2%
-4.21ms - +4.01ms
-

textfield permalink

test-basic

Version Bytes Avg Time vs remote vs branch
npm latest 432 kB 47.17ms - 51.15ms - faster ✔
6% - 16%
3.16ms - 9.24ms
branch 425 kB 53.06ms - 57.66ms slower ❌
6% - 19%
3.16ms - 9.24ms
-

Copy link
Contributor

@Rajdeepc Rajdeepc left a comment

Choose a reason for hiding this comment

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

Good Start!! Also can you check if you can better abstract the AbortController to a new controller file so that the rest of the library can also leverage it?

Also can you let me know how can I test this?


public override disconnectedCallback(): void {
// Cleanup form event listener and remove form element from DOM
this.form.removeEventListener('submit', this.handleSubmit.bind(this));
Copy link
Contributor

Choose a reason for hiding this comment

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

you should also check if the form element exists before trying to remove the event listener and remove the form element.


protected override firstUpdateAfterConnected(): void {
super.firstUpdateAfterConnected();
this.form.addEventListener('submit', this.handleSubmit.bind(this));
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of binding the event listener in the firstUpdateAfterConnected method, you can bind it directly in the connectedCallback method. This simplifies the code and makes it easier to understand.

Copy link
Author

Choose a reason for hiding this comment

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

In connectedCallback I don't have access to the form element because it hasn't been created yet. My first option was to add the listener in firstUpdated where the form element has been created but in that case it won't be called again after a disconnect

@@ -98,15 +130,28 @@ export class Search extends Textfield {
);
}

private _manageClearButtonListeners(): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

This can be better written like

private _manageClearButtonListeners(): void {
    if (this.clearButton) {
        if (!this.clearButtonAbortController) {
            this.clearButtonAbortController = new AbortController();
            const { signal } = this.clearButtonAbortController;
            this.clearButton.addEventListener('keydown', stopPropagation, { signal });
        }
    } else {
        if (this.clearButtonAbortController) {
            this.clearButtonAbortController.abort();
            this.clearButtonAbortController = undefined;
        }
    }
}

Copy link
Author

Choose a reason for hiding this comment

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

Will update, thanks for the suggestion

this.formAbortController?.abort();
this.clearButtonAbortController?.abort();
this.clearButtonAbortController = undefined;
this.form.remove();
Copy link
Contributor

Choose a reason for hiding this comment

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

It's generally a good practice to perform cleanup operations in the reverse order of initialisation. So, you might want to remove event listeners before aborting controllers and removing elements.

Copy link
Author

Choose a reason for hiding this comment

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

Calling formAbortController.abort() removes all the event listeners that were created using that controller's signal. You can read more about that here https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#signal

protected override firstUpdateAfterConnected(): void {
super.firstUpdateAfterConnected();

this.formAbortController = new AbortController();
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of creating a new AbortController instance every time the method is called, you can initialize it once in the constructor or connectedCallback and reuse it

Copy link
Author

Choose a reason for hiding this comment

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

I can't do that because once the controller.abort() method has been called its signal can't be reused to add the event listeners again. So the current flow is as follows:

  • on first update after connected I create an instance of AbortController
  • I use the controller's signal to add the form event listeners
  • the form event listeners are removed on disconnectedCallback by calling controller.abort()

If I try to reuse the same controller and let's say I reparent the element, after the element is moved the form event listeners won't be re-created.

private _manageClearButtonListeners(): void {
// add clear button listener when button is added to the DOM
if (this.clearButton && !this.clearButtonAbortController) {
this.clearButtonAbortController = new AbortController();
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here you are recreating this controller everytime this method reinstantiates.

Copy link
Author

Choose a reason for hiding this comment

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

As mentioned previously I can't reuse the same controller. If it seems like a significant issue I can remove the AbortController and instead use add/removeEventListener API along with a flag to check that the listeners have been added to achieve the same result

@loredanaspataru
Copy link
Author

loredanaspataru commented Apr 16, 2024

Also can you check if you can better abstract the AbortController to a new controller file so that the rest of the library can also leverage it?

@Rajdeepc Not sure exactly what you mean with this abstraction since AbortController is a browser API. Could you further explain this or offer an example?

Also can you let me know how can I test this?

I will update the test env I used to reflect the latest changes from this PR and get back with an answer. Thanks for taking the time to check this out

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

2 participants