WICG/webcomponents

Cascading Style Sheet module scripts

justinfagnani opened this issue Β· 116 comments

In addition to HTML Modules, the ability to load CSS into a component definition is an important capability that we're currently lacking. Loading CSS is probably a more important use case judging by the popularity of CSS loaders in JavaScript bundlers.

Currently, styles are usually either defined inline with HTML templating or JSX or loaded with various JS Bundler CSS Loaders. The CSS loaders often have global side-effects like appending a <style> tag to the document, which does not generally work well with the style scoping of Shadow DOM.

I propose that we add Cascading Style Sheet module scripts to the platform, allowing CSS to be imported directly by JavaScript:

import styles from './styles.css';

Exports

The semantics for Cascading Style Sheet module scripts can be very simple, and combined with Constructable Stylesheets allow the importer to determine how the CSS should be applied to the document.

To start with, the only export of a Cascading Style Sheet module script would be a default export of the CSSStyleSheet object. This can then simply be added to document.styles or shadowRoot.styles:

import styles from './styles.css';

class MyElement extends HTMLElement {
  constructor() {
    this.attachShadow({mode: open});
    this.shadowRoot.adoptedStyleSheets = [styles];
  }
}

Additional Features

Other userland CSS module systems often have more features, like the ability to import or export symbols that are defined in the module. ie:

import (LitElement, html} from 'lit-element';
import styles from './styles.css';

class MyElement extends LitElement {
  constructor() {
    this.attachShadow({mode: open});
    this.shadowRoot.adoptedStyleSheets = [styles];
  }

  render() {
    return html`<div class=${styles.classes.exampleClass}></div>`;
  }
}

These features may be very useful, but they can be considered for addition to the CSSOM itself so that they're exposed on CSSStyleSheet and available to both Cascading Style Sheet module scripts and styles loaded via <style> and <link> tags, or constructed from a string.

Polyfilling

It's not easy to polyfill a new module type, but build-time tools can create JavaScript modules that adhere to the Cascading Style Sheet module script interface.

.exampleClass {
  color: red;
}

Can be compiled to:

// create a container and scope to hold a style sheet:
const container = document.createElement('div');
const shadowRoot = container.attachShadow({mode: 'open'});

// create a <style> element to add css text to
let styleElement = document.createElement('style');

// add the styles
styleElement.textContent = String.raw`
.exampleClass {
  color: red;
}
`;

// add the <style> to the document so it creates a StyleSheet
shadowRoot.appendChild(styleElement);

const stylesheet = styleElement.sheet;
export default stylesheet;

edit: updated to the actually Constructible StyleSheet API.
edit: updated to refer to the feature by "Cascading Style Sheet module script"

To clarify that this would be useful for non-Shadow DOM use cases, especially for those who aren't familiar with the Constructible StyleSheet Objects proposal, here's how you would implement the side-effectful style of loading common today:

import styles from './styles.css';
document.adoptedStyleSheets = [...document.adoptedStyleSheets, styles];

Modules that currently use CSS loaders that write to global styles would just have to add the document.adoptedStyleSheets = [...document.adoptedStyleSheets, styles] statement so rigger the side-effect.

Loaders that modify selectors could still work at build time, or runtime with a call to mutate the stylesheet:

import styles from './styles.css';
import 'styleScoper' from 'x-style-scoper';

// extract original class names, and rewrite class names in the StyleSheet
const classNames = styleScoper(styles);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, styles];

// Use the rewritten class names
`<div class="${classNames.exampleClass}"></div>`

edit: updated to use current Constructible Stylesheets API

So the idea is just that the browser would recognize .css files (or text/css files, something like that) and automatically parse and stuff them into a CSSStyleSheet object for you?

@tabatkins yep. Though it'd be triggered off the mime-type, as I think WASM modules are proposed to do. (edit: uh, yes, you already said text/css :) )

I think this should be pretty simple. One question I didn't list is when to resolve the CSS Module in the presence of @import, and what to do with errors in loading @imports. I think you have similar open questions with Constructible StyleSheets.

I like this as a feature, but would mean that files containing custom elements must have a common structure in order to be reused (the path to './styles' will not always be consistent if the component is really being reused across apps). I do think this is needed, but would prefer a different entry point to add the styles such that any given file could simply export it's class and be consumed in another which would add the styles (I won't go into my thoughts on #468, but the custom element init object seems to make a lot of sense) so that the consumer of the component can point to the right files and the right structure for their app.

the path to './styles' will not always be consistent if the component is really being reused across apps

I don't quite understand this. The import specifier is a URL like any other import, and can be a relative or absolute URL to a file anywhere a JS module could be. There won't need to be any additional conventions that I can see.

Right, but two different apps using the same component will need similar structures or to change the component file's code for their app. If I publish a component through NPM and it's consumed by one app that way and another pulls from GitHub, but has different locations for scripts/CSS, there might be issues. Ultimately it's not that big of a deal, but something that should be considered.

I don't think there's anything to consider in this proposal. Component distribution is another issue entirely, one that might be solved by webpackage or something else. For the time being we can only distribute components as multiple files. In which case if someone hands you a JS script and a CSS module as siblings you should not be under the impression that you can then split those up in different locations on your server.

This could probably be implemented as a JS library if we had a more generic way to hook in to import. See https://github.com/AshleyScirra/import-as-and-html-modules for a POC which covers importing styles.

@AshleyScirra I think that idea is interesting but probably has a lot more discussion needed before it's ready. I see a number of problems with it as is. For example, what exactly is the Type? In some cases it seems to be an existing global object and others something new. Is SelectorAll going to be a new global? Would it have a purpose outside of this use case? And what happens if the global doesn't exist? If do import something from "./foo.txt" as NotExists; do I get a ReferenceError at parse time or what exactly? What about the imported value, is it expected to always be an instanceof the Type? Does this preclude having named exports then? What's the relation to this idea vs. some service worker approach?

Not trying to downplay your work, it's an interesting approach and I like the simplicity of using a symbol. Having observed what happened with previous attempts at defining loader hooks I would really hope browser vendors don't pass on a chance at implementing a simple and practical solution such as the CSS module idea from this issue in favor of waiting on something more generic, but much more complex.

For example, what exactly is the Type?

It's any object that has a Symbol.importer property. They are all library-implemented, they don't have to be "real" types. This should answer most of your questions (e.g. no new globals; nothing to do with instanceof; etc).

@AshleyScirra I think we should collect discussion of a more generic "import as" mechanism in it's own issue. I like the idea, but I think there's a place for extensible loading and several built-in module types other than just JavaScript. Browsers natively understand (including prescanning and parsing) JS, HTML and CSS. It makes sense for the module system to understand them as well.

jhnns commented

Regarding the import as proposal:

It doesn't belong to this particular discussion, but I think the question arises naturally when you're discussing about a way to import other things into JS. We (the webpack team) already had a long discussion when we tried to streamline this with browserify. It's kind of limiting if the platform defines how these assets are imported. In the long run, I'd really appreciate if there was a way to import assets into JS while allowing the importer to define the how. Kudos to @AshleyScirra πŸ‘

Regarding this proposal:

However, it still makes sense to define a sensible default behavior for common things like importing CSS. It also helps us to get a better understanding of the domain and maybe also helps to speed up the development of these generic import proposals.

