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

Declarative CSS Module Scripts #939

Open
justinfagnani opened this issue Aug 16, 2021 · 28 comments
Open

Declarative CSS Module Scripts #939

justinfagnani opened this issue Aug 16, 2021 · 28 comments

Comments

@justinfagnani
Copy link
Contributor

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
Copy link

@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
Copy link
Contributor Author

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
Copy link
Contributor Author

justinfagnani commented Aug 18, 2021

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
Copy link
Collaborator

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
Copy link
Collaborator

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
Copy link

+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
Copy link
Contributor Author

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

@calebdwilliams
Copy link

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
Copy link
Collaborator

rniwa commented Oct 6, 2022

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
Copy link

@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
Copy link

sashafirsov commented Jan 19, 2023

@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
Copy link
Contributor Author

@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
Copy link

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
Copy link

Update: after discussion, #939 (comment) 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
Copy link
Collaborator

WCCG had their spring F2F in which this was discussed. You can read the full notes of the discussion (#978 (comment)) 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
Copy link

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
Copy link

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
Copy link
Contributor

EisenbergEffect commented Jun 27, 2024

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
Copy link

mayank99 commented Jun 27, 2024

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
Copy link
Contributor

EisenbergEffect commented Jun 27, 2024

Off the top of my head, this provides:

  • Reduced repetition of inline styles.
  • Proper integration with the module system.
  • A way to avoid a FUOC.
  • Improved perf over today's techniques.
  • A match to the way people build (and want to build) web components and handle DSD today.
  • A more consistent way for all component authors to provide CSS for components.
  • An incremental step towards a fully declarative model for web components.
  • Parity between imperative and declarative APIs.
  • Modularity in CSS, conceptually matching JS Module Declarations.
  • Alignment with other proposals around HTML modularity.

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

  • As a component library author, I want to distribute the CSS of my design system as a single file to my end users, while my web components and DSD individually import only the relevant sheets. I want my customers to be able to use the CSS the same in DSD and imperative code.
  • As a site/app dev, I want to control how my CSS is bundled and served, making whatever optimizations I need based on my own metrics and priorities, without needing to re-architect my components.
  • As a framework author, I want to be able to perf optimize how CSS resources and DSD are being generated on the fly, based on heuristics and usage patterns.
  • As a component author, I want to leave CSS completely in the realm of the browser's native code, without needing it to pass into user code, enabling the browser to better optimize my components regardless of whether they are implemented in JS or represented in DSD/DCE.

@mayank99
Copy link

mayank99 commented Jun 27, 2024

@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 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" };

@justinfagnani
Copy link
Contributor Author

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
Copy link

mayank99 commented Jun 27, 2024

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
Copy link
Contributor Author

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
Copy link
Collaborator

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
Copy link

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.

@Westbrook
Copy link
Collaborator

Related proposal from the Edge team: https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ShadowDOM/explainer.md

@robglidden
Copy link

@KurtCattiSchmidt, @dandclark, and Tien Mai have provided an excellent analysis/proposal loosely titled "Declarative shadow DOM style sharing".

Framed as "a starting point for engaging the community and standards bodies in developing collaborative solutions fit for standardization", as I understand it, in my words it envisions:

  • a declarative form of ES module imports/exports
  • for importing resources (CSS, HTML & other)
  • into a declarative shadow DOM
  • via the module graph

Many thanks for potentially moving forward multiple issues like #909, #910, #939, #10176 and more.

I have made some suggestions in this slide deck with examples on github.

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

No branches or pull requests