WICG / webcomponents

Web Components specifications
Other
4.36k stars 370 forks source link

Declarative CSS Module Scripts #939

Open justinfagnani opened 3 years ago

justinfagnani commented 3 years ago

Declarative shadow roots and constructible stylesheets need to be made to work together so that stylesheets added to shadow roots can be round-tripped through HTML serialization and deserialization.

As of now, SSR code will have to read a shadow root's adopted stylesheets and write them to HTML as <style> tags. While this will style they shadow roots correctly on first render, it results in bloated a HTML payload and breaks the shared stylehseet semantics. Hydration code would have to deduplicate <style> tags and create and attach constructed stylesheets in order to reconstruct the origin DOM structure.

To solve this we need a serialized for of constructible stylesheets. These are stylesheets that do not automatically apply to any scope, but can be associated and applied by reference.

Requirements:

  1. Ability to serialize a constructed stylesheet to HTML
  2. Styles don't automatically apply to the document or any shadow root
  3. Styles are able to be associated with declarative shadow root instances
  4. To support streaming SSR, the styles must be able to be written with the first scope that uses it and referenced in later scopes.

One idea is to add a new type for <style> tags that accepts CSS text and creates a new constructed stylesheet:

  <style type="adopted-css" id="style-one">
    :host {
      color: red
    }
  </style>

This style can then be associated with a declarative shadow root:

  <my-element>
    <template shadowroot="open" adopted-styles="style-one">
      <!-- ... -->
    </template>
  </my-element>

Having a type other than text/css means that the styles won't apply to the document scope even in older browsers. It's also what allows it to have an adoptable CSSStyleSheet .sheet, which replace() works on as well.

One problem that immediately come up is scopes and idrefs. If a declarative stylesheet is only addressable from within a single scope, it's no better than a plain <style> tag because it would have to be duplicated in every scope that uses it. We need some sort of cross-scope idref mechanism. With that we can write the styles with the first scope that uses them, and reference them in later scopes in the document:

  <my-element>
    <template shadowroot="open" adopted-styles="x61h8cys">
      <style type="adopted-css" xid="x61h8cys">
        :host {
          color: red
         }
      </style>
      <!-- ... -->
    </template>
  </my-element>
  <my-element>
    <template shadowroot="open" adopted-styles="x61h8cys">
      <!-- ... -->
    </template>
  </my-element>

Here xid is a stand-in for some kind of cross-scope ID. "x61h8cys" is a stand-in for a GUID or other globally unique value (such as a hash of the style text) that the SSR code would be responsible for generating.

There are other instances where we need cross-scope references, like ARIA and label/input associations. I'm not sure if these cases are similar enough to use the same mechanism or not.

bkardell commented 3 years ago

@justinfagnani is your xid conceptually a DOMTokenList or something similar -- that is, can you import 2? This feels related to at least some of the discussions in #909 - this "idea" at least would at least probably provide a way to achieve use cases for things like those which aren't even 'secret' as much as they are 'shorthand for well-known constructs' like an element that allows markdown, asciimath, or LaTeX and it similar to the thing I created and have used for my own takes on this..

justinfagnani commented 3 years ago

Yes, shadow roots need to be able to reference multiple style sheets.

I don't personally see this as that much related to #909 ("open-styleable" shadow roots). Rather than allowing anything from the outside to reach into a shadow root, this is just attempting to replicate JS references to these objects in HTML. it shouldn't be loosening encapsulation at all.

justinfagnani commented 3 years ago

Another option instead of - or maybe in addition to - cross-scope refs is to allow inlining of all module types, keying them by URL that adds them to the module cache as if they had been imported. This would allow a page to render with the necessary CSS modules, attach them to the correct scopes, and later to load JS modules that import them without double-loading them.

<script> tags with type values that match import assertion types would support a new attribute that gives the specifier/URL. Something like:

<script type="css" specifier="/foo.css">
  :host {
    color: red
   }
