Multiple stylesheets per file
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>`);
cc @dandclark @yuzhehan
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
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.
@justinfagnani A small typo correction for clarity: you've got {sheet1, sheet1} in a few places where I think it should be {sheet1, sheet2}.
Thanks @dandclark! Updated
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?
@gsnedders correct
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.
cc @bmeck this seems like a good addition for the arbitrary module names, if my bundling still want to reference the names somehow.
I like this as it also helps on a better usage of dynamic import() for css files, IMO.
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. 👍
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.
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?
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 {
...
}
}
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:
- I assume that the top-level import is still the overall stylesheet containing the
@sheetrules, yeah? We just additionally specify that the@sheetrules produce additional exported sheets in the module object, keyed to their name? - The
@sheetcontents 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@namespacerule in the outer sheet (and presumably could contain a@namespacerule of its own). - What's the behavior of
@media (...) { @sheet {...} }? I presume the answer needs to be "it's invalid", and@sheetobjects are required to be top-level. - 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? - If you have multiple
@sheet foorules, 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?
- Yes.
- Independent. While there could be something interesting about additional work around
@import @sheet sheetNamein order to share across the single file, the goal is to keep the various@sheetentries separate from each other, but bound to the single file download. - Invalid.
@sheetshould be top level. - Invalid.
@sheetshould be top level. - 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...
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.
Shouldn’t user-defined sheet (and layer) names be either quoted strings or dash-idents?
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.
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.
That makes sense. 👍
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,@sheetcan contain@namespace, etc.- The contents of
@sheetare treated as an independent stylesheet, as if they were linked in via an@import url("data:...");. @sheetrules have the same placement restrictions as@import- only at the top of a styelsheet.
- The contents of
@sheetis 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
@sheetrules 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
@sheetrules 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
@sheetis meant to emulate an@importwith 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@sheetwrapping everything?
Great summary @tabatkins !
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.
I do want to be careful and conservative about this point, since it would be the first introduction of named exports in a stylesheet module. How might it interact with some of the ideas in https://github.com/w3c/csswg-drafts/issues/3714 - that might create named exports for things like class names like userland modules do today.
I don't think it's much of a concern, since it might be rare to have top-level rules/exports and bundled stylesheets, and even if you did you might be very unlikely to have name collisions, and even if you did have collisions, you're probably generating a bundle with a tool that can handle that by renaming things. But it's something to consider.
fwiw, one reason to expedite this feature would be that if it lands by the time all browsers support import assertions and CSS module scripts, then we might not need to handle cases where browsers support CSS modules but not nested stylesheets. That would simplify using CSS modules scripts in production a lot.
This may be closely related to @justinfagnani’s comments: it would feel strange to add named @sheet to CSS just so that JS works better, therefore I would expect that CSS’s own @import could make use of sheets to only import select parts of other stylesheets.
Furthermore, could named sheets be exposed as URL fragments then, e.g. <link rel=stylesheet href="style.css#sheet">?
Also, I still like the idea of namespaced custom properties inside variable references from #6099:
@import CD from url(corporate-design.css);
a {color: var(-CD-link-color);}
Again, we've historically preserved the single-dash idents for vendor prefixes, and there's no good reason for us to invade that space now. Please stop suggesting it.
Furthermore, could named sheets be exposed as URL fragments then, e.g. ?
Hm, this could be interesting actually. @justinfagnani, how do you feel about that? On the JS side it would still be multiple import statements, but only one request (assuming caching semantics allow reuse of the resource).
Furthermore, could named sheets be exposed as URL fragments then, e.g. ?
Hm, this could be interesting actually. @justinfagnani, how do you feel about that? On the JS side it would still be multiple import statements, but only one request (assuming caching semantics allow reuse of the resource).
This sounds really smart to me. It could work as <link rel=stylesheet href="style.css#sheet">'s inside shadow roots too.
Features that broaden the scope to as many style loading techniques and frameworks as possible sound great to me.
@import (or something similar) between bundled @sheet would be great too, though that may require settling on a semantic for @import in CSS module scripts.
As mentioned above, I think it's pretty important that a feature this invasive has a CSS use apart from any JS. On the surface, I like the idea of allowing @import/link to access fragments of a larger css file – but I'm curious what the use-case is for that. My immediate guess would be performance optimization, but I'm not actually sure it helps there? Now we're loading a large file, when we only want access to one part of it. But maybe it helps cut down on parse/render time? Is there some other story for how this helps CSS authors write more modular CSS, without relying on JS for the feature to do anything?
Regarding potential use cases for this feature, this could be very useful in styling things like declarative shadow DOM, especially if there's ever a mechanism for non-constructed stylesheets to be applied to adoptedStyleSheets.
For a CSS only usage, I'd say leaning into something like <link rel=stylesheet href="style.css#sheet"> would be one path, though more invasive. This allows the download of all of the CSS that might be needed for an experience in one request without leaving a bunch of unused CSS attached to the page (performance hit) until you need it. Even if the "need it" doesn't come till the loading of a subsequent page, you'll now have a faster load time as the file is cached from the previous request.
@calebdwilliams also hits this on the head for the benefits it brings to declarative shadow DOM. Having a path for a file with multiple @sheets to be including once and then the declarative shadow roots to be able to take parts of that would be a great benefit to that process. This likely requires a little more invasion as it would benefit from something along the lines of https://github.com/WICG/webcomponents/issues/939 as well as ensuring that all CSSStylesSheets were "constructible stylesheets". Each of these could then be addressed later (regardless of their initial acquisition) while also allowing for the form of sub-querying that @sheets would open up a lot of options around how these styles could be used.