I really like @justinfagnani 's proposal because it doesn't introduce new concepts. It looks very consistent with other parts of the web components API, like the shadowRoot.

Speaking as an application developer, it would be very useful to have a way to reference CSS class names from JavaScript. This makes it easier for bundlers and other tools to statically analyze the usage of class names. It allows them to remove unused class names. I think this makes current CSS-in-JS solutions like styled-components and the CSS modules project so appealing: it just plays well with existing tools because it makes it statically analyzable. It also makes things like Sass and Less obsolete as we can use JavaScript for that.

There is also a different approach:

Instead of standardizing CSS module imports, we could also make the CSSOM better approachable from the JS-side. The Constructable Stylesheet Objects proposal is a step in the right direction.

Consider we'd allow the developer to write code like this:

import uuid from "uuid";
import {regularPadding} from "../styles/paddings.js";
import {regularMargin} from "../styles/margins.js";
import {modularScale} from "../styles/typography.js";

const hamsterImage = new URL("../hamsters.jpg", import.meta.url);

export const modal = `modal-${uuid}`;
export const header = `header-${uuid}`;

export default CSSStyleSheet.parse`
.${modal} {
    background-image: url(${hamsterImage});
    padding: ${regularPadding};
}

.${header} {
    font-size: ${modularScale(2)};
}

.${modal} > ${header}:not(:last-child) {
    margin-bottom: ${regularMargin};
}
`;

Inside the component, you'd do:

import (LitElement, html} from "lit-element";
import styles, {modal, header} from "./styles.js";

class MyElement extends LitElement {
    constructor() {
        this.attachShadow({mode: open});
        this.shadowRoot.moreStyleSheets.push(styles); // push() doesn't actually exist yet
    }

    render() {
        return html`<section class="${modal}">
        <h1 class="${header}">
            This is a modal
        </h1>
    </section>`;
    }
}

You could also put everything into one file, leaving that decision up to the developer.

With that approach, we wouldn't need to standardize this and still had all the flexibility the ecosystem needs. Tools like bundlers are already able to analyze that code. They could even remove styles that are not used anymore (also this is a more complex operation with my example).

The developer could also decide to wrap the class names with a library:

import className from "some-library";

export const modal = className("modal");

This allows the library to track all used class names during render. This is how other CSS-in-JS libraries enable developers to include the critical styles on server render. Of course, this approach has several caveats, but the point is that this is all solvable in userland without specification.