</script>

A later import: import styles from '/foo.css' assert {type: 'css'} would load the CSS module script defined by the tag.

Full example:

  <my-element>
    <template shadowroot="open" css-modules="/foo.css /bar.css">
      <script type="css" specifier="/foo.css">
        :host {
          color: red
         }
      </script>
      <!-- ... -->
    </template>
  </my-element>
  <my-element>
    <template shadowroot="open" css-modules="/foo.css /bar.css">
      <!-- ... -->
    </template>
  </my-element>

This would have to be made coherent with import maps, but it would allow a page to be rendered with correct scoped styles w/o FOUC and w/o duplication of CSS text.

Westbrook commented 2 years ago

Being <template shadowroot="open"> creates a shadow root that ID resolution from above does not have access to, should the "expected" version of:

  <my-element>
    <template shadowroot="open" adopted-styles="x61h8cys">
      <style type="adopted-css" xid="x61h8cys">
        :host {
          color: red
         }
      </style>
      <!-- ... -->
    </template>
  </my-element>

Actually be:

  <style type="adopted-css" id="x61h8cys">
    :host {
      color: red
     }
  </style>
  <my-element>
    <template shadowroot="open" adopted-styles="x61h8cys">
      <!-- ... -->
    </template>
  </my-element>

In this wa x61h8cys can be made available for other shadow roots without having to penetrate otherwise encapsulated DOM structures? Or do you think that all style[type="adopted-css"] would be made available globally by default?

How would you make these "declarative CSS modules" available in an imperative context? Should there be API to allow for resolution of the actual Stylesheet object from the x61h8cys identifier so that it can be adopted by shadow roots deeper within the DOM hierarchy or that might not be delivered declaratively?

Inversely, should stylesheets that are registered imperatively have API added by which to associate an ID (a la adopted-styles="x61h8cys") so that they could be leveraged in declarative code?

Westbrook commented 2 years ago

The example of

<script type="css" specifier="/foo.css">
  :host {
    color: red
   }
</script>

Seems like a large change for developers, would it make a shorter step from historic practices to apply this concept via <style> or <link>? E.G:

<style type="module" src="/foo.css">
  :host {
    color: red
   }
</style>

OR

<link rel="stylesheet-module" href="/foo.css" />

In this way we could leverage already existing blocking mechanisms while informing the browser not to apply these styles until adopted. It's possible that link[rel="stylesheet-module"] might fit right into the rel="modulepreload" cache once assert { type: 'css' } hit browsers allowing it to leverage even more pre-existing features.

Question that applies to both of these approaches, do you expect that values applied to adopted-styles or css-modules would resolve lazily? E.G. could I list these style in my declarative shadow DOM but then load those sheets in a non-blocking way and still have them apply to the shadow root?

bicknellr commented 2 years ago

+1, I think the idea of using an attribute to provide a URL / specifier is a really interesting thing to explore because it seems like it would generalize well to basically anything in HTML that describes some other resource and has either inline and external forms. (I'm writing it here as cacheas since I don't think it would really be a specifier anymore? Anyway, the name would need bikeshedding.)

So, not only could you have:

<script type="module" cacheas="./some-path">
export let aString = "Hello, World!"
</script>
<script type="module">
import {aString} from "./some-path";
console.log(aString);
</script>

and

<style cacheas="./some-path">
body::before {
  content: 'Hello, World!';
}
</style>
<link rel="stylesheet" href="./some-path">

But also things like:

<img cacheas="./some-path" src='data:image/svg+xml,<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="120" height="60"><text y="30" x="30">Hello, World!</text></svg>'>
<img src="./some-path">
justinfagnani commented 2 years ago

@mfreed7 if we wanted this to be a more general feature, should we move the issue to HTML?

calebdwilliams commented 2 years ago

