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

Cascading Style Sheet module scripts #759

Open
justinfagnani opened this issue Aug 8, 2018 · 112 comments
Open

Cascading Style Sheet module scripts #759

justinfagnani opened this issue Aug 8, 2018 · 112 comments

Comments

@justinfagnani
Copy link
Contributor

@justinfagnani justinfagnani commented Aug 8, 2018

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"

@justinfagnani
Copy link
Contributor Author

@justinfagnani justinfagnani commented Aug 9, 2018

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

@annevk
Copy link
Collaborator

@annevk annevk commented Aug 9, 2018

@tabatkins
Copy link

@tabatkins tabatkins commented Aug 9, 2018

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?

@justinfagnani
Copy link
Contributor Author

@justinfagnani justinfagnani commented Aug 9, 2018

@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.

@calebdwilliams
Copy link

@calebdwilliams calebdwilliams commented Aug 9, 2018

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.

@justinfagnani
Copy link
Contributor Author

@justinfagnani justinfagnani commented Aug 9, 2018

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.

@calebdwilliams
Copy link

@calebdwilliams calebdwilliams commented Aug 9, 2018

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.

@matthewp
Copy link

@matthewp matthewp commented Aug 9, 2018

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.

@AshleyScirra
Copy link

@AshleyScirra AshleyScirra commented Aug 11, 2018

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.

@matthewp
Copy link

@matthewp matthewp commented Aug 11, 2018

@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.

@AshleyScirra
Copy link

@AshleyScirra AshleyScirra commented Aug 12, 2018

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).

@justinfagnani
Copy link
Contributor Author

@justinfagnani justinfagnani commented Aug 13, 2018

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

@jhnns jhnns commented Aug 28, 2018

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.

@rakina
Copy link
Member

@rakina rakina commented Sep 20, 2018

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?

@annevk
Copy link
Collaborator

@annevk annevk commented Sep 20, 2018

@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.

@annevk
Copy link
Collaborator

@annevk annevk commented Sep 20, 2018

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

@rakina
Copy link
Member

@rakina rakina commented Sep 20, 2018

@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)

@annevk
Copy link
Collaborator

@annevk annevk commented Sep 20, 2018

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.

@annevk
Copy link
Collaborator

@annevk annevk commented Sep 20, 2018

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?

@rakina
Copy link
Member

@rakina rakina commented Sep 21, 2018

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.

@annevk
Copy link
Collaborator

@annevk annevk commented Sep 21, 2018

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

@rakina
Copy link
Member

@rakina rakina commented Sep 21, 2018

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.

@annevk
Copy link
Collaborator

@annevk annevk commented Sep 21, 2018

@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.

@dtruffaut
Copy link

@dtruffaut dtruffaut commented Aug 14, 2019

Thanks for your kind replies.

If I add some context to my own usage :

A] It is important to await async functions

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)];
}

await addB(); // <------- It is important to await async functions
await addC(); // <------- It is important to await async functions

// immediately...
console.log(arr); // ['a', 'b', 'c'] <------- as expected

The litigious code :

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

... was expected to be part of an async function, and this function was expected to be awaited, as its ancestors, until we reach the main function.

// deep code

const style = async () => {
  document.adoptedStyleSheets = [...document.adoptedStyleSheets, await import('./style.css')];
}
await style();  // <------- I do await in 99.99% of my code

B] The only case I authorize myself to not await an async function is... the main.

// index.js

const main = async () => {
  // ...
} 
main();  // <------- I do *not* await at top level

A developer can still use the litigious code in the main function.
In this specific case, developer might experience race conditions.
Result seems strongly related to the very nature of async / await and how the developer masters it.

@Rich-Harris
Copy link

@Rich-Harris Rich-Harris commented Aug 14, 2019

Opened an issue to discuss an alternative name to adoptedStyleSheets: WICG/construct-stylesheets#97

@Lonniebiz
Copy link

@Lonniebiz Lonniebiz commented Aug 20, 2019

Mozilla appears to be way behind on this stuff. You can't even achieve CSS Modules the manual way which already works in Chromium.

If I open up a console in Firefox 68.0.2, and type:
let sheet = new CSSStyleSheet();

I get this error:
TypeError: Illegal constructor.