One of the things to be noted about using imported stylesheets is document.adoptedStyleSheets and shadowRoot.adoptedStyleSheets (previously moreStyleSheets) can only accept CSSStyleSheets that belong to the same document tree (see WICG/construct-stylesheets#23), and in constructed stylesheets, the url of the stylesheet is the url of the document it is constructed on (WICG/construct-stylesheets#10).

So my question is, what should be the document and url of an imported stylesheet?

@TakayoshiKochi it would help if you could elaborate on how you reached the single-document conclusion. If we allow reuse across shadow trees it seems reuse across documents isn't necessarily problematic either.

There is some state obtained from the document I suppose (e.g., quirks, fallback encoding), but we could simply set those to no-quirks and UTF-8 for these new style sheets.

(The URL of an imported style sheet should be the (eventual) response URL btw.)

@annevk I think there was also a problem with which fetch groups the stylesheet will be in if it's constructed in one Document and used in another (see WICG/construct-stylesheets#15)

Hmm yeah, it'd be nice if we had those formally defined. It seems in this case it should reuse the fetch group used by the JavaScript module.

It does seem that ownerNode would also be problematic, so not being 1:1 with documents might have a lot of gotchas that would need to be thought through. Having said that, ownerNode would also be problematic for reuse across shadow roots?

It does seem that ownerNode would also be problematic, so not being 1:1 with documents might have a lot of gotchas that would need to be thought through. Having said that, ownerNode would also be problematic for reuse across shadow roots?

In the case of constructed stylesheets, the ownerNode is null and we have not encountered any problems yet with reusing it across shadow roots, as long as it is in the same document tree.

For imported stylesheets, what if we do this: ownerNode to null, and it can only be used in the document tree where the script is run on.

It sounds reasonable, except I'd still like to see justification for the document restriction.

It sounds reasonable, except I'd still like to see justification for the document restriction.

Oh, sorry - I misunderstood your previous comments. I'm actually not quite familiar with fetch groups (do you know where can I look for background reading? I tried looking for the specs but no luck...)

But if the imported stylesheet can just reuse the fetch group used by the JavaScript module then that sounds fine to me to make it usable in many document trees. For constructed stylesheet case, let's continue discussion on WICG/construct-stylesheets#15.

@rakina now it's my turn to apologize. The high-level concept is documented at https://fetch.spec.whatwg.org/#fetch-groups, but it requires integration with HTML and that isn't done. It's effectively a collection of all fetches a document/global is responsible for so they can be managed together in case the document/global goes away (e.g., a tab is closed). In Gecko this is called a "load group". I suspect Chromium has a similar concept.

@annevk Thank you for the explanation!! From the spec I'm not so sure which things have their own fetch groups and how fetch records might interact with each another.

From @bzbarsky's first comment in WICG/construct-stylesheets#15,

As a concrete question, if I create a sheet from document A's window, and am using it in documents B and C, and B matches a rule with one background image while C matches a rule with another, which load events, if any, are blocked by the resulting image loads.

Will that be a problem too if we end up using the JavaScript module's fetch group? If not, then can we also apply similarly to constructable stylesheet's case?

That's not a problem, it's a question illustrating that if you don't define the fetch group it's unclear what the answer is. (The answer might also be somewhat unclear since not all user agents might consider images referenced from style sheets critical subresources (see the HTML Standard).)

I didn't read all the replies above, but for

import styles from './styles.css';

it can be easily implemented by server side, because browsers only recognize file types using its Content-Type ( aka MIME ) regardless of its file extension.

I did this before:

// node
const handleTypes = {
  css(content: string, request: http.ServerRequest, response: http.ServerResponse) {
    if (request.headers.accept.startsWith('text/css')) {
      // <link rel="stylesheet" href="/path/to/file.css" />
      response.writeHead(200, { 'Content-Type': 'text/css' });
      return content;
    }
    // import '/path/to/file.css';
    response.writeHead(200, { 'Content-Type': 'application/javascript' });
    return 'document.head.insertAdjacentHTML("beforeEnd", `<style>' + content + '</style>`)'; // or create a style element and `export default`
  },
}

@CarterLi that's interesting, but we'd first have to get browsers to send an Accept header other than */* for imports.

@CarterLi that's interesting, but we'd first have to get browsers to send an Accept header other than / for imports.

What about writing import styles from "./style.cssm" or import styles from "./style.css?module"

What about writing import styles from "./style.cssm" or import styles from "./style.css?module"

I don't understand the question.

Right now browsers only send Accept: */* for requests from imports. That'll need to change if servers are going to send .css vs .css.js depending on browser support.

What about writing import styles from "./style.cssm" or import styles from "./style.css?module"

I don't understand the question.

Right browsers only send Accept: */* for requests from imports. That'll need to change if servers are going to send .css vs .css.js depending on browser support.

Yes browsers only send Accept: */* for requests from imports, but they still send Accept: text/css for requests from <link rel="stylesheet">. Servers can decide what to send depending on what the Accept header is.

What I mean is that if you don't like Accept: */*, you can send request like style.cssm ( m means module ). Servers can decide what to send based on its file extension.

Could polyfill via ServiceWorker too, or import maps (though awkward).

I'm mostly here to drop a firm +1 on the idea. I threw a proposal together that turned out to be exactly what Justin is pushing for here, just using the current version of Constructable Stylesheets.

import sheet from './style.css';

// global CSS:
document.adoptedStyleSheets = [sheet];

const node = document.createElement('div');
const shadow = node.attachShadow({ mode: 'open' });
// scoped CSS:
shadow.adoptedStyleSheets = [sheet];

// Updates! (propagates out to all affected trees)
sheet.insertRule('.foo { color: red; }');

// "hot CSS replacement": (to be taken with healthy dose of salt)
module.hot.accept('style.css', req => {
  sheet.replace(req('style.css'));
});

Update: here's a prototype that abuses Service Worker to rewrite import "*.css" to use constructable stylesheets (and a poorlyfill for the latter API):

https://codesandbox.io/s/xrr68044ww

Just got directed towards this proposal. Overall I like it; it fills an important gap in the platform, and anything that allows us to avoid storing things that aren't JavaScript inside JavaScript files is a good thing.

I am concerned about the one-stylesheet-per-file thing though. With JavaScript modules, we can optimise apps by concatenating them into coarse-grained code-split chunks, which generally results in better performance than serving many small modules. It stands to reason that the performance considerations are similar for CSS modules, but as far as I can tell it's not possible to 'concatenate' multiple .css files under this proposal. I've drawn up a gist explaining what I mean in more detail.

The current proposal for allowing "concatenation", not just of CSS but of other file types as well, is https://github.com/WICG/webpackage.

As a possible layering on modules, I wrote up an issue for CSS "references": w3c/csswg-drafts#3714

They would serve some of the same purposes as exporting class names.

https://github.com/chanar/lit-scss-vaadin

// Import the LitElement base class and html helper function
import { LitElement, html } from 'lit-element';
import '@vaadin/vaadin-button/vaadin-button.js';

import styles from './my-element.scss';

// Extend the LitElement base class
class MyElement extends LitElement {
  static get properties() {
    return {
      prop1: { type: String }
    };
  }

  render() {
    return html`
      <style>
        ${styles}
      </style>

      <div class="wrap">
        <vaadin-button theme="primary your-custom-overwrite" @click="${this.fireClickEvent}">
          <slot></slot>
        </vaadin-button>
      </div>
    `;
  }

  fireClickEvent() {
    alert('Yesss!!');

    this.prop1 = 'prop1 now has a value';
  }
}

// Register the new element with the browser.
customElements.define('my-element', MyElement);

A question that I don't see resolved in this thread is whether CSS modules should be leaf nodes in the module graph. That is to say, how do we handle @import url("foo.css") in a CSS module? I see three possibilities:

  1. CSS Modules are leaf modules, and don't allow @import references (following the example of replaceSync in constructable stylesheets).
    module.
  2. CSS modules are leaf modules; prior to creating the module record for a CSS module, load the full @import tree of its stylesheet and if any fail to resolve, treat it as a parse error for the module.
  3. CSS Modules are non-leaf (cyclic) modules. Process a CSS Module's @imported stylesheets as its requested module children in the module graph, with their own module records. They will Instantiate and Evaluate as distinct modules.

I don't think we want to do option 1 as that seems too restrictive.
One of the main differences I see between options 2 and 3 is that 3 implies that if a CSS file is @imported multiple times for a given realm, each import would share a single CSSStyleSheet between them (because a module is only instantiated/evaluated once for a given module specifier). This has the advantage that if a developer includes a stylesheet multiple times by mistake or because of shared CSS dependencies, there will be performance and memory gains in sharing it. On the other hand, this is a divergence from the existing behavior where multiple @imports of the same .css file each come with their own CSSStyleSheet. I'm not sure how developers count on the existing behavior and whether it would be disruptive to make CSS modules work differently.

Performance-wise I don't think there is much difference between 2 and 3 (other than from only evaluating redundant @imports once). In both cases, we can fetch all the CSS files (and the rest of the module graph) in parallel, and we can't move on to graph Instantiation until all the CSS files (and other modules) are fetched.

Does anyone have thoughts on this? Any other considerations I'm missing?

I think this should go with behavior 2, as that's the behavior you'd get if, instead of using import, you just ran fetch("foo.css").then(r=>{const s = new CSSStyleSheet(); return s.replace(r.text);});. It also causes the minimal changes to the normal mechanics of nested stylesheets, with each @import rule continuing to get its own unique stylesheet object.

I'm a developer and not an implementer, I would prefer option 3. Not being able to declare dependencies of a stylesheet is one of the big reasons people use preprocessors. I'd be fine with a new syntax for the module case if that's preferable from an implementation perspective though.

there will be performance and memory gains in sharing it

Note that at least Gecko already coalesces multiple loads of the same stylesheet into doing a single load+parse, and has a single copy-on-write data structure with all the actual information backing multiple very small CSSStyleSheet objects. So the gains here would be much smaller than one would think.

@matthewp I'd like to understand the issue you raise about dependencies better. What are people trying to do that the current @import syntax/semantics do not allow but option 3 would allow?

@bzbarsky A CSS module might @import a dependency and override one of its selectors. Another CSS module might then @import that same dependency, thus reapplying the styles and overriding the changes that Module A made. To fix this Module B must remove its @import to prevent it from happening. Thus it can't express its own dependency because of the effect it has on others.

@matthewp So just to make sure I understand, we have three sheets: A, B, C. B and C both import A. B contains a selector with the same specificity as a selector in A, but B's selector wins out because of the "order specified" rule in https://www.w3.org/TR/CSS21/cascade.html#cascading-order step 4 or equivalent in later CSS specs. C comes after B in the declaration order, so the version of A imported by C ends up winning out over B. Is that the situation we're trying to address?

It's not clear to me how the "order specified" would even be defined in the option 3 case, and hence whether it would avoid this problem....

I was under the impression that A would be applied once, before B, and not reapplied when C imports it

Are we assuming that there's a single toplevel module import that ends up including both B and C, or are they separate toplevel module imports?

It's really not clear to me what this proposal envisions in general for how the nonlinear module graph maps onto the linear cascade. I guess for options 1 and 2 this is not an issue, because the module subgraph for any given CSS module is in fact just one node and that hands back a CSSStyleSheet; after that ordering in the cascade is just determined by where you place that CSSStyleSheet and existing cascading rules. For option 3, it's not really clear to me what the proposal is. If A and B are imported as separate modules, then B is inserted in a sheet list, then A is inserted, what happens? What if the order of insertions is reversed?

@bzbarsky That is the correct scenario yes. I might be misunderstanding option 3 in that case. I was expecting there not to be 2 versions of A in option 3, only 1 that is applied before B (and not reapplied by C).

@matthewp There's one version of A, sure. But note that the proposal is to just return a CSSStyleSheet, not apply anything. So say the sheets for both B and C (as separate modules) are returned, then the one for C is inserted into a sheet list, so C should start applying. Does that make A start applying? Note that B is not applying at this point. If B later starts applying, what changes? Does it depend on how B is ordered with respect to C?

With my user hat on, I think option 3 is preferable because it maps to the module loader behavior better.

This has the advantage that if a developer includes a stylesheet multiple times by mistake or because of shared CSS dependencies, there will be performance and memory gains in sharing it. On the other hand, this is a divergence from the existing behavior where multiple @imports of the same .css file each come with their own CSSStyleSheet.

This is analogous to JS Modules as well. import has the advantage that when a module is imported multiple times there's only one module instance. JS Modules also have a divergence from scripts, where multiple script tags (which happens easier than one might think with dynamic loaders) load a script multiple times.

@matthewp , @bzbarsky
Option 2 vs 3 would not behave differently in terms of the the styles that are applied. The difference is just whether a doubly-@imported stylesheet in a CSS module tree would have distinct identities for each time that it is @imported (as is the case for non-module CSS), or whether each @import of the same stylesheet in a CSS module tree would point to the same CSSStyleSheet object. I suppose that the order in which CSS files are fetched could be affected by whether @imports are modules or not but this is more of an implementation detail as that doesn't change the form of the final cascade and CSS OM.

I think it would be helpful to write out the example being discussed above as code:

a.css:

div { background-color: azure }

b.css:

@import url("a.css");
div { background-color: brown }

c.css:

@import url("a.css");
div { background-color: chartreuse }

main.html:

<style>
  @import url("b.css");
  @import url("c.css");
</style>
<div>This div will be chartreuse</div>
<script>
  let style = document.querySelector("style");
  let bSheet = style.sheet.rules[0].styleSheet;
  let cSheet = style.sheet.rules[1].styleSheet;
  let aSheetFromBSheet = bSheet.rules[0].styleSheet;
  let aSheetFromCSheet = cSheet.rules[0].styleSheet;
  console.log(`${aSheetFromBSheet === aSheetFromCSheet}`); // Prints 'false'; duplicate @imports have their own identities
</script>

mainUsingCSSModules.html

<script type="module">
  import bSheet from "./b.css";
  import cSheet from "./c.css";

  document.adoptedStyleSheets = [bSheet, cSheet];

  let aSheetFromBSheet = bSheet.rules[0].styleSheet;
  let aSheetFromCSheet = cSheet.rules[0].styleSheet;
  console.log(`${aSheetFromBSheet === aSheetFromCSheet}`); // Prints 'false' if option 2, 'true' if option 3
</script>
<div>This div will be chartreuse</div>

For mainUsingCSSModule.html, the resulting cascade is mostly the same regardless of whether we go with option 2 or option 3. The difference is that for option 3, a single CSSStyleSheet is shared by the @import from B and the @import from C since "a.css" would participate in the module graph and thus be processed only once for the page. As @justinfagnani pointed out this is similar to JS modules where multiple imports of a single script share one instance.

However, option 3 may come with certain other complications that we would need to contend with. For example, what is the parentStyleSheet and the ownerRule of aSheetFromBSheet/aSheetFromCSheet if they are the same CSSStyleSheet? I'm not sure that this divergence from existing invariants is worth it, especially since as @bzbarsky pointed out here, browser engines should generally coalesce duplicate stylesheet loads anyway, minimizing the additional performance wins that would come with option 3. Is there some concrete improvement in developer experience that option 3 would provide that would tip the scale in its direction?

However, option 3 may come with certain other complications that we would need to contend with. For example, what is the parentStyleSheet and the ownerRule of aSheetFromBSheet/aSheetFromCSheet if they are the same CSSStyleSheet?

Much like document.currentScript is set to null for modules, parentStyleSheet and ownerRule can be null for CSS modules.

Is there some concrete improvement in developer experience that option 3 would provide that would tip the scale in its direction?

It think that the equivalence to the behavior to the module loader is important in an of itself. Consider these two ways to load CSS modules base.css, element.css, into a JS module element.js:

Variant 1: using @import

base.css

...

element.css:

@import 'base.css';
...

element.js:

import styles from './element.css';
class Element1 extends HTMLElement {
  constructor() {
    this.attachShadow().adoptedStyleshets = [styles];
  }
}

Variant 2: direct import into JS

base.css

...

element-1.css:

...

element.js:

import baseStyles from './base.css';
import elementStyles from './element.css';
class Element1 extends HTMLElement {
  constructor() {
    this.attachShadow().adoptedStyleshets = [baseStyles, elementStyles];
  }
}

I think these two should be equivalent in terms of the modules that they instantiate, but if you extend the example to multiple JS modules, you'll get a difference where Variant 1 creates N CSSStyleSheet objects for base.css and Variant 2 creates only 1.

The difference is user observable if you're trying to dynamically edit styles, like a visual editor might do:

element.js:

import baseStyles from './base.css';
import elementStyles from './element.css';

makeBackgroundDark() {
  baseStyles.cssRules[0].styleMap.set('background-color', 'gray');
}

This only has global effect if we choose option 3, otherwise the developer has to avoid @import.

Yes, there are better ways to dynamically update a single or few properties, like custom variables, but there are cases where a tool will want to edit CSS, or a theming system will want to replace an entire shared stylesheet.

With my user hat on, I think option 3 is preferable because it maps to the module loader behavior better.

If we assume that CSS's @import rule is directly analogous to the JS import statement. Today it's definitely not; linking in a stylesheet multiple times does fresh requests for each of its @import rules and constructs independent CSSStyleSheet object for them.

This would be changing the behavior of @import in a way that can't be reproduced manually; there's no way to cause two stylesheets to share a single child stylesheet with the CSSOM as it stands.

This is analogous to JS Modules as well. import has the advantage that when a module is imported multiple times there's only one module instance.

JS and CSS have significant divergences here:

  • JS can have significant side effects upon loading, so limiting that to the first instance can be good, while CSS doesn't.
  • JS commonly shares mutable global state across a module, and having multiple instances use the same state is usually what you want, while CSS has virtually no stylesheet-global state (just @namespace; nothing usefully mutable)
  • stylesheet importing isn't as semantically significant as module importing; When A.js imports B.js, B is typically a separate thing semantically, but when A.css imports B.css, B.css is basically treated like it was just inlined into A.

Can you explain any particular advantage that having imported stylesheets sharing an @imported stylesheet would provide?

Much like document.currentScript is set to null for modules, parentStyleSheet and ownerRule can be null for CSS modules.

The question was what the .parentStyleSheet and .ownerRule would be for the @imported sheet, if it's shared between two imported sheets. That probably shouldn't be null, but it can't correctly be just one of the two sheets.


All in all, it looks like Option 3 (treat @import like a JS import, deduping same-url imports) would give us a brand new behavior for nested stylesheets which cannot be reproduced by hand, and would also have some awkward interactions with the existing CSSOM APIs which expect a tree structure with two-way links. I've come to strongly feel that Option 3 is definitely the wrong choice, and that we should be using Option 2 (the "import "foo.css" just fetches the sheet and constructs a CSSStyleSheet from its text" option).

linking in a stylesheet multiple times does fresh requests for each of its @import rules

Is this always true? Didn't @bzbarsky just say that Firefox doesn't do this?

If this should always be true, then it seems like a big knock against @import in CSS modules. You wouldn't want a bunch of CSS modules to ever @import a common stylesheet because you'd cause multiple fetches. Imagine 100 components that import 100 different component-specific CSS modules that each @import a few common stylesheets. That's a few 100 extra fetches.

Maybe we should disallow @import in that case, and treat CSS modules as if the called replaceSync? It wouldn't seem to behave how developers using modules would expect otherwise.

JS commonly shares mutable global state across a module, and having multiple instances use the same state is usually what you want, while CSS has virtually no stylesheet-global state

Isn't the stylesheet itself the stylesheet-global state?

Can you explain any particular advantage that having imported stylesheets sharing an @imported stylesheet would provide?

I thought I did with the base.css example - mutating a common stylesheet object seems like a reasonable way to switch themes across ShadowRoots. It's the only way I can see that doesn't involve getting every component to opt into some out-of-band system, in addition to loading a base CSS module.

Let's frame the example another way:

theme.js

import styles from './base.css';

export function switchTheme(url) {
  styles.replace(await (await fetch(url)).text()));
}

