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

Multiple stylesheets per file #5629

Open
justinfagnani opened this issue Oct 16, 2020 · 44 comments
Open

Multiple stylesheets per file #5629

justinfagnani opened this issue Oct 16, 2020 · 44 comments

Comments

@justinfagnani
Copy link

justinfagnani commented Oct 16, 2020

With Cascading Stylesheet Module scripts on the horizon we will soon have the ability to import CSSStyleSheets directly into JS, like so:

import sheet from './styles.css' assert {type: 'css'};

Problem

The semantics here are fine for unbundled apps, but bundling becomes tricky. If you have two .css files in an app, you can't just combine them. ie:

import sheet1 from './styles1.css' assert {type: 'css'};
import sheet2 from './styles2.css' assert {type: 'css'};

Is not compatible with:

import sheet from './styles1and2.css' assert {type: 'css'};

The current workaround is to compile CSS into JS modules, which defeats some of the performance benefit of having the browser directly load and parse CSS.

Web Bundles might solve this problem generically for multiple file types, though its future on multiple browsers seems unclear right now.

Proposal: @sheet

To fix this and allow bundling of CSS, could we introduce an at-rule that contains an entire style sheet as its contents?

For example, there could be a @sheet rule which allows files to contain named stylesheets:

styles1and2.css:

@sheet sheet1 {
  :host {
    display: block;
    background: red;
  }
}

@sheet sheet2 {
  p {
    color: blue;
  }
}

These could be imported separately from JS:

import {sheet1, sheet2} from './styles1and2.css' assert {type: 'css'};

And also be available on the main style sheet:

import styles, {sheet1, sheet2} from './styles1and2.css' assert {type: 'css'};
styles.sheet1 === sheet1;
styles.sheet2 === sheet2;

Relation to existing approaches

The proposal is most obviously relevant to code that manages CSSStyleSheets in JS - ie, users of Constructible StyleSheets and the API currently named adoptedStyleSheets.

It would also be useful as a bridge to userland CSS loaders that do bundling and scoping via selector rewriting. By standardizing bundling, scoping could be done with client-side utilities:

import {sheet1, sheet2} from './styles.css' assert {type: 'css' };

// doesn't yet exist, but a utility that re-writes class selectors and returns
// an object with a .sheet property and properties for each class
import {scopeSheet} from 'css-module-utilities';

const scopedSheet1 = scopeSheet(sheet1);
const scopedSheet2 = scopeSheet(sheet2);

document.adoptedStyleSheets.push(scopedSheet1.sheet, scopedSheet2.sheet);

document.append(`<div class="${scopedSheet1.fooClass}"></div>`);
document.append(`<div class="${scopedSheet2.barClass}"></div>`);
@justinfagnani
Copy link
Author

justinfagnani commented Oct 16, 2020

cc @dandclark @yuzhehan

@dandclark
Copy link
Contributor

Early thoughts: I like this, though it might not replace CSS bundlers in some really performance-sensitive cases. There is still one fetch incurred for the import {sheet1, sheet1} from './styles1and2.css' assert {type: 'css'}; statement that would be eliminated by bundling. If the perf benefit of eliminating this last extra fetch is greater than the perf benefit of parsing everything directly as CSS [1], then there might not be a performance win for using this instead of a bundler.

But, it reduces the cost of using CSS modules in production to just 1 extra fetch, which is down from N extra fetches for N stylesheets. So for the cost of the one fetch, you cut out one part of the build/bundling process, get some perf benefit from parsing CSS directly without passing it through the JS parser, and the resulting production code will be easier to read and reason about than production code that had CSS bundled in the JS.

[1] Last year I did some rough experiments to try to measure this potential perf benefit. I observed real differences in both time and memory, although you need a lot of iterations before it starts to be really observable: https://dandclark.github.io/json-css-module-notes/#css-module-performancememory-examples

@guybedford
Copy link

If there is a way to add this to the polyfill I would be more than happy to include this in the SystemJS module types polyfill as well. It seems a great feature.

@dandclark
Copy link
Contributor

@justinfagnani A small typo correction for clarity: you've got {sheet1, sheet1} in a few places where I think it should be {sheet1, sheet2}.

@justinfagnani
Copy link
Author

Thanks @dandclark! Updated

@gsnedders
Copy link
Member

gsnedders commented Oct 27, 2020

The semantics here are fine for unbundled apps, but bundling becomes tricky. If you have two .css files in an app, you can't just combine them. ie:

import sheet1 from './styles1.css' assert {type: 'css'};
import sheet2 from './styles2.css' assert {type: 'css'};

Is not compatible with:

import sheet from './styles1and2.css' assert {type: 'css'};

Dumb question from someone lacking context about CSS modules: these aren't compatible because sheet1 and sheet2 are both CSSStyleSheet objects consisting of their respective parts, right? Hence you need some way to split up the concatenated stylesheet back up into its constituent parts?

@justinfagnani
Copy link
Author

@gsnedders correct

@tabatkins
Copy link
Member

This sounds fairly reasonable to me. Semantically, this is basically a more convenient way to write @import url("data:...");, with the potential to hook into CSS Modules a little better.

@leobalter
Copy link

cc @bmeck this seems like a good addition for the arbitrary module names, if my bundling still want to reference the names somehow.

@leobalter
Copy link

I like this as it also helps on a better usage of dynamic import() for css files, IMO.

@devongovett
Copy link
Contributor

This proposal is very interesting to the Parcel team, and seems to mirror the Module Fragments proposal in the JavaScript world. Current approaches to bundling CSS by simply concatenating them are prone to ordering issues that can cause specificity problems. Sometimes bundling is simply not possible to do correctly due to this. An approach that allows natively combining separate sheets together that can be applied in the correct order would be very useful. 👍

@justinfagnani
Copy link
Author

cc'ing @littledan who is working on the related module declarations. If language-specific bundling is needed despite web bundles, maybe we need to push this forward for CSS.

@justinfagnani
Copy link
Author

It turns out you can emulate this feature by abusing @supports.

If there's an unknown supports function, the parsed stylesheet will still contain the rules inside @supports {...}. We can invent a function like sheet(name) and stash rules in there keyed by the bundled sheet name:

@supports sheet(styles-one.css) {
  //...
}

@supports sheet(styles-two.css) {
  //...
}

Then we can rewrite CSS module script imports from :

import styles from './styles-one.css' assert {type: 'css'};

to:

import $bundledStyles from './styles-bundle.css' assert {type: 'css'};
import {getBundledSheet} from 'lit/get-bundled-sheet.js';
const styles = getBundledSheet($bundledStyles, 'styles-one.css');

where getBundledSheet is a utility to grab a set of rules in a @supports sheet(name) at-rule and create a new stylesheet out of them.

Proof of concept here: https://lit.dev/playground/#gist=5fab7cc0987e6f1610ba3bd4f432f02c (requires import assertions support to work)


@tabatkins adding native support for something like @sheet would eliminate the processing and double-parse (bouncing rules though .cssText and insertRule()). Seems relatively simple (but famous last words). What do you think about adding this?

@dutchcelt
Copy link

This seems like a good progression using assertions. Bundles would become much more useful from CSS perspective.

/* bundle.css */

@layer defaults, common, brand, components;

@sheet designsystemstyles {
  @layer commmon {
     ...
  }
}
@sheet nameofbrand {
  @layer brand {
    ...
  }
}

@tabatkins
Copy link
Member

As I said in my previous comment, I think it's a very reasonable suggestion, I just haven't spent any time speccing it out. ^_^

A few questions that probably need resolving, tho:

  1. I assume that the top-level import is still the overall stylesheet containing the @sheet rules, yeah? We just additionally specify that the @sheet rules produce additional exported sheets in the module object, keyed to their name?
  2. The @sheet contents are independent, as if they were @import url("data:...");, right? The example in the preceding comment would indeed work (layer names are shared across all sheets already) but it wouldn't, say, see a @namespace rule in the outer sheet (and presumably could contain a @namespace rule of its own).
  3. What's the behavior of @media (...) { @sheet {...} }? I presume the answer needs to be "it's invalid", and @sheet objects are required to be top-level.
  4. Can you nest @sheets tho? If so, does the top-level import expose them all as a flat list, or do you just get the top-level ones, and have to go find the lower ones yourself?
  5. If you have multiple @sheet foo rules, do we only take one and treat the rest as invalid (and then do we take first or last?)? Or do we merge their contents as if they were a single sheet?

@Westbrook
Copy link

  1. Yes.
  2. Independent. While there could be something interesting about additional work around @import @sheet sheetName in order to share across the single file, the goal is to keep the various @sheet entries separate from each other, but bound to the single file download.
  3. Invalid. @sheet should be top level.
  4. Invalid. @sheet should be top level.
  5. CSS rules say last definition wins. JS rules say multiple consts throw errors. CSS doesn't really throw errors, so keep towards the CSS rules here.