Am I reading right that this idea is a form of declarative module blocks? A bit off-topic, but I'm wondering how that could play with import assertions and this concept, seems like a lot of the semantics are the same … 

rniwa commented 1 year ago

Yeah, URL based namespacing is exactly like JS modules. In theory, you should be able to define an inline (e.g. data URI) style inside import maps. So the only additional mechanism needed here is the ability to adopt it.

How about <style type="module" src="foo"></style>.

matthewp commented 1 year ago

@rniwa Would that <style> be resolved synchronously? I just see the word "module" and think async, but this needs to be sync to avoid FOUC.


cc @mfreed7, @justinfagnani what do you think of the idea @rniwa is proposing here?

sashafirsov commented 1 year ago

@justinfagnani , why to limit the adopted-styles concept to the CSS only? It is a "library" concept with common scope as in CSS as in custom components registry and perhaps some other common things like import maps.

I.e. this proposal has to be converted to something like "custom elements library scope".

If agree, the name adopted-styles does not reflect the semantic meaning.

justinfagnani commented 1 year ago

@matthewp @rniwa that seems good to me.

My main ask is for a way to emit a style module at the first use site when streaming server rendering a large tree of components, so that further components can use that same style sheet.

This is needed because some modern streaming SSR approaches do not know what components will be rendered ahead of time, due to request-specific data and data-dependent templating.

My other thought is whether this is a thing that should apply to all types of module scripts - where an inline tag can contain content, but populate the module cache.

nolanlawson commented 1 year ago

I would prefer a syntax on the <template shadowroot> rather than a <style> inside the template. The issue for us (LWC) is that we use adopted stylesheets for pure client-side rendering (for perf reasons), whereas for SSR, since there is no equivalent, we emit an inline <style> which has to be specially handled on the client.

The main perf benefit of adopted stylesheets that I've seen in my testing is just bypassing the DOM and avoiding the cost of creating/inserting a <style> element (since browsers have optimizations for <style>s inside shadow roots). So if we need to insert a <style> element to match SSR, then I predict the perf benefits of adopted stylesheets would go away, and you may as well use an old-school inline <style>.

nolanlawson commented 1 year ago

Update: after discussion, https://github.com/WICG/webcomponents/issues/939#issuecomment-900791467 seems good to me. In SSG (non-streaming) at least, you can hoist the first <script> up into the <head> or something, which resolves the issue with the asymmetry between client-rendered versus server-rendered DOM structure.

keithamus commented 1 year ago