element.css

@import './base.css';

element.js

import styles from './element.css';

class MyElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow().adoptedStylesheet = [styles];
  }
}

It would be great if this worked. And not just for theme switching in an app, but for visual design tools and IDEs with live previews. Otherwise it does seem to discourage use of @import, which would be bad because there's no other way to have CSS modules load dependencies.

@dandclark

Option 2 vs 3 would not behave differently in terms of the the styles that are applied.

Ok, this was my misunderstanding then. I was expecting Option 3 to prevent a.css from being applied the second time. So if they are equivalent in that regard, I have no opinion on which is better.

I would love to see some future CSS feature that allows CSS modules to be used more similar to how JS modules are used, where all dependencies can be explicitly declared and applied only once.

@matthewp I think I finally put my finger on the conceptual issue with that (very reasonable!) desire. For JS modules, it doesn't matter "where" in the module graph they are in terms of the multiple graph edges pointing to them. In fact, the very concept doesn't make sense. But per the above description of the desired CSS behavior, we have a graph that has two incoming edges to the same node, and we want to base the application of that node based on one, but only one, of those edges. Again, unless I am missing something...

@bzbarsky That does sound right. I have brought up this idea before (here) (note that its a dated idea and probably would be much different with modules). Maybe we should take this discussion there, or another issue if you prefer. I don't want my misunderstanding of the options to derail this thread.

