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(engine-dom): remove stylesheets when unused #2797

Closed
wants to merge 39 commits into from

Conversation

nolanlawson
Copy link
Collaborator

@nolanlawson nolanlawson commented Apr 19, 2022

Details

Fixes #2466

This PR causes <style>s and adoptedStyleSheets to be removed from the DOM when the style is no longer needed. This happens in two cases:

  1. A render() function switches from one template to another
  2. A component is removed from the DOM

Note that multiple components/templates may depend on the same <style>/adoptedStyleSheet (e.g. multiple instances of the same component), so we have to do some bookkeeping to check that no components/templates are still relying on the style.

Upsides of this PR

  • Consistent behavior across hydration / non-hydration / SSR
  • Consistent behavior across light and shadow
  • Styles do not infinitely accumulate in the <head> (potential memory leak)

Downsides of this PR

  • Potential breaking changes (see below)
  • Impossible to use static analysis to identify breakages
  • Extra code to do bookkeeping of style usage (+0.18KB min+gz)
  • There is a small runtime cost for this bookkeeping:

Screen Shot 2022-04-28 at 3 13 35 PM

Does this pull request introduce a breaking change?

  • ✅ No, it does not introduce a breaking change.

Does this pull request introduce an observable change?

  • ⚠️ Yes, it does include an observable change.

Styles are unrendered when dynamically swapped out, rather than remaining in the DOM. For instance, if a component has a render() function, and it switches from template A to template B, here is the behavior:

  • Old behavior: B's styles are added on top of A's styles. A's styles still remain.
  • New behavior: A's styles are removed and replaced with B's styles.

SSR'ed/hydrated components are a bit different, because they use inline <style>s rather than shared style sheets.

GUS work item

W-9789883

@nolanlawson
Copy link
Collaborator Author

nolanlawson commented Apr 20, 2022

Two thoughts on this PR:

  1. The code is not very DRY. We can probably combine the add/remove logic for stylesheets, and possibly we can combine the global vs local stylesheets into one utility to handle both.
  2. Since this is an observable change that may cause breakages, it may need a feature flag. Depends on how serious we think CSS breakages may be, and how widespread the breakages may be.
  3. I should write hydration / SSR tests for this, since it behaves differently for hydrated/SSR vs non-hydrated.

@nolanlawson nolanlawson force-pushed the nolan/unrender-stylesheets branch from 5573b54 to 79ecf74 Compare April 28, 2022 17:36

function insertStyleElement(content: string, target: ShadowRoot | Document) {
const elm = createStyleElement(content);
const targetAnchorPoint = isDocument(target) ? target.head : target;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Previously we were doing:

document.head || document.body || document

But I don't see the point of that. If it's the document, it has a head. And if it has neither a head nor a body, the browser won't let you append directly to the document anyway.

adoptedStyleSheets.push(styleSheet);
} else {
target.adoptedStyleSheets = [...adoptedStyleSheets, styleSheet];
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Previously we were not using constructable stylesheets for the global document. The main reason was performance (there are a lot of stylesheets on the document compared to a single shadow root). But now that we have mutable adopted stylesheets shipping in Chrome 99, there shouldn't be a perf penalty for appending to the adoptedStyleSheets array.

@vovius
Copy link

vovius commented Apr 28, 2022

/nucleus test

@nolanlawson
Copy link
Collaborator Author

As it turns out, this is a really difficult feature to implement. And it causes a more severe perf impact than I expected:

Screen Shot 2022-04-28 at 3 13 35 PM

Considering the perf impact, the extra complexity, the potential for backwards compatibility breakages, and the potential for unexpected breakages if our bookkeeping is buggy (e.g. styles get unrendered when they shouldn't), it's debatable whether we should even implement this.

if (document.adoptedStyleSheets) {
document.adoptedStyleSheets = [];
}
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It would be great to fix this, but it only happens in dev mode, and hot-swapping is not even used on platform.

expect(getComputedStyle(element.shadowRoot.querySelector('.green')).color).toEqual(
'rgb(0, 0, 0)'
);
expect(getComputedStyle(element).marginLeft).toEqual('0px');
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is a good example of the behavior becoming more consistent with this PR. This is very subtle, because what's actually happening here is that, in synthetic, the stylesheets are also not removed from the DOM. But the reason the color is "correct" in synthetic mode is that the scope attributes are not applied to the DOM nodes (due to the stylesheets array being empty).

In any case, this PR makes the behavior consistent.

}
} else if (ssr || isHydrating()) {
} else if (ssr || vm.hydrated) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It turns out that what we care about is if the vm was hydrated in the first place, not whether it's currently hydrating.

throw new Error('Unexpected element left in the <head> by a test: ' + child.outerHTML);
}
});
var bodyChildren = getBodyChildren();
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We actually don't want the test to clear the <head> after every test, because that might hide bugs in the stylesheet count bookkeeping. Changing this code helped me find some bugs in how I was handling hydration, as well as the hot-swapping issue.