WCCG had their spring F2F in which this was discussed. You can read the full notes of the discussion (https://github.com/WICG/webcomponents/issues/978#issuecomment-1516897276) in which this was discussed, heading entitled "Declarative Adopted Stylesheets".

In the discussion, present members of WCCG reached a consensus that this needs further discussion with implementers.

I'd like to point out that representatives of Apple, Google, and Mozilla were present in the meeting, but no firmer conclusion was made. This issue will likely require implementer feedback.

eddyloewen commented 1 year ago

I'm not sure if this is the right place or if it is more related to #909 (open-stylable). But I would hope that declarative CSS module scripts would also automatically apply to the global document. Like I said in the other issue - most of the time we want to use CSS frameworks like Bootstrap or TailwindCSS and they need to be applied to the global document.

For https://github.com/webtides/element-js/blob/main/src/StyledElement.js we are currently duplicating inlined styles to make them adoptable.

<html>
<head>
<style id="globalStyles">/* all of TailwindCSS... */</style>
</head>
...
const globalStyles = document.getElementById('globalStyles');

class MyElement extends HTMLElement {
    constructor() {
        const shadow = this.attachShadow({ mode: 'open' });
        const sheet = new CSSStyleSheet();
        sheet.replaceSync(globalStyles.textContent);
        shadow.adoptedStyleSheets = [sheet];
    }
}

I hope that it will be possible to share styles between the global document and shadow roots instead of duplicating them.

mohamedmansour commented 7 months ago

This is the only thing stopping me to build a perfect solution. It would be great if we had something like:

External Module

index.html

<link as="style" rel="modulepreload" href="./app.css">
....
<style type="module" href="./app.css">

app.js (when not loaded in main html and dynamically loaded, we want to lazy get the css file)

import('./app.css', { with: { type: 'css' } })
 .then(module => {
    this.shadowRoot.adoptedStyleSheets = [module.default];
});

Internal Module

index.html

<style type="module" id="styles-app">
:host { 
  background-color: red;
}
</style>

app.ts (when it is in the dom, we want to add it to the adoptedStyleSheet)

    this.shadowRoot.adoptedStyleSheets = [document.getElementById('styles-app').content];

That way the DOM parsing would be blocked until the CSS loads.

EisenbergEffect commented 2 months ago

I like the css-modules and specifier approach. It works very well with how one would want to SSR a web component. If css-modules could also support the @sheets idea that would pretty much sum up what I'm looking for.

For example, common, rarely changing CSS could be multiple sheets in a single, cacheable file, while individual component css could be inlined. You might have something like this:

<link rel="stylesheet" href="design-system.css" specifier="/design-system.css">

<my-element>
  <template shadowrootmode="open" css-modules="/design-system.css#typography /my-element.css">
    <script type="css" specifier="/my-element.css">
      :host {
        color: red
       }
    </script>
    <!-- ... -->
  </template>
</my-element>
<my-element>
  <template shadowrootmode="open" css-modules="/design-system.css#typography /my-element.css">
    <!-- ... -->
  </template>
</my-element>

And in JS you could also do this:

import { typography } from "./design-system.css" with { type: "css" };
import styles from "./my-element.css" with { type: "css" };

export class MyElement extends HTMLElement {
  constructor() {
    super();
    // Elided: skip if DSD already exists.
    this.attachShadow({ mode: "open" }).adoptedStyleSheets.push(typography, styles);
  }
}

customElements.define("my-element", MyElement);

This should also work well for component authors who want to distribute their entire design system, providing a single, external CSS file that their customers can easily link, edit, or replace, without component code alterations needed.

mayank99 commented 2 months ago

Can someone share the use cases for this thing?

I understand the theoretical need for a serializable form of adopted stylesheets, but I have many concerns with the direction this is going.

What exactly does this provide that a regular <link rel="stylesheet"> inside <template> doesn't? Is it just for reducing repetition of inline <style>s (which could also be done using a data-uri, as @rniwa mentioned earlier)?

EisenbergEffect commented 2 months ago

Off the top of my head, this provides:

I believe some use cases have already been listed, but:

mayank99 commented 2 months ago

@EisenbergEffect I appreciate your comment, but most of these things should be covered by <link rel="stylesheet"> and @sheet (which can be used together with fragment URLs, according to this resolution), and some of the points feel subjective and abstract. Today, <link rel="stylesheet"> is already a performant way to handle styling in DSD, which is why I'm asking specifically what this proposal adds on top of it.

You mentioned modules, but this proposal doesn't really address that (hence the name "CSS module scripts"). True CSS modules would need to be usable within CSS itself, even before HTML or JS is involved. I'd imagine that CSS modules would allow a @sheet to be @imported into another CSS file and applied there.

It's also worth pointing out that DSD still requires repetition of the shadow markup today. When we get HTML modules (and/or DCE), it would reduce this markup repetition. Since <style>/<link> is just markup, HTML modules basically solve this problem of repeated styles. This is also why I think it makes more sense to pursue HTML modules first.

Lastly, I want to highlight https://github.com/w3c/csswg-drafts/issues/10013, which was recently greenlit. It may sound unrelated, but in my mind it opens up the potential for these two syntaxes to refer to the exact same stylesheet instance:

<link rel="stylesheet" href="design-system.css#button">
import buttonStyles from "design-system.css#button" with { type: "css" };
justinfagnani commented 2 months ago

The main purpose of this propose is the same as declarative shadow DOM: to be able to accurately serialize a valid DOM tree to HTML.

Right now there's no way to properly serialize adoptedStyleSheets. During SSR you can convert them to <style> tags, but you can't deduplicate them, so you end up with potentially hundreds or thousands of duplicate stylesheets. Converting the to <link> tags would likewise result in potentially hundreds of HTTP requests. Both of those workarounds break the semantics of constructible stylesheets.

mayank99 commented 2 months ago

The <style> and <link> tags may be duplicated in the HTML, but they will be optimized by all browsers to point to a single stylesheet instance (similar to adopting a constructed stylesheet). And the HTML size is usually not an issue because it compresses well (and will be completely deduplicated with HTML modules).

justinfagnani commented 2 months ago

It's still non-optimal and breaks semantics. Constructible stylesheets actually share a single stylesheet instance, something that doesn't happen with <style> or <link>.

There is still a performance hit from so many <style> tags even with deduplication - this was part of the motivation for constructible stylesheets in the first place. The HTML parser still has to consume the bytes of each tag and check the cache. There's still an extra element in the tree. And compression doesn't completely make up for the extra bytes.

<link> tags would be pretty cumbersome to implement in an SSR system because you have to invent a URL scheme for them. It would be ok with CSS modules, but won't work well for constructible stylesheets created from inline CSS.

Westbrook commented 2 months ago

Lastly, I want to highlight w3c/csswg-drafts#10013, which was recently greenlit. It may sound unrelated, but in my mind it opens up the potential for these two syntaxes to refer to the exact same stylesheet instance:

<link rel="stylesheet" href="design-system.css#button">
import buttonStyles from "design-system.css#button" with { type: "css" };

@mayank99 has there explicitly been discussion to date that implied that files included via rel="stylesheet" will make it onto the module graph so that they are not requested multiple times. I'd love to join that conversation! The way that opens an alternate version of the above where in a file full of @sheet fragments could be included via rel="stylesheet":

<link rel="stylesheet" href="design-system.css">

While being added to the module graph so that extended usage of the same in JS would essentially be modulepreloaded for use like:

import { button as buttonStyles } from "design-system.css" with { type: "css" };
import element from '/my-element.css' with { type: 'css' };

class El extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.adoptedStyleSheets = [buttonStyles, elementStyles];
  }
}