To add customer requests for the use-case I'm referring to:

We have a current Polymer customer who is using a feature we call "style includes" which are basically a direct replacement for @import (a <style> tag can include another style with an include attribute. We do this so we can process the styles for polyfilling) ask to be able to dynamically replace a certain include across their app in order to implement theme switching.

It would be very natural to do this with CSS modules with Option 3 and @import. With Option 2 they would have to import all switchable/base styles directly via the JS module system, rather than use @import in CSS. This would hurt the ability to refactor the base styles because each JS module has to import all transitive CSS dependencies as direct dependencies, rather than getting them transitively via their immediate CSS imports.

Is this always true? Didn't @bzbarsky just say that Firefox doesn't do this?

FWIW, what Boris said is that Firefox will do a single load + parse, but it will expose two separate CSSStyleSheet objects, and if you mutate one we'll copy-on-write. So nothing that should be observable to authors.

Not doing a fresh request for each @import of the same file is observable.

I meant observable from JS / the CSS OM. Of course it's observable from the server that doesn't get the request if that's what you mean.

Playing around with some simple test pages, I’ve observed that in Chrome, Edgeium, and Firefox, duplicate @imports do not seem to cause duplicate fetches in practice. This is regardless of whether the @imports come from the same stylesheet import tree or from trees originating from separate <link> or <style> elements. So I’m not sure that overhead from extra Fetches is much of a concern in practice. The performance difference would seem to be limited to the creation of the duplicate CSSStyleSheet objects.

I mainly used this page to make these observations, in case anyone else wants to try it out. It has imports leading to β€œ2a.css” from a few different paths, but Fiddler only observes one fetch of the file per page load.

On the other hand the Polymer customer use case is interesting. This may end up being something that we have to get into the hands of customers via a prototype to get some early feedback. I’m still concerned that the introduction of one-way links in the CSSOM tree structure could lead to awkwardness but at this point I can’t back that up with anything concrete.

would love to see some future CSS feature that allows CSS modules to be used more similar to how JS modules are used, where all dependencies can be explicitly declared and applied only once.

^ that's aligned with what I'd like to see (and hear) as well. JS's module system went through many gauntlets to get where it's at, I'd like to see CSS follow that arterial path. In my opinion, option 3 fits more closely to the dependency graph we've grown to appreciate and may receive less criticism from the community because of this alignment.

Whichever helps beginners and advanced folks "think less" about how loading works so they can focus on their task and let the platform handle the implementation intricacies. Dependency graphs are working, let's work towards CSS having a robust graph as well?

It seems that a lot of folks in this thread are interested in a new type of imports for CSS, which work differently than @import today. I think that feature needs to be designed separately, with the help of the CSSWG.

As such, I don't think we should proceed with CSS modules until those discussions get figured out, as it seems like our original plan of "this will be easy! Just expose a CSSStyleSheet object!" will not meet community expectations. We need a more involved design process.

I agree that this part should have some deliberate design, even if it's disappointing to see a delay in what I think is a very critical feature for modernizing web development.

There is a path that's forward-compatible with either outcome of the @import discussion, which is to disallow @import for now, much like CSSStyleSheet#replaceSync does. At worst, this pushes dependencies between CSS modules into JS module wrappers, which is what would happen if @import in CSS modules retained non-module CSS @import semantics. That could be undone if/when @import is supported in the future.

Note that delaying CSS modules would likely delay HTML modules too, given the current HTML modules proposal's dependency on them.

But given the need to design @import carefully, how can we move that discussion forward? Is this issue/repo even the right place? Should it be in the CSSWG? Who needs to be involved? cc @tabatkins.

I'm in favor of the forward-compatible v1 that bans @import to allow forward progress on both CSS and HTML Modules while the discussion of a potential new @import model for v2 continues in parallel.

rniwa commented

I'm in favor of the forward-compatible v1 that bans @import to allow forward progress on both CSS and HTML Modules while the discussion of a potential new @import model for v2 continues in parallel.

As in throw when there is @import? Silently ignoring @import is probably not going to be forward compatible as @import start working in the future could cause problems.

As in throw when there is @import? Silently ignoring @import is probably not going to be forward compatible as @import start working in the future could cause problems.

Yes, throwing like CSSStyleSheet.replaceSync:

  1. If rules contains one or more @import rules, throw a "NotAllowedError" DOMException.

FWIW, I think a v1 that throws on imports is a fine starting point. From reading the discussion in this issue, I also think that option 3 (@imported CSS is considered another module in the graph) is the best way to go, for reasons of performance: avoiding duplicated work, improved caching, and support for tooling to package and optimize sites statically.

