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

[lit-labs/ssr] Shared and de-duplicated declarative styles utility #4378

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

AndrewJakubowicz
Copy link
Contributor

@AndrewJakubowicz AndrewJakubowicz commented Nov 9, 2023

Issue: #4357

Background

Currently, there is no way to deduplicate styles in the declarative shadow DOM. This PR provides an opt-in mechanism (if JavaScript is enabled), to deduplicates declarative shadow DOM styles from LitElement's static styles array.

Design

During SSR, if this feature is enabled, each css tag present in the static styles array will be servers side rendered into a <style> element once. This element will be wrapped with the deduplicate element.

After the first instance of the styles have been encountered, any subsequent instances encountered during SSR rendering will only send down the deduplication element with an id attribute which references the first instance.

During parse time, the deduplication element can synchronously pull in the styles and attach them. This avoids any FOUC.

I've made it so Constructable style sheets are used if available, falling back to cloning the style element directly.

Semantic benefits

In the case where the browser supports constructable style sheets, this utility provides semantics closer to what we would like SSR to exhibit. Shared styles will use the same underlying style sheet instance.

Thus this utility also exhibits better SSR styles semantics.

If the browser doesn't support adopting constructable style sheets, the fallback behavior is to insert a style element.

Benchmarks

tl;dr: Performance impact is unclear. The most obvious win is in gzip and uncompressed network size.

This is a hard feature to measure, because it impacts network size, but also will result in changes in runtime characteristics. E.g., less style tokens are parsed, but a style element or constructable style sheet is added instead.

Below are measurements I took using material-web.dev as an example of a Lit SSR'd website.

Test No compression gzip brotli
button.html 993kB 45kB 23kB
button.html (with deduplicated styles) 327kB (-67%) 31kB (-31%) 23kB (-0%)

What is the impact on browser performance timings. Note this was done locally using Chrome developer tools, and I jotted down the rough numbers after a couple refreshes. This is for ballpark figures.

Test Parse HTML time (ms) FCP DCL
button catalog page 7.6ms (self 7.0ms) 200ms 183ms
button.html (with deduplicated styles) 10.02ms (self 5.73ms) 225ms 241ms

The increased parse HTML time makes sense with deduplicated styles because we are applying the styles during parse time via the new element. This seems to slightly increase all other timings.

Risks

Doesn't work in no JavaScript environments, this is a userland solution to the following proposal: WICG/webcomponents#939

Copy link

changeset-bot bot commented Nov 9, 2023

⚠️ No Changeset found

Latest commit: 0398f4c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Contributor

github-actions bot commented Nov 9, 2023

📊 Tachometer Benchmark Results

Summary

nop-update

  • this-change, tip-of-tree, previous-release: unsure 🔍 -7% - +2% (-0.81ms - +0.29ms)
    this-change vs tip-of-tree