You can achieve some efficiency in Firefox using <link rel="stylesheet">, but that's inferior to a javascript variable in both performance and portability.

If you have persuasive points that might expedite Firefox's progress on this, please post them at the bug report here. In the meantime, I'll be using css blobs for achieving self-contained, self-sufficient web components.

@Jack-Works
Copy link

@Jack-Works Jack-Works commented Sep 7, 2019

I have made a demo that can import without Service Worker (but service worker can cache and enhance the experience).

css-module-loader.js

const src = new URL(import.meta.url).searchParams.get('src')
const container = new CSSStyleSheet()

fetch(src)
    .then(x => x.text())
    .then(x => container.replace(x))

export default container

index.js

import article from './markdown-loader.js?src=./article.md'
import css from './css-module-loader.js?src=./style.css'

document.body.appendChild(document.createElement('div')).appendChild(article)
document.adoptedStyleSheets = [css]

This pattern works for all types of file that do not need a sync loading.

For async loading, you need top level await or sync xhr. For async compiling, only can be done by Service worker.

My demo at: https://github.com/Jack-Works/loader-with-esmodule-example

Supports Markdown module, CSS Module, JSON Module

@tomayac
Copy link
Contributor

@tomayac tomayac commented Sep 26, 2019

Given the security issue raised by Apple, could a concept similar to Preload's as attribute be a way out, so the import can be parsed accordingly?

import styles from "styles.css" as style;

Or alternatively more specific naming?

importCSS styles from "styles.css";

@justinfagnani justinfagnani changed the title CSS Modules Cascading Style Sheet module scripts Oct 1, 2019
@dandclark
Copy link
Contributor

@dandclark dandclark commented Oct 8, 2019

Given the security issue raised by Apple, could a concept similar to Preload's as attribute be a way out, so the import can be parsed accordingly?

import styles from "styles.css" as style;

Or alternatively more specific naming?

importCSS styles from "styles.css";

That issue is being discussed over at this thread, and changes to the import syntax like those suggestions are one of the options being considered.

@jkrems
Copy link

@jkrems jkrems commented Jun 29, 2020

This thread seems to be getting pretty long. It's not quite clear where the proposal currently stands and what the best way to submit feedback is. E.g. is it more than a historical accident that this issue in in the webcomponents repo? Would non-webcomponents feedback be relevant here? Given the overlap with existing patterns in ecosystem code (for better or worse), it seems to me like this discussion may deserve a more prominent place.

@justinfagnani
Copy link
Contributor Author

@justinfagnani justinfagnani commented Jun 30, 2020

@jkrems I opened it here because this is where we were also discussing HTML modules. I'm not sure how others feel about potentially forking the discussion vs finding a more prominent home, but I think at this point that this issue is well-known enough and referenced from other places (explainers, spec PRs, etc) that I'd be inclined to continue the discussion here.

And non-web-components related feedback is most definitely welcome - this proposal is for minimal semantics that have no real coupling with web components aside from constructible stylesheets.

@prantlf
Copy link

@prantlf prantlf commented Mar 13, 2021

@Rich-Harris , I wonder why you did not use push, when you knew that you would fill the array asynchronously. You showed a race condition, but it was caused by a bug in the code. I can imagine other bugs caused by assigning into a variable.

Was you intention to prevent such bugs in the client code by a more limited interface? Did you have other intention?

Corrected code:

arr = ['a'];

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

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

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

addB();
addC();

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

domenic pushed a commit to whatwg/html that referenced this issue Jul 14, 2021
This change extends the JavaScript module system integration to include CSS module scripts, in addition to JavaScript module scripts. These allow web developers to load CSS into a component definition in a manner that interacts seamlessly with other module script types.

Explainer document: https://github.com/w3c/webcomponents/blob/gh-pages/proposals/css-modules-v1-explainer.md

Closes WICG/webcomponents#759. Part of #4321.

This change includes the integration for the import assertions proposal (https://github.com/tc39/proposal-import-assertions/), thus closing #5640 and closing #5883 by superseding it.
@jogibear9988
Copy link

@jogibear9988 jogibear9988 commented Dec 17, 2021

support has landed in chrome: https://web.dev/css-module-scripts/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Linked pull requests

Successfully merging a pull request may close this issue.

None yet