I also think @dandclark is right that the issue discussed regarding dependencies of sheets is not really affected by this particular proposal, because the loading of the modules has to do with the network, as opposed to the cascade; the cascade is affected by where these CSSStyleSheet objects are placed by script, which is controllable and deterministic by the developer.

So, what is being proposed is just syntactic-sugar for for this?:

// Filename: styles.mjs

const style = `:host
{
	background-color: white;
	color: red;
}`;

const styles = new CSSStyleSheet();
styles.replace(style);
export { styles };
import { styles } from './styles.mjs'

class MyElement extends HTMLElement {
  constructor() {
    this.attachShadow({mode: open});
    this.shadowRoot.adoptedStyleSheets = [styles];
  }
}

I've posted an Explainer doc for the @import-less CSS Modules V1.

We're wrapping up JSON modules and are ramping up to implement CSS Modules in Blink (most of the groundwork has already been laid with Synthetic Modules).
I don't see other implementer interest concretely stated in this thread -- @annevk , @rniwa , thoughts on moving forward with the V1?

I don't know where to appropriately have this conversation, but I'm very worried about the migration path to this thing from the existing technology called "CSS Modules." https://github.com/css-modules/css-modules

To differentiate between the proposal here and the existing "CSS Modules" thing, I guess I'll call the existing thing "ICSS Modules," since (as an implementation detail) they compile to so-called Interoperable CSS files.