@nolanlawson
Copy link
Collaborator Author

I can't believe I got this working, so I guess this PR is ready for review now.

This should probably have a feature flag, but we need to fix #2811 first.

@nolanlawson nolanlawson marked this pull request as ready for review April 29, 2022 22:07
@pmdartus
Copy link
Member

pmdartus commented May 2, 2022

Let me play the devil's advocate and challenge the necessity of this change.

This change doesn't impact the native shadow DOM components. For native shadow DOM components, styles are either injected in the shadow tree via a <style> element or via ShadowRoot.prototype.adoptedStylesheets. Upon component removal, the styles are automatically cleaned up.

This leaves us with synthetic shadow DOM components and light DOM components. We all agree that synthetic shadow DOM isn't something we want to deprecate. So I am not much worried about the impact of leaving the stylesheet in place for synthetic shadow DOM components.

IMO to evaluate the necessity for this change we should answer the following question: "Should stylesheets injected by light DOM components be cleanup upon removal?". Note that this should only impact light DOM components that are injected inside the main document tree. Light DOM components injected within a shadow tree (light DOM components wrapped within shadow DOM component) aren't impacted as the stylesheet will be injected in the closest ancestor shadow root.

Going back to the original issue #2466 (comment), @nolanlawson you added an interesting comment:

It's worth noting that not every CSS-in-JS library or framework actually does this. So it might not be a big deal in practice.

While I understand why you are referring to the CSS-in-JS, I think a better comparison would be with Svelte and Vue component styling. Those frameworks have built-in styling support which is more relatable to LWC light DOM component styling. Upon inspection, none of those frameworks do clean up injected styles after unmounting a component tree. I haven't heard either strong feedback from developers using those frameworks complaining about the lack of style clean-up capability.

If the main concern here is the potential style clashing cross light DOM components, my answer would be to prefer scoped styles over global styles for light DOM components.


As a side note, there are a couple of interesting refactors in this PR, that we might want to land regardless of if we roll out style clean up or not.

@nolanlawson
Copy link
Collaborator Author

@pmdartus

This change doesn't impact the native shadow DOM components.

The most important case this PR solves is multi-template components, which does affect native shadow DOM. If a component switches from template A to template B, and then back to template A, then B's styles will still win because they are listed last in the DOM.

What prompted me to write this PR is someone pointing out that our own documentation is incorrect on this (#2793 (comment)). Probably this worked at some point in the past (when native shadow injected inline <style>s), but it was broken when we introduced Constructable Stylesheets. It never worked in synthetic shadow AFAIK.

Light DOM components injected within a shadow tree (light DOM components wrapped within shadow DOM component) aren't impacted as the stylesheet will be injected in the closest ancestor shadow root

It does affect light DOM components inside of shadow roots. If the shadow root is never removed from the DOM, then the styles are never cleaned up. So this may happen if a large omnipresent container component is never removed. Any light DOM components inside of that shadow root will just continually append style sheets and never remove them.

If the main concern here is the potential style clashing cross light DOM components, my answer would be to prefer scoped styles over global styles for light DOM components.

Scoped styles actually have exactly the same problem with multi-template components. Scoped styles use the same scoping token for all templates owned by a given component. So if that component switches templates, the two style sheets will still conflict with each other.

I haven't heard either strong feedback from developers using those frameworks complaining about the lack of style clean-up capability.

I didn't find anything for Vue, but I did find some discussion in Svelte and certain CSS-in-JS libraries about this. It seems some developers would like this functionality:

For LWC, I think an argument could be made that we should only remove styles for multi-template components. This case feels clearly "wrong" to me, whereas removing styles on component unmount feels a bit like an implementation detail, or a memory leak optimization. (E.g. for dynamically-created components, we don't want to keep adding <style> tags to the <head>. Which may or may not be a huge memory leak in practice.)

This PR could be refactored to only handle multi-template components. But I haven't thought much about how we would implement it, or how we would handle corner cases. (E.g. simply reordering the style sheets is not sufficient for the case where template A has no styles, but template B does.)

@nolanlawson
Copy link
Collaborator Author

@pmdartus OK, after Dev Sync, I realized I was wrong about two things:

  1. This does work correctly in synthetic shadow, because the scope token is different for template A vs template B.
  2. This does work correctly in scoped styles, for the same reason.

So maybe our message to developers should be: "If you want multi-template components to work as expected when switching back and forth, then use scoped styles." Ditto for light DOM components, as you said.

@nolanlawson
Copy link
Collaborator Author

Closing in favor of #2827

@nolanlawson nolanlawson closed this May 9, 2022
@nolanlawson nolanlawson deleted the nolan/unrender-stylesheets branch July 25, 2022 16:20
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.

Stylesheets should be removed when components/templates are disconnected
3 participants