render

  • this-change: 49.82ms - 52.19ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -4% - +4% (-0.70ms - +0.82ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +3% (-0.88ms - +0.97ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -1% - +2% (-0.47ms - +0.72ms)
    this-change vs tip-of-tree

update

  • this-change: 562.60ms - 575.75ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -10% - +3% (-4.30ms - +1.42ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -3% - +3% (-2.55ms - +2.48ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +1% (-11.03ms - +5.21ms)
    this-change vs tip-of-tree

update-reflect

  • this-change: 567.66ms - 577.15ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -1% - +2% (-7.41ms - +8.44ms)
    this-change vs tip-of-tree

Results

this-change

render

VersionAvg timevs
49.82ms - 52.19ms-

update

VersionAvg timevs
562.60ms - 575.75ms-

update-reflect

VersionAvg timevs
567.66ms - 577.15ms-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
18.45ms - 19.68ms-unsure 🔍
-4% - +4%
-0.70ms - +0.82ms
unsure 🔍
-6% - +1%
-1.25ms - +0.29ms
tip-of-tree
tip-of-tree
18.56ms - 19.46msunsure 🔍
-4% - +4%
-0.82ms - +0.70ms
-unsure 🔍
-6% - +0%
-1.18ms - +0.11ms
previous-release
previous-release
19.08ms - 20.01msunsure 🔍
-2% - +7%
-0.29ms - +1.25ms
unsure 🔍
-1% - +6%
-0.11ms - +1.18ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
40.31ms - 44.41ms-unsure 🔍
-10% - +3%
-4.30ms - +1.42ms
unsure 🔍
-10% - +3%
-4.65ms - +1.25ms
tip-of-tree
tip-of-tree
41.81ms - 45.79msunsure 🔍
-3% - +10%
-1.42ms - +4.30ms
-unsure 🔍
-7% - +6%
-3.16ms - +2.64ms
previous-release
previous-release
41.94ms - 46.18msunsure 🔍
-3% - +11%
-1.25ms - +4.65ms
unsure 🔍
-6% - +7%
-2.64ms - +3.16ms
-

nop-update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
11.38ms - 12.21ms-unsure 🔍
-7% - +2%
-0.81ms - +0.29ms
unsure 🔍
-6% - +3%
-0.72ms - +0.42ms
tip-of-tree
tip-of-tree
11.69ms - 12.42msunsure 🔍
-3% - +7%
-0.29ms - +0.81ms
-unsure 🔍
-4% - +5%
-0.42ms - +0.64ms
previous-release
previous-release
11.55ms - 12.33msunsure 🔍
-4% - +6%
-0.42ms - +0.72ms
unsure 🔍
-5% - +3%
-0.64ms - +0.42ms
-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
35.36ms - 36.69ms-unsure 🔍
-2% - +3%
-0.88ms - +0.97ms
unsure 🔍
-2% - +3%
-0.85ms - +0.93ms
tip-of-tree
tip-of-tree
35.34ms - 36.61msunsure 🔍
-3% - +2%
-0.97ms - +0.88ms
-unsure 🔍
-2% - +2%
-0.87ms - +0.86ms
previous-release
previous-release
35.40ms - 36.57msunsure 🔍
-3% - +2%
-0.93ms - +0.85ms
unsure 🔍
-2% - +2%
-0.86ms - +0.87ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
76.74ms - 80.36ms-unsure 🔍
-3% - +3%
-2.55ms - +2.48ms
unsure 🔍
-3% - +3%
-2.62ms - +2.30ms
tip-of-tree
tip-of-tree
76.85ms - 80.33msunsure 🔍
-3% - +3%
-2.48ms - +2.55ms
-unsure 🔍
-3% - +3%
-2.53ms - +2.29ms
previous-release
previous-release
77.05ms - 80.38msunsure 🔍
-3% - +3%
-2.30ms - +2.62ms
unsure 🔍
-3% - +3%
-2.29ms - +2.53ms
-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
33.44ms - 34.37ms-unsure 🔍
-1% - +2%
-0.47ms - +0.72ms
unsure 🔍
-2% - +2%
-0.85ms - +0.55ms
tip-of-tree
tip-of-tree
33.41ms - 34.15msunsure 🔍
-2% - +1%
-0.72ms - +0.47ms
-unsure 🔍
-3% - +1%
-0.91ms - +0.37ms
previous-release
previous-release
33.53ms - 34.57msunsure 🔍
-2% - +2%
-0.55ms - +0.85ms
unsure 🔍
-1% - +3%
-0.37ms - +0.91ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
546.46ms - 556.59ms-unsure 🔍
-2% - +1%
-11.03ms - +5.21ms
unsure 🔍
-1% - +2%
-3.10ms - +11.62ms
tip-of-tree
tip-of-tree
548.08ms - 560.78msunsure 🔍
-1% - +2%
-5.21ms - +11.03ms
-unsure 🔍
-0% - +3%
-1.13ms - +15.47ms
previous-release
previous-release
541.92ms - 552.61msunsure 🔍
-2% - +1%
-11.62ms - +3.10ms
unsure 🔍
-3% - +0%
-15.47ms - +1.13ms
-

update-reflect

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
555.70ms - 565.96ms-unsure 🔍
-1% - +2%
-7.41ms - +8.44ms
unsure 🔍
-1% - +2%
-4.54ms - +10.72ms
tip-of-tree
tip-of-tree
554.28ms - 566.36msunsure 🔍
-2% - +1%
-8.44ms - +7.41ms
-unsure 🔍
-1% - +2%
-5.70ms - +10.85ms
previous-release
previous-release
552.09ms - 563.39msunsure 🔍
-2% - +1%
-10.72ms - +4.54ms
unsure 🔍
-2% - +1%
-10.85ms - +5.70ms
-

tachometer-reporter-action v2 for Benchmarks

Copy link
Contributor

github-actions bot commented Nov 9, 2023

The size of lit-html.js and lit-core.min.js are as expected.

@AndrewJakubowicz AndrewJakubowicz marked this pull request as draft November 9, 2023 01:07
packages/labs/ssr/src/lib/declarative-style-dedupe.ts Outdated Show resolved Hide resolved
</script>`;
}

getStyleHash(styles: CSSResultOrNative[]): string {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think you need a hash, unless we're trying to make this work over multiple requests. I think you should be able to use stylesheet identity on the server and a simple counter for the id.

packages/labs/ssr/src/lib/lit-element-renderer.ts Outdated Show resolved Hide resolved
packages/labs/ssr/src/lib/declarative-style-dedupe.ts Outdated Show resolved Hide resolved
@AndrewJakubowicz
Copy link
Contributor Author

The primary reason for making this prototype was to measure if there would be any benefit. I've now measured against a page in material-web.dev which uses Lit SSR.

Method:

I npm packed a new eleventy ssr plugin which contained the de-duping styles prototype and ran it on material-web.dev. I visually inspected that the styles were not duplicated and also ensured the page worked great.
Most importantly, I could inspect the file size of the generated catalog.

Test No compression gzip brotli
button.html 993kB 45kB 23kB
button.html (with style-module) 327kB (-67%) 31kB (-31%) 23kB (-0%)

Conclusion:

Brotli is pretty fantastic and handling duplicated text content, and thus de-duping styles provides less benefit than I initially expected. The gzip benefit of 30% is compelling but I'd like feedback to see if this benefit is worth pursuing.

@justinfagnani
Copy link
Collaborator

67% smaller is a lot less to parse, regardless of compression. And it's a lot less for any downstream processing: the server, browser, any transforms or middleware. So we should at least measure first paint, but even if we didn't detect a huge improvement there I think this should still be an option because we can make it semantically correct (with some client-side changes).

@sorvell
Copy link
Member

sorvell commented Nov 15, 2023

IMO, the main reason to add this feature is to make the styling behave as authored, using adopted stylesheets.

The performance testing is mostly important to ensure it doesn't regress performance, but if it makes FCP faster, that's great.

@AndrewJakubowicz AndrewJakubowicz changed the title [WIP] prototype of de-duping styles [lit-labs/ssr] Shared and de-duplicated declarative styles utility Nov 17, 2023
packages/labs/ssr/src/test/lib/render-lit_test.ts Dismissed Show dismissed Hide dismissed
packages/labs/ssr/src/test/lib/render-lit_test.ts Dismissed Show dismissed Hide dismissed
@AndrewJakubowicz AndrewJakubowicz marked this pull request as ready for review November 22, 2023 00:31
@AndrewJakubowicz
Copy link
Contributor Author

AndrewJakubowicz commented Nov 22, 2023

@sorvell I've addressed your great feedback. This change still needs docs, however I'd love a re-review now that the logic has been fixed and works semantically correctly.

Thank you!

* for a page, and use `emitCustomElementDeclarationOnce` to insert the script
* tag helper before any styles are encountered.
*/
export class DeclarativeStyleDedupeUtility {
Copy link
Member

Choose a reason for hiding this comment

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

Nit: "Dedupe utility" kinda sounds somehow less professional to me (I can't say why). How about "DeclarativeStyleModuleShim"? Feel free to ignore.

</script>`;
}

private getStyleHash(style: CSSResultOrNative): number {
Copy link
Member

Choose a reason for hiding this comment

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

This isn't really a hash anymore. getStyleId maybe?


*renderDedupedStyles(styles: CSSResultOrNative[]): RenderResult {
for (const style of styles) {
const styleHashId = this.getStyleHash(style);
Copy link
Member

Choose a reason for hiding this comment

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

Just styleId here?

* there should be only one utility instantiated which must be shared between multiple SSR
* renders.
*/
dedupeStyles?: DeclarativeStyleDedupeUtility;
Copy link
Member

Choose a reason for hiding this comment

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

I'm trying to think of the benefit of the user supplying an instance here, versus just making this option boolean and SSR just using a global instance. Maybe it's problematic if we're rendering across VM modules where we'd need to provide the same instance.

yield '<style>';
for (const style of styles) {
yield (style as CSSResult).cssText;
if (renderInfo.dedupeStyles) {
Copy link
Member

Choose a reason for hiding this comment

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

Should this be an instanceof check to make sure we have the right thing? I don't think we validate the option anywhere in case user isn't using TypeScript and passes in something wrong.

// Script tags do not execute when used in innerHTML. So instead
// re-attach them to the DOM so they can execute. This must be done
// here, so it's early enough for the test to be able to assert DOM.
// These tests must use `expectMutationsOnFirstRender: true`.
Copy link
Member

Choose a reason for hiding this comment

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

Let's add that the script tags we care about executing are mainly the declarative style module for future readers. It is only for tests, but I assume we don't have any test that assert that script tags here won't run, right? It's meant to be inserting raw HTML that the server sends so should be fine.

},
],
expectMutationsOnFirstRender: true, // The test setup manually attaches the de-dupe script tag.
stableSelectors: ['dedupe-constructable-stylesheet'],
Copy link
Member

Choose a reason for hiding this comment

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

should this be 'dd-constructable-stylesheet'?

</style><lit-ssr-style-dedupe style-id="3" style="display:none;"></lit-ssr-style-dedupe><!--lit-part--><!--/lit-part--></template></test-static-styles-array><test-static-styles-array><template shadowroot="open" shadowrootmode="open"><lit-ssr-style-dedupe style-id="1" style="display:none;"></lit-ssr-style-dedupe><lit-ssr-style-dedupe style-id="3" style="display:none;"></lit-ssr-style-dedupe><!--lit-part--><!--/lit-part--></template></test-static-styles-array><!--/lit-part-->`
);
});

Copy link
Member

Choose a reason for hiding this comment

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

Could we test that sharing the same instance of the dedupe utility allows deduping across multiple render calls?

@marcmalerei
Copy link

hi, i am very interested in this function. is there a plan when this will be released?

@marcmalerei
Copy link

@AndrewJakubowicz, can you provide any updates on this?

@prashantpalikhe
Copy link

It would be great to be able to opt in to this feature. We use a security package that parses the HTML and adds nonces to any script/links etc. And right now, the HTML size is really a problem there. Would be amazing to have no repeated styles.

@marcmalerei
Copy link

Hi @AndrewJakubowicz ,

I hope this message finds you well. We are currently working on a project that heavily relies on the Lit framework, and we have encountered a challenge that we believe could be addressed by this pull request.

Context and Need:
Our project involves rendering pages statically, and as it stands, the current approach to styles in Lit is proving to be a significant bottleneck. Specifically, the inlined styles within each statically rendered component are causing our pages to bloat, resulting in increased load times and negatively impacting the overall performance and user experience.

It would be great if we can use this feature in a near future.

Kind Regards

Marcel

@prashantpalikhe
Copy link

@marcmalerei do you comrpess the HTML? gzip/brotli should compress these duplicate styles really well and significantly reduce the performance overhead.

@marcmalerei
Copy link

@marcmalerei do you comrpess the HTML? gzip/brotli should compress these duplicate styles really well and significantly reduce the performance overhead.

@prashantpalikhe, yes we do but if you have hundreds of the same component it still leads to a bloat of html. is there any progress here?

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.

6 participants