ICSS modules and CSS Modules V1 sound identical (they're both just called "CSS Modules") but they behave completely differently.

  • ICSS modules mangle class names for scoping; CSS Modules V1 returns a CSSStyleSheet, which has to be attached to a shadow DOM root in order to have any scoping.
  • ICSS modules export a mapping of class names to mangled names, allowing a tree-shaking algorithm to drop unimported ICSS rules. CSS Module V1 returns a big old CSSStyleSheet object, which the user would then attach in bulk.
  • ICSS modules work great with server-side JS, allowing developers to return plain HTML + CSS with no client-side JS required for rendering. I can't see how a CSSStyleSheet could be used on the server side at all.
  • ICSS modules are popular, and developers have been asking (perhaps naively) for native browser support for "CSS Modules" for years, in the same naive way that they wish for JSX built-in to the browser. This proposal is not at all what they meant.

Then there's the naming conflict. Imagine trying to Google for this: "How do I port my code from CSS modules to CSS modules?"

If I may be so bold as to speak for the many, many developers who never use Shadow DOM and never plan to start using it, it seems like this proposal is just going to make our lives worse.

@dfabulich The naming collision is unfortunate but what do you propose be done about it? In this issue "CSS Modules" is not a marketing phrase but rather a description of the feature. The same confusion exists for JavaScript modules which had a pre-existing meaning before import/export, and now there's also work being done on JSON modules.

This case is a little more confusing since CSS Modules is a project name. If we call this CSS modules lower-case does that help a little? Otherwise I'm not sure what can really be done; generic names are generic.

For the naming conflict in particular, perhaps we can pick a synonymous name? Here are a few suggestions:

  1. Stylesheet modules
  2. ES stylesheet modules
  3. Native stylesheet modules

I recognize that "CSS" stands for "Cascading Style Sheets" and so the semantic confusion would remain. (Honestly any proposal whose syntax begins import 'styles.css' will be confusing in that regard.) But I think picking a better syntactic name will help people to Google for the right solution when they need it.

To add a few more constructive comments, I would wish that this specification would be feature equivalent to ICSS modules, i.e. if I try to port from ICSS modules to "native stylesheet modules" (or whatever we call it), I wouldn't get "stuck" on major missing features.

  1. Work out a server-side polyfill library that supports scoping for CSS modules without client-side JS with solid bundling support. I anticipate that this will be hard, at least as hard as figuring out what to do with @import, and that the problems that the library implementor will encounter will/would inform even this specification as well as whatever CSS Modules V2 would look like.
  2. That may be too much to ask, but as a compromise, at least work out a polyfill intended for use with SSR that could render scoped CSS modules with an extremely tiny JS payload in the <head> of the document.
  3. Provide a way to import (and tree-shake!) individual classes/rules from a big stylesheet. That will be especially important if, in V1, users will have no way to @import, because then I'll have to pre-resolve the @imports into a big giant CSS file; including only the rules I actually need will be way more important.

IMO, that naming conflict should be resolved on the library side.

There is a well known case with ES2015+ related to certain Array methods names, changed because of Prototype.js reserved the originally suggested names, so that implementing them natively could break the thousands of sites.

Thankfully, this time we are talking about rebranding for a CSS authoring library. There are hundreds of those "tools-that-generate-unique-class-names-or-inline-styles" in React ecosystem. Why should we care about them?

I mean, if we give up on the name today, it might lead to consequences in future.

PS: this proposal is not tightly coupled with Shadow DOM, so let's not expand anyone's objections against using it here.

@dfabulich I think the items in your constructive comments are laudable, but I'm optimistic we'll get there just fine starting from basic capabilities first.

Any polyfill for this feature, just like with JS modules, is going to essentially require a build step (excluding build-steps in the browser). There's a very simple transform of the importer and CSS file to make this work, but presumably support for the standard semantics will be added to tools that do more on top, like bundling and tree-shaking.

For importing individual rules see my CSS References proposal: w3c/csswg-drafts#3714 Again, with this one I expect that tools will adapt to optimize on top of the standard semantics.

BTW, for tree-shaking, CSS Modules already give us a huge leap forward: by enabling finer-grained and explicit dependency CSS, we can use the native tree-shaker - the browser doesn't load modules that aren't imported.

As for the name, I'm not sure what else you would reasonably call this feature. We have JS modules, JSON modules, CSS modules and HTML modules.

When I refer to ECMAScript modules I say just "JS modules" and hope that other possible ambiguous meanings will fade away over time and when we need to be specific we use "CommonJS modules", "AMD", etc. I think that's slowly happening, and hope the same will for CSS modules.

There's a very simple transform of the importer and CSS file to make this work

I don't think that's right in this case; the build will need to provide a server-side runtime implementation of CSSStyleSheet, which looks non-trivial to me. The build-time bundler will furthermore need a way to transform constructed CSSStyleSheet objects into bundled CSS files and scope them without client-side JS or Shadow DOM. (How, exactly?)

It is precisely my concern that you think this should be pretty trivial, but I claim that it's really really not, and that's why we should stop and think about this for another minute.

BTW, for tree-shaking, CSS Modules already give us a huge leap forward: by enabling finer-grained and explicit dependency CSS, we can use the native tree-shaker - the browser doesn't load modules that aren't imported.

But by dropping @import, it'll be a big step backward in practice, as the bundler will have to flatten all @imports together and let the browser download all of the imported rules, whether they're needed or not.

As for the name, I'm not sure what else you would reasonably call this feature

"Stylesheet modules" is short and sweet. I provided two other names as well.

It is precisely my concern that you think this should be pretty trivial, but I claim that it's really really not

@dfabulich we have a lot of experience building polyfills for these features, including Shadow DOM with and without style scoping, CSS custom variables, JS and HTML modules, and a few prototypes for Constructible Stylesheets. We also have a limited version of the transform (for slightly different semantics) in use at Google.

I think we have a very good idea of what this entails, and the transform for CSS modules is quite simple, especially compared to the polyfills we've build before.

I'm a firm believer in layering for polyfills, so the transform would be based on Constructible Stylesheets so that 1) we have optimal perf in Constructible Stylesheets supporting browsers 2) can work on-top of any Constructible Stylesheets polyfill, and 3) we enable all the use-cases like adoptedStylesheets.

And like most polyfills, there will likely be multiple competing implementations and better techniques will be discovered and used if they exist. I advocate for new APIs to consider polyfill-ability, and I'm very confident that this proposal is easily and efficiently polyfillable by a build-time tool.

Dropping @import from v1 allows us to start getting the tools ecosystems to add support while the decision is worked on.

as the bundler will have to flatten all @imports together and let the browser download all of the imported rules, whether they're needed or not.

You can easily construct @import supporting transforms that do not duplicate dependencies.

Here's an example Rollup transform: https://gist.github.com/samthor/ee78b434b0f9aa525c5d235979b830aa

A transform can go off-spec with a cooperating Constructible Stylesheets polyfill and support @import by lifting @imports into the JS module. I would suggest that tools don't do that though, and wait for the spec to evolve.

That transform requires client-side JS to work. It's transforming declarative CSS into imperative client-side JS. So, sure, that is a trivial transform, but that's not the polyfill I'm asking about.

ICSS modules running on the server side can generate scoped HTML + CSS with no client-side JS. Is that polyfill even possible under the current proposal?

@dfabulich This proposal does not attempt to solve all of the same problems that CSS Modules solve, only the problem of being able to import CSS from JavaScript. In the future other specs could solve some of the other things that CSS Modules give you. It's common to release spec features iteratively rather than pack many features into 1 proposal because the latter is less likely to gain consensus.

That's true, but this feature is clobbering an existing userland solution that solves the problem better than the proposal on the table.

Most features aren't clobbering any existing userland solution; under the extensible web, most new features add some totally new capabilities, and if V1 hasn't added enough capabilities, maybe we can add them in later, but having partial capabilities earlier is worth it. Not so in this case.

This feature violates the norm to "first, do no harm."

If we're going to introduce a naming conflict, with syntax that's confusingly similar to a widely loved existing userland transpiled solution, then it had better be worth it. It shouldn't just be an 80% solution compared to userland; it ought to be actually better than userland if we're going to incur these costs on the web community's behalf. People using the old userland thing should say "It's a pain that I have to upgrade, but I'm glad they introduced this new thing; this is way better than our hacked up solution. Let's abandon the userland approach in favor of the new standard."

And if it's not worth it in the current form, but we still want to move ahead with it anyway, then it ought to at least be forward compatible with a solution that actually is better than userland. We ought to know what that solution could look like, making sure that we won't have to break backwards compat when we all finally adopt V2 or V3 which actually solves these problems.

I'm sure that a good solution for @import can be devised at some point, but I'm skeptical that even a polyfill with no client-side JS can be prototyped for CSS Modules V1.

First, do no harm.

This feature doesn't interfere with the userland solution in any way. The userland solutions are implemented as custom transforms that will absolutely still work with no changes whatsoever, because the CSS module import doesn't reach the browser.

Those transforms can carry on from now till eternity, or they can be updated to perform a new subset of their transforms on the CSS contents and use CSS modules for loading and parsing. Systems that provide more exports from CSS than this proposal (like classes) can still do so by generating the necessary JS.

The example taken from https://github.com/css-modules/css-modules:

style.css:

.className {
  color: green;
}

app.js:

import styles from "./style.css";
// import { className } from "./style.css";

element.innerHTML = '<div class="' + styles.className + '">';

can be transformed to:

style.css:

.a /* renamed from .className */ {
  color: green;
}

style.css.js:

import {style} from './style.js';
document. adoptedStyleSheets = [...document. adoptedStyleSheets, style];
const classes = {
  className: 'a',
};
export default classes;
export const className = classes.a;

app.js:

import styles from "./style.css.js";
// import { className } from "./style.css.js";

element.innerHTML = '<div class="' + styles.className + '">';

So this preserves existing semantics and leverages native loading. If they wanted to update the transform, say to be usable with shadow DOM, they could have a version that exports the stylesheet and doesn't automatically apply it to the document.

There are a ton of options on how the basic semantics of importing a stylesheet can be used and extended for all kinds of cases and bridged to existing userland systems. It'd be more useful if general claims that this proposal is in conflict with such systems, and especially that it might "do harm", were accompanied with more specific examples.

The harm is the naming conflict and the syntax conflict.

Syntax conflict: What does import styles from 'styles.css' do? I know what it does now because that's the syntax for CSS modules. In the future, it will also be the syntax for CSS modules, but it will be difficult to know which CSS module system is in use. Can we mix and match CSS modules with CSS modules? Does import styles from 'style.css' work on the server side?

These are settled questions today, but not when this feature ships. It will create significant confusion and incompatibilities between two identically named CSS module systems. That's harm.

The web community still hasn't finished absorbing the backwards compatibility problems introduced by ES modules. The old ways still work! But they look identical to the new ways, and they don't work together. That harmed the community, in hindsight. This does something similar.

I recognize that you can't make this particular omelette without breaking a few eggs. But in this case, we already have a delicious omelette. The new omelette has to be better than the omelette we already have.

import styles from './styles.css'; is not yet standardized, so you only know what it does now if you're using a particular tool. There are a number that utilize that same syntax with slightly different behavior:

  • CSS Modules The tool you're familiar with
  • Webpack CSS loader Defaults to not implementing the CSS Modules tool's behavior.
  • rollup-plugin-postcss Also defaults to not implementing the CSS Modules tool's behavior.
  • Parcel: Same, though also might have slightly divergent behavior from CSS Modules according to their docs.

These are just a few of the tools, but they do have some commonalities:

  • They were created before JS modules were implemented in browsers. Design choices could naturally be different after this.
  • They completely omit the import statement from the output, so they don't use any native loading. Adding native loading will not interfere with them.
  • They are opt-in and configured at the use site. Packages rarely publish JS modules with CSS imports in them - they transform them out ahead of time. Native loading
  • They are side-effectful. They all append a stylesheet to the main document. That's not a behavior we'd want in the JS module system where modules are by default side-effect free, unless code in them has a side-effect.
  • They assume a single global scope and so are incompatible with adoptedStyleSheets and shadow DOM.
  • They allow for importing class names into JS, which is a use case we do want to cover eventually, and definitely not prevent in the meantime. I mention this in the original text of this proposal, and have made the CSS References proposal to cover this case natively. Tools (and hand-written JS modules) can also provide those exports like I've shown above.

All-in-all I think this capability will be a huge boon to the userland solutions which will now have a standardized compile target. Packages can use a tool's proprietary semantics, but transform to standard semantics before publishing. This will unlock many packages to publish standard JS modules to npm.

So something I've been playing around with is creating a transform system that is a superset of this proposal with the things I mentioned here supported as well.

In particular I hope to be able to solve ICSS modules use cases (but not necessarily be syntax compatible) while also supporting Shadow DOM well and keeping the door open for future extension.

In particular I aim to have imports from both CSS and JS of values, keyframes, selectors, custom property names and custom names (e.g. for paint($name)) working within a few weeks.

I haven't decided exactly how selectors will work, but I'm leaning towards treating them more like classes than rulesets, so that generic selectors work e.g.:

/* metrics.css */

$gridBaseline: 8px;
/* mixin.css */

@import "./metrics.css" {
  names: $gridBaseline;
}

$mixin {
  background-color: red;
  color: black;
}

$mixin > p + p {
  margin-block-start: calc($gridBaseline * 2);
}

$mixin > h1 {
  margin-block-start: calc($gridBaseline * 4);
}

$large {
  @extends $mixin;
  font-size: 1.2em;
}
/* myComponent.css */
@import "/path/to/mixin.css" {
  names: $mixin;
}

#main {
  @extends $mixin;
}
/* myComponent.js */
import styles from './styles.css';
import { large as largeStyles } from '/path/to/mixin.css';