1 and 4 in concert beg the question of what is returned at import styles from './styles.css' assert { type: 'css' }; when the file does have @sheet specifiers? In the JS space, you'd hope for something more of an object with the other sheets on in { sheet1: CSSStyleSheet, sheet2: CSSStyleSheet, ...etc }, however, I'd expect we'd need to actually accept a CSSStyleSheet with its cssRules array including CSSStyleSheets, instead of rules. This could be a bit surprising to JS users, but clarifies the fact that 4 is not possible. If there were some magic way to get more of a JS import out of the the CSS assertion, it would be interesting to push for nested sheets to do the same, but both feel a bridge to far in a world where we'd actually want something like this to ship...

@tabatkins
Copy link
Member

I presume the top-level sheet is the default export, and @sheets just provide named exports, so you can get either or both, depending on which syntax you use for the import.

@Crissov
Copy link
Contributor

Crissov commented Jan 26, 2023

Shouldn’t user-defined sheet (and layer) names be either quoted strings or dash-idents?

@tabatkins
Copy link
Member

No, not necessarily. They're user-controlled, so there's no need for strings (they can comfortably remain in the identifier syntax). And these names won't mix with other CSS values, so there's no particular need to mark them out as user-defined; plus they're meant to map to JS identifiers, so dashed-idents would be inconvenient.

@justinfagnani
Copy link
Author

If the goal is bundling files that otherwise would be part of the module graph, I think some of the questions you raised @tabatkins seem to me to have relative obvious answers, and I would agree with @Westbrook's responses.

The main thing for me is that the module graph is keyed by URL, and so is flat in that sense, so @sheet should be top-level only. References between sheets, should that become possible in the future, should be via a module-graph compatible @import that could reference other @sheets.

@tabatkins
Copy link
Member

That makes sense. 👍

@tabatkins
Copy link
Member

Agenda+ to check the group's temperature on this idea (and figure out what spec it would go in).

To sum up:

The problem to solve is bundling stylesheets together. (Particularly for importing in a JS import, but also more generally to consolidate multiple requests into fewer, larger requests.) There are some inconvenient workarounds today (@import url("data:...");, a false @media {...} rule that you then reparse the contents of yourself, etc), but they suck, and also don't interact nicely with JS imports.

  • Add an @sheet <sheet-name (ident)> {<stylesheet>} rule, which can contain anything that can go in a top-level sheet. (Except @charset, but that's not really a rule anyway, just a weird encoding flag handled before parsing happens.) So for example, @sheet can contain @namespace, etc.
    • The contents of @sheet are treated as an independent stylesheet, as if they were linked in via an @import url("data:...");.
    • @sheet rules have the same placement restrictions as @import - only at the top of a styelsheet.
  • @sheet is only allowed at the top level of a stylesheet (no nesting in @media, etc), and also can't be nested inside of itself.
  • If multiple @sheet rules have the same name, all but the last is ignored.
  • When importing a stylesheet using JS imports, while the overall sheet is the default import (how it works today), all the @sheet rules are exposed as separate CSSStyleSheet objects as named exports.

So for example, given a stylesheet like:

@sheet one { .bar {...} }
@sheet two { .baz {...} }
.foo {...}

then JS can import the sheet like:

import topLevel, {one, two} from "./combinedSheet.css";

Remaining design questions:

  • since @sheet is meant to emulate an @import with a data url, should we let it take the same import restrictions - a MQ, a SQ, a layer? Or is it okay to expect these to be translated into rules inside the @sheet wrapping everything?

@sorvell
Copy link

sorvell commented Jan 10, 2024

As mentioned above, I think it's pretty important that a feature this invasive has a CSS use apart from any JS.

If there were an accompanying "apply this sheet here" @rule, it could effectively become a "mixin."

@sheet redMixin {
  :scope {
    background: red;
  }
}
.myContainer {
  @apply-sheet redMixin;
}

@mirisuzanne
Copy link
Contributor

For full mixin functionality, you would want the ability to pass in arguments.

@EisenbergEffect
Copy link

EisenbergEffect commented Jan 16, 2024

I believe that this feature also provides a way to handle a recent set of community requests that have surfaced around component customization.

If Web Component library authors distribute their CSS in a single file with @sheets for each component, then it will make it very easy for component consumers to provide custom styles or override styles of the 3rd party components. They simply replace the external CSS file provided by the library with their own. Import maps can be used if needed.

@LeaVerou
Copy link
Member

I’m wondering if @export would be a name more consistent with the rest of the web platform. I was confused when I first saw @sheet, but at least to those who write JS, export is a familiar concept. We already have @import, so this would be nicely analogous.

@justinfagnani
Copy link
Author

@LeaVerou I would think that @export should be used for a more general export mechanism that might export selectors or class names, rulesets, upcoming mixins and functions, etc. This feature is for defining an independent nested stylesheet ,so @sheet describes what it is, and they're just exported implicitly because they're not useful when not exported.

If a sheet should be explicitly exported, maybe the syntax should be @export @sheet?

@export @sheet sheet2 {
  p {
    color: blue;
  }
}

I'm not sure if @-rules can work that way though.

@LeaVerou
Copy link
Member

@justinfagnani I’m not sure what a mechanism to export selectors or class names would look like. Rulesets, mixins, functions can already be exported with this rule since its contents would be <stylesheet>, no? From an architectural pov, I would be wary of introducing features in the web platform to do things that are seen as similar but slightly different, as that tends to cause confusion and poor DX.

@Westbrook
Copy link

From an architectural pov, I would be wary of introducing features in the web platform to do things that are seen as similar but slightly different, as that tends to cause confusion and poor DX.

This is a worthwhile line of investigation, and would likely benefit from actual user testing or surveying, etc. Historically, we've done import/export within the JS context, but neither CSS nor HTML have done this bi-directionally, even in their own context, to the best of my understanding. As we expand the use of Import Attributes, it'll be good to take a stance on whether that should be more like something that exists or more clearly a new feature. That hopefully means that an answer here could be applied to the conversation around with { type: 'html' }, too.

With this in mind, I wonder if the use of @export would be more confusing as it is not reciprocal to the @import token that is already available in CSS?

@justinfagnani
Copy link
Author

justinfagnani commented Feb 26, 2024

@LeaVerou

I’m not sure what a mechanism to export selectors or class names would look like

Some ideas are in here, though without the @export keyword: #3714

Rulesets, mixins, functions can already be exported with this rule since its contents would be , no?

What if you wanted to import a single mixin and apply it in a specific place, not a whole stylesheet?

@mayank99
Copy link

mayank99 commented Mar 23, 2024

Does this feature open up the path to real "CSS modules"?

Right now, all CSS imports are side-effectful, but @sheet could make it possible to split the "import" vs "apply" parts into two steps.

  1. Declare:
    /* In foo.css */
    @sheet A {…}
    @sheet B {…}
  2. Import:
    @import "foo.css" sheets(A, B);
  3. Apply (in any order desired):
    @sheet B;
    @sheet A;

Also it would be useful to be able to rename sheets when importing. I think this functionality is essential for any module-like system.

@import "foo.css" sheets(A as X, B as Y);

@sheet X;
@sheet Y;

Lastly — and this might be far-fetched — maybe there should be some reserved sheet names that have special meaning. For example, @sheet inherit; could be used to reference host context stylesheets from within a shadow-root. This could be a solution for open-styleable.

<head>
  <style>
    @sheet A {…}
    @sheet B {…}
  </style>
</head>

<div>
  <template shadowrootmode="open">
    <style type="module">
      @sheet inherit.A;
      @sheet inherit.B;
      /* or just @sheet inherit; */
    </style>
  </template>
</div>

Regardless of the specific ideas and syntaxes I illustrated, I think we should all be thinking about how to make CSS (and HTML) better and more useful on its own, even when JS is not involved.

@justinfagnani
Copy link
Author

Right now, all CSS imports are side-effectful

CSS imports only return a CSSStyleSheet object - they don't have any side-effects.

@mayank99
Copy link

mayank99 commented Mar 23, 2024

I'm talking about CSS @imports (in CSS), rather than CSS module script imports (in JS).

@o-t-w
Copy link

o-t-w commented Mar 24, 2024

With this in mind, I wonder if the use of @export would be more confusing as it is not reciprocal to the @import token that is already available in CSS?

100% agree - I would naturally assume I could @import something I have @exported, although from the CSSWG meeting transcript it looks like you will be able to to do this so maybe the export name is ok (although I've not seen anyone answer Miriam's question about whether being able to @import an @export is even a useful thing to be able to do, whereas the JavaScript use case is very compelling by itself).
Because the thing getting exported is a CSSStyleSheet I find the @sheet syntax more clear regardless.

@export @sheet

This syntax is ugly imo. Particularly if you are using other @rules like @layer within the sheet, its too many @s that it feels a bit overwhelming. It would be unfortunate to adopt an unnecessarily verbose syntax just for the purposes of catering to a totally speculative potential future CSS feature.

@mayank99
Copy link

In my previous comment, I described a CSS way to import an existing @sheet from another file.

Another useful thing would be the ability to name a regular stylesheet from HTML, using an attribute like name (similar to <link title="…">).

<link rel="stylesheet" href="legacy.css" name="legacy" />

This would give you a @sheet named legacy can be referenced elsewhere.

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

No branches or pull requests