Would be awesome!

Passing that same theoretical approach to developers using DSD would be important to ensure that they can share learned concepts between the HTML-first or JS-first implementations of a shadow root.

<my-element>
  <template shadowrootmode="open" adoped-style-sheets="/design-system.css#button /my-element.css">
    <!-- ... -->
  </template>
</my-element>

Expanding this to include inline <style> elements completes the picture, no matter how you prefer to write you application:

<link rel="stylesheet" href="design-system.css">

<style type="module" href="/my-element.css">
   // styles
</style>

<my-element>
  <template shadowrootmode="open" adoped-style-sheets="/design-system.css#button /my-element.css">
    <!-- ... -->
  </template>
</my-element>

<script type="module">
  import { button as buttonStyles } from "design-system.css" with { type: "css" };
  import element from '/my-element.css' with { type: 'css' };

  class El extends HTMLElement {
    constructor() {
      super();
      this.attachShadow({mode: 'open'});
      this.shadowRoot.adoptedStyleSheets = [buttonStyles, elementStyles];
    }
  }
</script>

😍

Editors note: APIs and attribute name FPO only!

mayank99 commented 2 months ago

has there explicitly been discussion to date that implied that files included via rel="stylesheet" will make it onto the module graph so that they are not requested multiple times.

@Westbrook Not yet, but that's one of the things I'm hoping will be discussed in CSSWG when we talk about adoptable non-constructed stylesheets and @sheet and such. I think these discussions need to happen in parallel with (if not before) declarative adopted stylesheets.