/* ... */
this.shadowRoot.adoptedStyleSheets = [styles];

/* ... onChange */

if (this.getAttribute('size') === 'large') {
  this.shadowRoot.querySelector('#main').adoptedClasses.add(largeStyles);
}
/* ... */

All-in-all I think this capability will be a huge boon to the userland solutions which will now have a standardized compile target.

We already have a standardized compile target: HTML and CSS, with no client-side JS required.

β€’ They are side-effectful. They all append a stylesheet to the main document. That's not a behavior we'd want in the JS module system where modules are by default side-effect free, unless code in them has a side-effect.

I don't understand your point here. It seems like a tautology. Modules are side-effect free by default…unless they have a side effect. These modules certainly do have side effects, and that is part of their design, just like importing a module that declares a custom element.

Your other points seem to emphasize that ICSS Modules won't stop working when this ships; I agree that they won't stop working. I never said they would. I said that the new thing would create incompatibility and confusion between two identical syntaxes with the same name ("CSS Modules"). I won't be able to mix-and-match CSS Modules with CSS Modules.

If CSS Modules V1 solved all of the same problems of ICSS modules and more, then it might be worth paying that price, but it doesn't, so it isn't. We should only clobber ICSS modules if we have a solution that is so superior to ICSS modules that it would be worth switching to it.

csvn commented

We already have a standardized compile target: HTML and CSS, with no client-side JS required.

@dfabulich If we want to import CSS, then no, we can't compile to CSS. The CSS has to either be wrapped in a Javascript file via e.g. default export, or use fetch to retrieve CSS text content.

// style.css
export default `
  body { /* style */ }
`;
// or
const style = await (await fetch('/style.css')).text();

After this, a style tag or Constructible StyleSheet must be created and the text inserted. This is quite a bit of extra code for a trivial example over simply using import style from '/style.css' and getting back something that is directly usable.

If we want to import CSS, then no, we can't compile to CSS.

But that's exactly what we do, right now. We import CSS on the server side and it compiles to CSS. It's quite nice! It's better than this proposal.

As I said, I recognize that import style from 'style.css' is inherently going to use the same syntax as ICSS modules, and so you can't make this omelette without breaking those eggs. But this proposal isn't better than the userland solution, so it's not worth the cost of creating incompatibility and confusion between two identical syntaxes with the same name.

Can I do lazy-loading / code splitting using dynamic import with await import ?

Example :

document.adoptedStyleSheets = [...document.adoptedStyleSheets, await import('./style.css')];

I believe it would be unwise to write it in one line like that, because ...document.adoptedStyleSheets will evaluate before the import does its fetch. If someone adds a stylesheet to adoptedStyleSheets during the import, it will be blown away when the import finishes.

Splitting it into two lines will fix it:

const style = await import('./style.css');
document.adoptedStyleSheets = [...document.adoptedStyleSheets, style];

These modules certainly do have side effects, and that is part of their design, just like importing a module that declares a custom element.

Attaching global stylesheets simply doesn't work for shadow DOM, so being compatible with ICSS while supporting shadow DOM is not going to happen for any actual CSS modules implementation.

Regarding the verbosity concerns, personally I'd rather just see CSS modules be able to be included directly in HTML when dynamic removal/addition isn't actually required. e.g.:

<!-- Uses the CSS module system and caches the same sheet -->
<style src="./styles.css"></style>

<div class="someClass">
  ...
</div>

@dfabulich @dtruffaut

async function go() {
  document.adoptedStyleSheets = [...document.adoptedStyleSheets, await import('./style.css')];
}

would work just fine. The inner expressions are evaluated first, yielding at the await, then the array literal, then the assignment, etc...

Edit:

I would have never have written this code if we had a mutable object, like an Array. It very obviously would have been:

async function go() {
  document.adoptedStyleSheets.push(await import('./style.css'));
}

I think @dfabulich is right:

arr = ['a'];

function resolveAfter (value, ms) {
  return new Promise(f => setTimeout(() => f(value), ms));
}

async function addB() {
  arr = [...arr, await resolveAfter('b', 100)];
}

async function addC() {
  arr = [...arr, await resolveAfter('c', 50)];
}

addB();
addC();

// later...
console.log(arr); // ['a', 'b']

This is a strong argument in favour of having an interface for adding and removing styles, rather than using an array literal, which is likely to result in some unpleasant bugs for this reason.

Why would you do that, rather than just getting/setting document.adoptedStylesheets in each function?

Also, note that this is a consequence of await semantics in argument position; they're a little unobvious and should generally be avoided. (Basically, all preceding arguments are eagerly evaluated before the function pauses.) If you wrote those functions as:

async function addB() {
  const newVal = await resolveAfter('c', 50);
  arr = [...arr, newVal];
}

...you'd be just fine and it would work as expected, with arr being ['a', 'c', 'b'] at the end.

Oh, that's the exact code Justin listed. ^_^ Dont' do that code, Justin! It's bad!

Which is the point. If Justin Fagnani's intuitions about how that code behaves are wrong, imagine what mischief the average developer will get up to!

Saying 'don't do that' isn't adequate, in my view. Good API design leads you into the pit of success; this does the opposite. The fact that it took so long in this thread before anyone noticed this issue is a foreshadowing of how many subtle race conditions will go unnoticed if this is what ends up in browsers.

I didn't say that @dtruffaut 's code was free of race conditions, but that it would work. His snippet was outside of an async function, so I put it in one to make it valid. My intuition wasn't incorrect because I wasn't making a broader claim.

But now we're talking about async functions, race conditions, and adoptedStyleSheets, which are not CSS modules. CSS modules only allow you to import a stylesheet. If there are more ergonomic ways if consuming stylesheets in the future, CSS modules will naturally and seamlessly work with them. That's the benefit of a targeted and incremental proposal, rather than something that tries to duplicate features and semantics from much larger-in-scope userland solutions out of the gate.

Well, we have a different definition of 'it would work'. Regardless, where is the appropriate place to discuss adoptedStyleSheets? I would argue it needs some more consideration.

I believe @rniwa as complained about this exact issue, or similar issues with the array. The adoptedStyleSheets API is just awkward, and counter intuitive. I hope that we can still fix that portion, and get Apple folks onboard.

Can someone open another issue to discuss adoptedStyleSheets? I'm not sure where it was discussed before.