Declarative CSS Modules and Declarative Shadow DOM `adoptedstylesheets` attribute
What problem are you trying to solve?
Developers using Declarative Shadow DOM (DSD) do not have a way to share declarative stylesheets without using script. There are many workarounds, but none are ideal.
What solutions exist today?
If a developer wants to share styles between the light DOM and DSD, they can either:
a. Duplicate individual inline style rules between shadow roots. This is problematic because it often leads to lots of duplicated style definitions.
b. Use <link rel> tags for shared CSS files in each shadow root. This is problematic because external stylesheets are an asynchronous render-blocking resource, and could cause an FOUC.
c. Use the Javascript adoptedStyleSheets property on the declarative shadow root to share stylesheets. This is problematic because it can cause an FOUC and only works with Javascript enabled.
None of these options are ideal.
How would you solve it?
Declarative CSS Modules allow developers to define style sheets that by default do not apply to the main document, but instead are stored in the global Module Map. An adoptedstylesheets attribute on the <template> element will allow developers to opt Declarative Shadow DOM elements into sharing these stylesheets via module syntax.
Proposed syntax:
<script type="css-module" specifier="/foo.css">
#content {
color: red;
}
</script>
<my-element>
<template shadowrootmode="open" adoptedstylesheets="/foo.css">
<!-- ... -->
</template>
</my-element>
The <script> tag allows for styles to be defined without impacting the main document. The adoptedstylesheets attribute on the <template> element will look up the module specifier and associate it with the referenced <style> block.
Explainer: https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ShadowDOM/explainer.md
Anything else?
No response
The most alarming part of this proposal to me is introducing the specifier="" attribute. That seems like it would have significant implications for the module system.
How necessary is that to this idea? Could you instead use, e.g., the id="" attribute?
The most alarming part of this proposal to me is introducing the
specifier=""attribute. That seems like it would have significant implications for the module system.
That's the most noble part of this proposal. It's a feature which allows CSS module to be defined inline.
How necessary is that to this idea? Could you instead use, e.g., the
id=""attribute?
I don't think we want to use id for this purpose since id's are not shared across shadow boundaries.
I saw this was discussed at WHATNOT and some new issues were opened. Let it be clear that I don't consider my above objections resolved and I would not be OK with this proceeding further in its current state. This completely new way of manipulating the module map needs significant further discussion and ideally an alternative should be found that either generalizes beyond this specific new feature, or avoids manipulating the module map entirely.
If you want to open a new issue to start that discussion that might be good.
I don't think we want to use id for this purpose since id's are not shared across shadow boundaries.
This doesn't seem like a blocker to me.
Ultimately you have to decide whether you want to pierce shadow boundaries by using a global shared namespace, like the module map / global ID map, or whether you want to use a per-shadow root namespace. The current proposal wants a global shared namespace. So assuming that, some potential workarounds are:
- Have the web developer hoist the shared stylesheets out into the global space, to make it more obvious that they're doing something that is not constrained by shadow DOM instead of having this implicit shadow-crossing behavior of populating the module map.
- Have some new syntax, e.g.
globalid="", for populating the global ID map while inside the shadow tree. This seems like a bad idea to me, but so does populating the global module map, and I'd rather we have DOM elements populate the global ID map than the global module map, since they're DOM elements. - Use some of the more esoteric techniques that people use to forward IDs across shadow boundaries, e.g. for
::part(), or the recent ARIA proposals.
If you want to open a new issue to start that discussion that might be good.
The module map behavior is probably the largest piece of this proposal, so I think it makes sense to discuss here.
Have the web developer hoist the shared stylesheets out into the global space, to make it more obvious that they're doing something that is not constrained by shadow DOM instead of having this implicit shadow-crossing behavior of populating the module map.
The module map is already global when done via script, and it already supports stylesheets. The only difference with this proposal is that it's being done with markup instead of script. See https://web.dev/articles/css-module-scripts.
@domenic, can you clarify why you're opposed to using the module map for this? Note that this was also discussed in two TPAC breakout sessions, where the discussions included all 3 implementers and people from the WC developer community, and feedback was generally favorable towards the module approach.
It's certainly possible that the specifier attribute is too vague. @robglidden suggested export instead of specifier and import instead of adoptedstylesheets in order to generalize and provide intent (see linked slides here).
Have some new syntax, e.g. globalid="", for populating the global ID map while inside the shadow tree. This seems like a bad idea to me, but so does populating the global module map, and I'd rather we have DOM elements populate the global ID map than the global module map, since they're DOM elements.
The module map is already global when accessed via script (and already supports global stylesheets), so I don't see the advantage of introducing another global map. I would also expect a globalid to work anywhere an IDREF is expected, which might be too broad.
Use some of the more esoteric techniques that people use to forward IDs across shadow boundaries, e.g. for ::part(), or the recent ARIA proposals.
Are you referring to shadowrootreferencetarget? It is somewhat similar, but only allows references to propagate inside the shadow root - https://github.com/WICG/webcomponents/blob/gh-pages/proposals/reference-target-explainer.md, so styles defined in shadow roots couldn't be added to the global map (i.e. it could allow the shadow root to access global styles, but it wouldn't allow shadow roots to define styles that can be used globally).
We also probably don't want to diverge too far from the existing script-based CSS Module Scripts. Requiring dependencies on other features (especially esoteric ones) seems counter to that.
Note that this was also discussed in two TPAC breakout sessions, where the discussions included all 3 implementers and people from the WC developer community, and feedback was generally favorable towards the module approach.
Having attended at least one of those I don't think that's an accurate characterization. There was quite a bit of confusion as to how this would work, that it was different from everything else we have in HTML to date, and that this would warrant further discussion.
There were a number of open questions that we need to continue discussing -- thanks @KurtCattiSchmidt for starting to get issues opened for these -- but my impression was that the module-based approach was the one that had the most energy and positive sentiment around it out of the many ideas Kurt presented for solving this problem.
The modules-based approach had gotten particularly positive feedback both during and outside of TPAC from the WebComponents dev community (thanks to folks like @justinfagnani and @westbrook from sharing their thoughts). And while questions and concerns were raised from implementers, I wasn't picking up on feedback recommending we move away from the overall modules-based approach or pursue a different path.
(I definitely don't want to mischaracterize anyone's position; notes from the sessions can be found here and here.)
@domenic (and others!) it would be great if you can share the specific concerns you have with the module-based approach so we can discuss. In the meantime we plan to continue thinking through the open questions that have already been raised and working on shoring up some of the more hand-wavy areas of the proposal.
The idea that:
<script type="css-module" specifier="/foo.css">
#content {
color: red;
}
</script>
<my-element>
<template shadowrootmode="open" adoptedstylesheets="/foo.css">
<!-- ... -->
</template>
</my-element>
Is an almost 100% declarative translation of:
import style from '/foo.css' with { type: 'css' };
class MyElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.adoptedStyleSheets = [ style ];
this.shadowRoot.innerHTML = '...';
}
}
customElements.define('my-element', MyElement);
Is incredibly exciting to the Web Components Community Group and the greater community made up by its members' various networks. It's seemingly only a few steps away from fully declarative custom elements, which seem just as interesting to implementors as they do to the developing communities served by the hard work those implementors are putting into browsers. The way it implies a future syntax for other module scriptable content types is also quite nice, and quite needed in the content of each (JSON, CSS, HTML, WASM, et al).
If there is anything the WCCG can do to support moving the conversation around this API forward (testing [we've been working on WPT coverage for a number of features as of late and are happy to expand out efforts for such an important API], feedback [point us to the right place], demos [we actually had a series of presentation in the Spring around Declarative Custom Elements concepts, many of which featured the need for an API like this], etc.), please let us know!
it would be great if you can share the specific concerns you have with the module-based approach so we can discuss.
I think I did? I'll try restating:
- Allowing modification of the module map so that it contains importable (i.e. non-inline) modules that do not correspond to network resources is unprecedented, and requires much more significant design work. (E.g., interaction with import maps, especially mutable import maps. Or just the mutability of the
specifier=""attribute itself!) - Doing so only for CSS modules is poor design. Any such solution should be designed for all module types.
- Reusing the module map for what are not really modules, but instead a global map of constructible stylesheets, is unnecessarily entangling two parts of the platform.
- This pierces the shadow root boundary, which generally not allowed for declarative features. As you point out, scripts can do it via imperative APIs, but we have not allowed markup inside a shadow root to poison global namespaces so far, and we shouldn't do so here.
- The global ID map may suffice; I have not seen anyone describe why authors need to be able to reference adopted stylesheets across shadow roots and cannot instead hoist them outward. (E.g. @Westbrook's example would work fine with the global ID map.)
I appreciate that authors find the connection between these technologies exciting. But we have to look at use cases, and how we can accomplish them without overly complicating or special-casing the platform. So far I have not seen a use case that requires modules as a technology; the use case is roughly "declarative adopted stylesheets", which can be accomplished in many other, less complex and less precedent-breaking ways.
I'm sorry that I wasn't in the room where this was discussed at TPAC, but I want to stress that the WHATWG working mode is async-first, and that this is the first thread where this proposal has been presented to the WHATWG community in a way that equitably allows all participants to comment without attending a specific meeting.
I'm not sure a global map would be beneficial to a page level developer achieve their goals in that not populating the module graph with /foo.css (or similar) would prevent <my-other-element> from being able to benefit from the shared code in the CSS module script later in the lifecycle of the page, seemingly?
import style from '/foo.css' with { type: 'css' };
class MyOtherElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.adoptedStyleSheets = [ style ];
this.shadowRoot.innerHTML = '...';
}
}
customElements.define('my-other-element', MyElement);
While I could envision paths by which a global ID map were bent to the will of the developer in this case, it's hard to see a path that does not require a developer to more heavily rely on tooling in that the ability to convert the standard code of:
import style from '/foo.css' with { type: 'css' };
// elide complexity
this.shadowRoot.adoptedStyleSheets = [ style ];
to something like:
this.shadowRoot.adoptedStyleSheets = [ someMagicMapBoundToWindow.get('foo.css') ];
This wide separation of what is written and what is shipped seems like a lot when the module graph already exists exactly for this sort of responsibility.
On the topic of other module types, some feel that even including them in the explainer is preparing it for derision (due to an even broader expanse of unanswered questions). However, there is specific reference to HTML module scripts in the explainer. I greatly agree that clarifying that the specifier attribute can apply across module types (include JS itself!) and how would be a great way to solidify the benefits of this approach!
@domenic does bring up a question I hadn't seen before about the mutability of the specifier attribute itself.
- Should it be some sort of magic attribute wherein it can only be set with side effects once? If possible, feels like a cheat code!
- Is there a path to removing something from the module graph? This would allow it to just be live, but the cascade effects of things no longer having resolved values seems like it opens even more questions.
- Should it be "write only" to the module graph? Meaning it populates every value that it is ever set to with its contents, were the value not otherwise available in the graph. This does align with some of the questions about possible spamming of the module graph at TPAC.
Good questions @domenic!
Allowing modification of the module map so that it contains importable (i.e. non-inline) modules that do not correspond to network resources is unprecedented, and requires much more significant design work. (E.g., interaction with import maps, especially mutable import maps. Or just the mutability of the specifier="" attribute itself!)
I agree, this is probably the most controversial part of this design. However, it's not decided whether this will not correspond to network requests. See https://github.com/whatwg/html/issues/10711. Regardless of the outcome of that issue though, developers want an alternative that can be done without external CSS files.
Good points on import maps - I will need to do some investigation on how these can interact. Also mutating the specifier attribute is a good call out - this behavior will need to be clarified.
Doing so only for CSS modules is poor design. Any such solution should be designed for all module types.
This doesn't preclude doing so for other types of modules. We can certainly spec other modules in a similar way.
Reusing the module map for what are not really modules, but instead a global map of constructible stylesheets, is unnecessarily entangling two parts of the platform.
The module map is already being used as a global map of constructable stylesheets. See https://web.dev/articles/css-module-scripts.
This pierces the shadow root boundary, which generally not allowed for declarative features. As you point out, scripts can do it via imperative APIs, but we have not allowed markup inside a shadow root to poison global namespaces so far, and we shouldn't do so here. The global ID map may suffice; I have not seen anyone describe why authors need to be able to reference adopted stylesheets across shadow roots and cannot instead hoist them outward. (E.g. @Westbrook's example would work fine with the global ID map.) I appreciate that authors find the connection between these technologies exciting. But we have to look at use cases, and how we can accomplish them without overly complicating or special-casing the platform. So far I have not seen a use case that requires modules as a technology; the use case is roughly "declarative adopted stylesheets", which can be accomplished in many other, less complex and less precedent-breaking ways.
I covered quite a few alternatives (including global ID scoping withing shadow DOM) in my TPAC discussion, and why each of them comes with unfortunate tradeoffs - I would encourage you to take a look at my slides. Happy to discuss alternatives here though, in case those tradeoffs are not dealbreakers.
I'm sorry that I wasn't in the room where this was discussed at TPAC, but I want to stress that the WHATWG working mode is async-first, and that this is the first thread where this proposal has been presented to the WHATWG community in a way that equitably allows all participants to comment without attending a specific meeting.
No need to apologize, and agreed, this is the right place to discuss these kinds of concerns.
Thanks all for the discussion! I'm glad we're able to get into the technical details like this.
@Westbrook: I'm a bit confused by your comment. Before, you were talking about making declarative CSS modules + shadow DOM an easy translation from the imperative versions. Now you are talking about... making one version of imperative CSS modules + shadow DOM into a different imperative version? That doesn't seem related to the goal of this proposal, from what I can tell.
But I'll try to engage anyway:
to something like:
this.shadowRoot.adoptedStyleSheets = [ someMagicMapBoundToWindow.get('foo.css') ];
No "some magic map bound to window" necessary. You can use document.getElementById() to access the global ID map.
How to get from the element to the CSSStyleSheet object depends on some details, discussed below.
To @KurtCattiSchmidt:
I agree, this is probably the most controversial part of this design. However, it's not decided whether this will not correspond to network requests. See #10711. Regardless of the outcome of that issue though, developers want an alternative that can be done without external CSS files.
To be clear, adding more complexity to a feature I'm objecting to, by also adding network request support, doesn't ameliorate my objections to the base feature :)
Good points on import maps - I will need to do some investigation on how these can interact. Also mutating the
specifierattribute is a good call out - this behavior will need to be clarified.
I'd encourage you to take a step back and engage with alternatives, as I think they'll be a lot more feasible to reach consensus by sidestepping a lot of these problems.
This doesn't preclude doing so for other types of modules. We can certainly spec other modules in a similar way.
In my experience, module features need to be designed holistically. I wouldn't be comfortable with doing something CSS-specific that changes the fundamental nature of the module system in such a way. It would need to support all existing module types (JS, wasm, CSS, JSON) from day 0 in order to reach consensus.
The module map is already being used as a global map of constructable stylesheets. See https://web.dev/articles/css-module-scripts.
That's not really accurate. (Please try to refer to the spec, instead of third-party articles which can be imprecise.)
The module map is used as a global map of modules. Sometimes those modules can be CSS modules. CSS modules are exposed to JavaScript as CSSStyleSheet objects.
It is not used a as global map of CSSStyleSheet objects. The fact that sometimes CSStyleSheets can be accessed via it, under certain network setups, does not mean it's a good place to insert CSSStyleSheet objects.
I covered quite a few alternatives (including global ID scoping withing shadow DOM) in my TPAC discussion, and why each of them comes with unfortunate tradeoffs - I would encourage you to take a look at my slides. Happy to discuss alternatives here though, in case those tradeoffs are not dealbreakers.
I assume you're referring to slide 20? Here are my takes on the problems identified there. TLDR they are not really problems.
Does not align with script-based adoptedStyleSheets
Script-based version takes an object reference – this is very different than a corresponding attribute that takes ID’s
I can't understand what point this is concretely making. Remember, we need to stay focused on the use cases and requirements (which your slides list). This seems to be an aesthetic preference between objects vs. strings?
Can you give an example of a thing a web developer wants to accomplish where strings vs. objects makes a difference?
If this is about just the logistics of how to assign to adoptedStyleSheets, like in @Westbrook's question above, then my suggestion is:
- Use
<script type="text/css" disabled id="foo">. - Then you'd get the
CSSStyleSheetreference viadocument.getElementById("foo").sheet. - Modify https://drafts.csswg.org/cssom/#dom-documentorshadowroot-adoptedstylesheets to allow such non-constructed disabled
CSSStyleSheetobjects.
There are alternatives that are more complex, based off of defining inline CSS module scripts (https://github.com/whatwg/html/issues/7367) and then accessing their default export (https://github.com/whatwg/html/issues/7415). I would not object to going that route, as long as you did the design work for CSS/JSON/WASM all at the same time. But it's much more complex, so since the above seems like it accomplishes your use cases, I would suggest starting there.
(In case it is confusing why I would not object to something based off of inline CSS module scripts, but I do object to the specifier="" design: inline module scripts are an established concept. Giving URL specifiers, as well as a place in the module map, to non-networked resources, is much more disruptive.)
No ability to define styles without applying them to the light DOM
The disabled="" attribute solves this.
Not extensible for other resources
Again, we need to stay focused on the use case, of "declarative adopted stylesheets". Creating over-generic solutions, ahead of the actual use cases, is dangerous.
If you want to expand the requirements to include other resources, gather evidence for the web developer need, and solve all of them at the same time, then I'd be cautious but supportive. But we cannot tackle just CSS, and vaguely hope that our solution will work well for other resources in the future.
To be clear, adding more complexity to a feature I'm objecting to, by also adding network request support, doesn't ameliorate my objections to the base feature :)
Good point :) However, the premise of this feature is to allow adoptedStyleSheets without requiring external files. This is what web developers have requested. Regardless of whether this initiates a fetch or not, I do think that we should address that developer need.
In my experience, module features need to be designed holistically. I wouldn't be comfortable with doing something CSS-specific that changes the fundamental nature of the module system in such a way. It would need to support all existing module types (JS, wasm, CSS, JSON) from day 0 in order to reach consensus.
That makes sense. If we do go down this route, I'm happy to incorporate those into the spec.
That's not really accurate. (Please try to refer to the spec, instead of third-party articles which can be imprecise.) The module map is used as a global map of modules. Sometimes those modules can be CSS modules. CSS modules are exposed to JavaScript as CSSStyleSheet objects. It is not used a as global map of CSSStyleSheet objects. The fact that sometimes CSStyleSheets can be accessed via it, under certain network setups, does not mean it's a good place to insert CSSStyleSheet objects.
The current behavior of import sheet from './styles.css' with { type: 'css' }; will insert a CSStyleSheet object into the module map. This proposal provides an alternate means of doing that via markup.
Your original post said it was "unnecessarily entangling two parts of the platform" - can you clarify which two parts of the platform you're referring to? I assumed you meant 1) stylesheets and 2) the module map, but this response further confirms that they are already entangled.
I assume you're referring to slide 20? Here are my takes on the problems identified there. TLDR they are not really problems.
The biggest issue with any ID-based approach is that ID's are scoped to shadow roots. This makes any ID-based approach fundamentally incompatible with shadow DOM, as one of shadow DOM's main features is ID isolation. You mentioned earlier about a global ID, which would address this issue, but that comes with its own set of tradeoffs. But I'm happy to discuss those in this thread.
I can't understand what point this is concretely making. Remember, we need to stay focused on the use cases and requirements (which your slides list). This seems to be an aesthetic preference between objects vs. strings?
My slides didn't really cover this, but in-person I mentioned the asymmetrical nature of things like:
foo.adoptedStyleSheets = [some object]; vs
foo.setAttribute("adoptedstylesheets", "some list of id's");
The only existing attribute I can think of that take a list of ID's are the Aria properties like aria-labelledby and aria-describedby, and these handle this issue by having different name for the DOM API that takes object references - ariaLabelledByElements; (currently only supported in WebKit).
That's an option here, but since the DOM API existed first (unlike with aria-labelledby and ariaLabelledByElements), I'm attempting to align with that as much as possible.
Can you give an example of a thing a web developer wants to accomplish where strings vs. objects makes a difference?
It's more of a consistency thing, as I don't see any other API's that behave so differently between the DOM API and HTML attribute. But I might wrong here.
The disabled="" attribute solves this.
By making a stylesheet disabled, it's also disabled when adopted (and thus not applied), so I don't see how it solves this issue. See codepen here.
Using a different type attribute on the <style> tag as proposed here does address this though, as the only valid types are 1) empty and 2) "text/css" https://html.spec.whatwg.org/#update-a-style-block.
Again, we need to stay focused on the use case, of "declarative adopted stylesheets". Creating over-generic solutions, ahead of the actual use cases, is dangerous.
If you want to expand the requirements to include other resources, gather evidence for the web developer need, and solve all of them at the same time, then I'd be cautious but supportive. But we cannot tackle just CSS, and vaguely hope that our solution will work well for other resources in the future.
This is great advice, much appreciated.
-
Most broadly stated the developer use case is "devs want to share a stylesheet instance across shadows, without script and without a network request".
-
Zooming out and looking at things holistically, there's other similar instances of this problem like "devs want to reference a centrally-defined chunk of HTML and use it to populate the contents of multiple shadows, without script and without a network request". Together with the use case in the previous bullet these could be generalized to say "developers want to define a resource in a central place and apply it to shadows without duplicating unnecessarily, and without network requests or script". Plugging into modules seems to be the best path forward for solving this holistically, for multiple content types.
-
We think the current usage of
adoptedStyleSheetshas the cleanest mapping to the declarative approach, and the strongest developer demand, so that's where we started. We have example code of how this could work with HTML in our explainer and will flesh that out in more detail, and will also think through how this can work for other module types where appropriate.
The current behavior of
import sheet from './styles.css' with { type: 'css' };will insert a CSStyleSheet object into the module map.
This is not accurate; I wrote out the actual specified behavior in more detail above.
But, this kind of discussion about exactly what the spec says is not that important, since it's not focused on the use cases and how we can solve them. So I'm happy to drop this.
Your original post said it was "unnecessarily entangling two parts of the platform" - can you clarify which two parts of the platform you're referring to?
I mean, inline HTML markup (whether modules or <style>) and the external-resource-based module map.
The biggest issue with any ID-based approach is that ID's are scoped to shadow roots. This makes any ID-based approach fundamentally incompatible with shadow DOM, as one of shadow DOM's main features is ID isolation. You mentioned earlier about a global ID, which would address this issue, but that comes with its own set of tradeoffs. But I'm happy to discuss those in this thread.
Please explain this issue. In particular, I've asked repeatedly in this thread for cases where the stylesheet cannot be hosted outside the shadow DOM. Nobody has provided one so far, and all examples (e.g. in the presentation, or in https://github.com/whatwg/html/issues/10673#issuecomment-2427981316) locate the stylesheet outside of the shadow DOM.
Maybe there's a misconception that you cannot refer to ID-based elements outside the shadow DOM, from inside the shadow DOM? That's not correct. The shadow DOM design constraints are:
- Cannot influence global namespaces (e.g. the global ID space, or the global module map) using markup from inside the shadow DOM.
- Markup outside the shadow DOM cannot see into the shadow DOM.
There's no constraint which prevents markup inside the shadow DOM from seeing outside the shadow DOM.
My slides didn't really cover this, but in-person I mentioned the asymmetrical nature of things like:
Thanks for explaining. It sounds like we agree that this isn't a requirement.
By making a stylesheet disabled, it's also disabled when adopted (and thus not applied), so I don't see how it solves this issue. See codepen here.
That is why I included the line in my proposal about modifying the behavior of the adoptedStyleSheets setter.
This is great advice, much appreciated.
[3 bullets]
Thanks so much for summarizing the use cases and zooming out. I agree with everything you wrote here, even including "Plugging into modules seems to be the best path forward for solving this holistically, for multiple content types"!
The part where we diverge is the use of the module map, vs. using inline modules. So my suggested decision tree would be:
- Decide whether you're willing to tackle all module types at once, or want to focus on CSS.
- If only CSS: work on something involving
<style>elements, like my suggestion withdisabled=""above, or yours with a differenttype="". - If all module types: work on something based on inline modules, not modifying the module map.
Thanks for the questions @domenic
I meant to reply earlier, so I'll go back to your first list
it would be great if you can share the specific concerns you have with the module-based approach so we can discuss.
I think I did? I'll try restating:
- Allowing modification of the module map so that it contains importable (i.e. non-inline) modules that do not correspond to network resources is unprecedented
I think these modules should generally correspond to importable modules. We're trying to figure out how to serialize a CSS module import that's been adopted to a shadow root. The URL that the inline module uses should match the resolved URL of the CSS module so that the CSS import shares the module map entry.
- Doing so only for CSS modules is poor design. Any such solution should be designed for all module types.
I agree, and personally want this feature to be a general module inlining feature. I know of use cases for inline JS modules that are sill importable by external modules.
- Reusing the module map for what are not really modules, but instead a global map of constructible stylesheets, is unnecessarily entangling two parts of the platform.
The should really be modules. It shouldn't be a map of stylesheets, but the existing map of modules, some of which are CSS modules.
- This pierces the shadow root boundary, which generally not allowed for declarative features. As you point out, scripts can do it via imperative APIs, but we have not allowed markup inside a shadow root to poison global namespaces so far, and we shouldn't do so here.
Working across shadow roots is a critical feature. Without that this whole thing doesn't solve anything wrt the current situation where you have to duplicate all stylesheets used in shadow roots. The point is to deduplicate all the stylesheets that have to be repeated today.
- The global ID map may suffice; I have not seen anyone describe why authors need to be able to reference adopted stylesheets across shadow roots and cannot instead hoist them outward. (E.g. @Westbrook's example would work fine with the global ID map.)
It's not possible to hoist these modules because you only discover them as you render components. You don't know ahead of time which components will be rendered. You could hook module loading on the server and emit every CSS module that's imported in the graph, but that could be wildly inefficient. Highly complex dynamic pages may only render a small fraction of the components in the graph for any given URL.
For example, let's say you have root component A that dynamically renders either child component B or C, each of which dynamically render D or E (for B), or F or G (for C). On the server you don't know if you're going to render B or C until you run the actual logic for A, and on down the tree.
So you render this:
<x-a>
<template shadowrootmode="open">
<x-c>
And here is where you need to emit the styles for <x-c>. If that style isn't usable is other shadow roots, then you have to emit it again for the next <x-c>. To emit the styles only once, but also hoist them, you'd have to hold onto them until the end of any top-level shadow roots, then emit them, which would be really bad.
Here is a thought-exercise repo that demos using import maps, which already in Chrome support CSS modules from files and (could?) sidestep or at least scope the specifier attribute issue, while also providing the necessary capabilities of a mutable adopt attribute, modifiable stylesheets, and a working import().
<script type="importmap">
{
"imports": {
"dashedstyles": "./dashedstyles.css",
"solidstyles": "./solidstyles.css",
"toggleBorderSizes": "./toggleBorderSizes.js"
}
}
</script>
To me, the "simple" approach of using a script or style tag's id or global id to assign styles to adoptedStyleSheets could simplify the "export" side of this proposal by avoiding the module map altogether.
But it would put more complexity on the "import" side, because to build out features like a mutable importing attribute and modifying the underlying CSSStylesheet associated with an exported style would seem to require some kind of parallel tracking and referencing system.
So +1 to "we cannot tackle just CSS, and vaguely hope that our solution will work well for other resources in the future" and we should do the "design work for CSS/JSON/WASM all at the same time".
To me, import maps, and the multiple import maps work already well underway hint that polymorphic URL module specifiers would be both confusing to web developers and difficult to implement.
It might be a more reasonable tradeoff to have a constraint similar to what is already on import maps that declarative inline modules must be must be declared and processed before any <script> elements that import modules using specifiers declared in the map.
Although import maps don't support inline modules (at least today, though this thought exercise implies that might be worth considering), they are at least declarative-ish. And their preprocessor-ish nature parallels in part the need for declarative shadow DOMs to be HTML-parsed early. And like bare specifiers they provide a convenient way to reference, add, modify, and remove adopted stylesheets.
From a Web developer point of view, this would be simpler than the above sounds, as the gif in the repo show:
- file-based CSS modules could be referenced, adopted, updated, and readopted without any change to the module system
- inline CSS modules would need to be declared early, like import maps (or through import maps?)
- no confusing polymorphic specifier URL
Resources other than CSS do not have an exact equivalent to adoptStylesheets, so that needs to be thought through also.
For anybody not already following along, there is a parallel discussion about this same topic here: https://github.com/w3ctag/design-reviews/issues/1000
This is a really exciting proposal! One potential issue I want to raise is with polyfills: could there be a declarative way to detect support, which would allow for falling back to <link rel=stylesheet>?
I ask because a web author using this API will have to support older browsers for some time, and although you could imagine injecting <script>s to detect support, this kind of defeats the purpose of a JS-less API (e.g. FOUC).
The analogy here would be with <script nomodule> – having a way to detect browsers that don't support modules is very powerful for emitting the ideal scripts for both older and newer browsers. Just a sketch:
<my-element>
<template shadowrootmode="open" adoptedstylesheets="/foo.css">
<link rel="stylesheet" href="/foo.css" noadoptedstylesheets> <!-- no-op on newer browsers -->
</template>
</my-element>
In my opinion, putting CSS styles inside <script type="css-module" specifier="/foo.css">...</script> is confusing both for web developers and for syntax highlighters in code editors. I'd propose a simpler solution:
Just use a set of global <style id="foo" type="css-module">...</style> tags in <head> (reusing the type property; if type != "text/css" it shouldn't be applied to the document), then reference them using <link rel="stylesheet" href="#foo"> from inside the Declarative Shadow DOM. Just modify the HTML spec so that if the <link rel="stylesheet">'s href property starts with a #, e.g. #foo, then don't make a request but refer to the contents of the <style id="foo"> element if it exists.
Using the # symbol as a dedicated inline module specifier could also untangle the race-conditiony dual-use '/foo.css' specifier in the original proposal.
In other words, add a fourth type of specifier for inline modules to the three current types of specifiers (relative, absolute, and bare).
This would work with dynamic and static imports and import maps, and be consistent with the existing module system. And open up the idea of inline modules to a broader range of use cases.
And the # symbol is already used to mean the id of an element:
- as a fragment identifier in URLs,
- in CSS selectors to select elements by their id attribute
- in an href attribute on a web page to link to an anchor within the same page
The # symbol as a reference to a light DOM element by id could also be used for the simpler approach to adoptable style sheets by light DOM element ids.
In my opinion, putting CSS styles inside
<script type="css-module" specifier="/foo.css">...</script>is confusing both for web developers and for syntax highlighters in code editors.
I disagree and think it's more consistent for a feature that inlines modules. All inlined modules would be represented with a <script> tag regardless of what type of module it is. I think this is a simpler alternative than a tag per module type.
Just use a set of global
<style id="foo" type="css-module">...</style>tags in<head>
This doesn't work because it would require you to know all the styles you want to inline before you emit any of your components. You'd have to emit all possible styles, even if you only use a subset of them for the page.
@justinfagnani What's the difference between <script type="css-module" specifier="/foo.css"> and <style type="css-module" id="foo"> in practice? Both of them should be included along with a corresponding web component. The backend (or SSG) should be responsible for injecting only the relevant CSS styles and web components into HTML documents.
An important consideration is what we're gonna do with HTML modules. Using script element there isn't great because then you can't have nested HTML modules (script elements can't be nested).
An important consideration is what we're gonna do with HTML modules. Using
scriptelement there isn't great because then you can't have nested HTML modules (script elements can't be nested).
Ah, true. And there's no way to escape a </script> tag inside a script, right?
I think HTML modules and recently here, are due for a back-to-basics TAG design re-review in light of modern developments of import attributes, import map constraints and DSD as well as this current discussion of inline modules and declarative importing.
Even back in 2017 (see), an HTML module could be as simple as a document fragment. I sometimes find that a good starting point, particularly given CSS and JSON import attributes solve part of multi-type module packaging as originally envisioned for HTML modules.
Just a small update here - We've made some additions to the explainer to start to cover some of the things we're discussing here, and we're also looking into TAG's suggestion of @sheet as a potential direction.
Good to see in the updated explainer consideration of import attributes like SVG.
I note that this already works from inside a shadow DOM:
<icon-button>
<template shadowrootmode="open">
<button>
<svg>
<use href="./icons.svg#folder-tree"></use>
</svg>
</button>
</template>
</icon-button>
What you can't do now is <use href="#folder-tree"></use> to a light DOM svg element from within a shadow DOM like you can elsewhere.
Being able to use <use href="#folder-tree"></use> as in the explainer would be better, though apparently at the cost of cloning? And with the specifier technique in the current explainer, ./icons.svg would have a different meaning depending on where used, or introduce backwards incompatibility.
Using a reference to a light DOM element or (inline) specifier might be better, ala <use href="##folder-tree"></use>.
Based on feedback here and elsewhere we have the explainer for @sheet that we believe handles the primary scenarios we're discussing and would love any feedback - https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/AtSheet/explainer.md
Probably can move specific questions/comments on that to this thread - https://github.com/w3c/csswg-drafts/issues/11509 or new issues in our explainers repo.
I met with the Edge team this morning to get some background on this project. Let me dump my resulting thoughts here, referencing the current version of the explainer.
Summary:
- I think the current design adds unfortunate complexity and will inevitably force some awkward changes to existing HTML elements and concepts. But, it's probably the best way to meet the constraints and solve the stated problem.
- I strongly encourage designing a system that works for inline JSON and JS modules, and not just CSS.
- The proposal's use of bare specifiers and string-valued module map keys is pretty nice, and sidesteps some of the problems with
#idrefs. - I think the proposal's current suggestion for actually referencing the inline modules seems underdeveloped and could probably be improved.
I've been hoping since the beginning of this project to keep the complexity low, by avoiding messing with the module system as much as possible (e.g. encouraging the @sheet-based approach). Even if we had to involve the module system, I was hoping we could avoid changing how the module map works by leaving inline scripts out of the module map, and introducing an exports property that scripts could access, e.g. this.adoptedStylesheets.push(document.getElementById('my-css-module-script').exports.default). (See https://github.com/whatwg/html/issues/7367.)
However, I've come to understand that the constraints here don't really allow that. We want a global map of style sheets, mixing inline and external, which can be declaratively attached to shadow DOMs (without script involved). I am forced to agree with the conclusion that the module map is our best option for those constraints.
Given this, we're going to have to face some hard design choices, as we're doing several unprecedented things:
-
Adding inline modules to the module map. Previously, the module map was URL-keyed, with entries being 1:1 with externally-fetched URLs. Changing this invariant will impact many parts of the system.
-
Adding inline modules that are not JavaScript. Previously, we could assume the
<script type=module>meant "JavaScript module", but now we need some syntax for inline modules that are not JavaScript. -
Adding a way of referencing inline modules. Previously, all references were via either URLs (e.g.
<script>'ssrc="") or via module specifiers (importstatements). How will we reference inline modules, from both JS and HTML?
Some general notes:
-
I strongly believe that the system we design here should not be CSS-specific. At the very least it must work for JSON as well, as they are very similar (both non-JavaScript leaf nodes in the module graph). It should also work for inline JavaScript modules, although this adds more complexity since they are not leaf nodes. WebAssembly modules are probably OK to exclude, since as a binary format, inlining them isn't that sensible.
-
It's tempting to jump to
#fooas a reference or specifier syntax for inline modules. However, this is fraught, as it takes concepts from the URL and ID world and then tries to port them over to the specifier world. What does./bar#foomean? Does#foomean something different if the page has a<base>element? Can you use bothspecifier="#foo"andsrc="#foo", and if you do, do they mean the same thing? How does this interact with theid=""attribute? How does this interact with shadow DOM, which is traditionally an ID-scoping root (to various degrees)? It's not impossible to make#foowork, but it's not nearly as easy as it might appear.The Edge team's proposal sidesteps this issue entirely by just using the bare specifier namespace for inline module scripts. This feels a bit messy, since traditionally and conventionally bare specifiers are mapped to "third-party packages". But it's elegantly simple, and lets web developers come up with their own conventions if they'd prefer to namespace off inline modules from such "third-party packages". (E.g., developers could do
specifier="style!foo"or"js!foo"for inline modules, and leave"foo"for such packages.)
Now, for the hard parts of the design:
How to introduce the inline modules
There are no elegant choices here. We have to use an existing element, so <script> or <style>. (Or, theoretically, <xmp> or <textarea>, but let's not go there.) There aren't enough elements to have something separate for each of JS, JSON, and CSS.
The Edge team's proposal suggests <script type=module> for JS, <style type=module> for CSS, and doesn't tell us what JSON will look like. Maybe <script type=json>. It seems like a reasonable option, as long as they are willing to explicitly spell out their plans for JSON.
The best alternative I can think of is <script module> for JS, <script module=css> for CSS, and <script module=json> for JSON. This is more elegant in my opinion, but (a) until all browsers implement this, it has bad back-compat properties, as all three get interpreted as classic scripts; (b) it introduces some confusion between <script module> and <script type=module>, and in general adds another attribute to <script> which has enough confusing attributes already; (c) web developers, for some reason, seem to hate putting CSS module scripts inside <script> elements.
So, between these two, the Edge team's proposal is probably what we've got to work with.
How to store inline modules
The Edge team proposes changing the module map key from always being a URL, to either being a URL (mapping to an externally-fetched resource) or a string (mapping to an inline module).
Any such two-valued key approach essentially breaks the integration with import maps, as they point out, since import maps always map from specifiers to URLs. So, there's no way for an import map to ever map a specifier to one of these inline modules. I think that's essentially fine, as there's no known use cases for mapping in this way.
(An import map could map from one of the inline specifiers to a URL, essentially making it impossible for an inline module to be imported. This also seems fine; developers should just not do that. Their explainer also covers this.)
The alternative is to keep using URL keys, but reserve some special range of URLs to mean inline modules. The two possibilities here are:
-
A new URL scheme, e.g.
inlinemodule:foo. -
Fragment-based URLs, e.g., for the module map corresponding to the page with URL
https://example.com/foo, anything of the formhttps://example.com/foo#baris an inline module. (Or should it be the page whose base URL ishttps://example.com/foo?)
As noted above, the latter seems kind of attractive, and it might indeed be worth exploring. But my instinct is that it won't work well. For example, consider an external module at https://example.com/foo/lib/app.mjs which wants to import an inline module. Does import '#bar' work? By the usual URL resolution rules, that would import https://example.com/foo/lib/app.mjs#bar, not the desired https://example.com/foo#bar. We could patch module specifier resolution so that specifiers of the form '#bar' don't use the usual URL resolution rules. But is that wise?
Overall I think the current proposal is, again, one of the least-bad options. I'd kind of like to see the fragment-based version explored in more detail so we can point to a smoking gun of why it doesn't work, but I suspect it won't. (Maybe the smoking gun is shadow DOM interactions?)
How to reference inline modules
Due to their use of the bare specifier namespace, the answer here is pretty straightforward for the from-script case, as the proposal points out. Where it gets more interesting is in references from HTML.
The proposal so far includes only one reference from HTML: the shadowrootadoptedstylesheets="" attribute, which takes a space-delimited set of specifiers. Questionably, it filters to exclude specifiers that reference "fetching" modules, so it works for either inline modules, or for already-fetched external modules. It also mutates the module map by inserting placeholder entries for specifiers that are not yet seen, so that you can forward-reference inline modules that are later in the document. That is:
<script type=module>
import "foo" with { type: "css" }; // this will error since there is no "foo"
</script>
<template shadowrootadoptedstylesheets="foo"></template>
<script type=module>
import foo from "foo" with { type: "css" }; // will not error!
console.log(foo); // `foo` will be an empty CSSStyleSheet!
</script>
This design seems pretty scary to me, although it may again be the case that it's the least-bad option. But I hope we can do better.
Stepping back, I see at least three issues that we should try to address:
-
Mutating the module map from what looks like a reference is strange. It would be good to avoid that.
-
We generally need a system for dealing with not-yet-present specifiers in these references. E.g., inline modules that are later in the document, or still-fetching external modules, or not-yet-fetched external modules. Ideally, we would support all types of modules in these references. This probably means some sort of messy system for updating
adoptedStyleSheetsover time, and maybe render-blocking the document until they arrive? -
A space-separated list of specifiers is not very extensible or powerful.
On that last point, the explainer suggests maybe using a <link rel="adoptedstylesheet" specifier="foo"> syntax. Then you could use sheet="" or media="", and we'd have room for future extensibility, e.g. maybe respecting blocking="".
This is again an area where we have no great choices. I don't like reusing <link>, because right now <link> is always about a hyperlink to the URL specified in the href="" attribute. (There's the slightly strange case of rel="expect", but even for that we tried hard to give it hyperlink-ish semantics, and it uses #foo URLs in the href="" attribute.) This would be the first case where we're basically using <link> to mean "a generic reference to something", and departing from href="".
But there aren't a lot of better options. We could abuse <script> some more, e.g. <script fromspecifier="foo" media="..." sheet="..."></script>, but that doesn't seem better. We could similarly abuse <style>. I guess we could try to introduce a new element, e.g. <adoptedstylesheet fromspecifier="" media="..." sheet="..."></adoptedstylesheet>? Is that worth it?
(Note that I'm avoiding using specifier="", because the proposal currently uses that to introduce new module scripts. My fromspecifier="" is for referencing them. Better names welcome.)
What's tricky about this area is that unlike references from scripts, we don't have any known use cases for declarative references to JSON or JS modules, so we risk over-designing something that's CSS module-centric and then needing to start the process all over again once we discover a need for JSON references. Maybe we should try harder to hypothesize what it would look like for JSON? E.g., just imagine something like
<template>
<... some reference to a JSON module full of data ...>
<td>${name}</td><td>${value}</td>
</template>
and then figure out what syntax you might use for that JSON module reference, that could also work for our CSS case?
Has it ever been suggest to reverse the setup; why not inject instead of adopt?
<link rel="inject stylesheet" href="custom-element.framework.css" type="text/css; components=custom-element, another-element">
<link rel="inject stylesheet" href="custom-element.overwrite.css" type="text/css; components=custom-element">
<custom-element></custom-element>
<style rel="inject stylesheet" type="text/css; components=custom-element">
:host {
color: green;
}
</style>
<custom-element></custom-element>
The key difference is that the <link> or <style> decides its destination rather than <template> that adopts a module through an shadowrootadoptedstylesheets attribute or a <link> tag. You could use the type attribute to define which component to target. Like charset=UTF-8, you define a specific characteristic for the mime type.
The <link> and <style> tags with rel="inject stylesheet" could be anywhere on the page. If they are above the first custom-element: no FOUC, if after than probably a FOUC.
You could say the above is a declarative solutions for the following.
const injectableSheets = document.querySelectorAll('style[rel="inject stylesheet"]');
for (const injectableSheet of injectableSheets) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(injectableSheet.textContent);
const parsedMime = parseMime(injectableSheet.type);
const components = document.querySelectorAll(parsedMime.components);
for (const component of components) {
component.shadowRoot.adoptedStyleSheets.push(sheet);
}
}
const injectableLinks = document.querySelectorAll('link[rel="inject stylesheet"]');
for (const injectableLink of injectableLinks) {
const sheet = new CSSStyleSheet();
const resp = await fetch(injectableLink.href);
sheet.replaceSync(await resp.text());
const parsedMime = parseMime(injectableSheet.type);
const components = document.querySelectorAll(parsedMime.components);
for (const component of components) {
component.shadowRoot.adoptedStyleSheets.push(sheet);
}
}
In my opinion this simplifies the setup:
- It's like defining reset css for custom-elements. It's crystal clear to developers.
- The solution targets the custom element, more precisely its shadow root, not the shadow root contents
- A template tag != a shadow root, which is what the current solution is basically opening up to. How many
shadowroot*attributes do you want to invent? - It circumvents the whole
specifierdiscussion: the custom-element tag name is the specifier. No modules or importmaps required. - With the current adopt suggestion, there might be a solution to adopt stylesheets with DSD, when (also) creating new custom elements dynamically through
document.createElement('custom-element'), the creator of the element must call.shadowRoot.adoptedStyleSheets.push()or the element itself must have a solution built-in for when it is created within a dynamic context. The inject solution solves both DSD and dynamic contexts. - It's a declarative solution for very simplistic javascript.
- No additional tag/attribute required, it relies on known semantics. The
relattribute defines that it should not be injected into the full document but rather to a specific subset of the document. - It could be extended by adding more possible parameters to the (mime)
typeattribute, e.g.text/css; fragment=template-id.
@domenic Thanks for weighing in and being open to working something out here.
I've been hoping since the beginning of this project to keep the complexity low, by avoiding messing with the module system as much as possible
Stepping back, we could say that this gives us the opportunity to expand the module system to support inline and bundling use cases and support more media types. (I'm sure you recall that a key reason HTML Imports failed was that it was not integrated with the, then emerging, module system.)
I strongly believe that the system we design here should not be CSS-specific.
Completely agree. Although I'd like to tackle HTML later, I think anticipating supporting it is critical.
It's tempting to jump to #foo as a reference or specifier syntax for inline modules. However, this is fraught, as it takes concepts from the URL and ID world and then tries to port them over to the specifier world.
I'd like to avoid this, in particular, because # URLs are currently tied to IDs which are scoped in a way undesirable here (unique in Shadow DOM).
However, I do think it would be valuable to contemplate a way to reference module sub-resources directly from the URL/specifier. Finding syntactic space for this is likely challenging, but it seems really valuable to support arbitrary bundling.
web developers, for some reason, seem to hate putting CSS module scripts inside
This seems worth unpacking to inform the design. The first issue is a taste opinion: the desire to segregate content by type (I want to separate my peas and carrots). However, there's also complexity around the behavior of scripts and its coupling to Javascript (JS): (1) JS can be disabled, (2) JS may not run for crawlers, (3) JS may cause poor performance and/or FOUC. All of this is potentially addressable, but there's a lot of momentum against using script here.
It's important to note, however, that the processing model we want for these elements is more like script than style in that like script, these "modules" should execute and be done. Modifying the specifier or textContent should have no effect, as it does for styles that otherwise produce stylesheets.
We generally need a system for dealing with not-yet-present specifiers in these references.
I'm not sure this is required. The streaming scenario requires that a style be available before a declarative shadowRoot tries to use it. Because in these cases servers are often very performance constrained, there's a desire to avoid a pre-processing stage to, say, emit all the styles at the top. For web components, the desire is that an element can be treated as an atomic unit emitted in one pass. The styles and shadowRoot content are available with the element definition so, presumably, something like this would be workable:
<x-foo>
<style type="module" specifier="x-foo">...</style>
<template shadowrootmode="open" shadowrootadoptedstylesheets="x-foo">...</template>
</x-foo>
<!-- ... -->
<x-foo><template shadowrootmode="open" shadowrootadoptedstylesheets="x-foo">...</template></x-foo>
A space-separated list of specifiers is not very extensible or powerful.
Can you elaborate? While it's true that an element with attributes provides more configurability, it's not clear it's needed. For example, you don't get the ability to configure media via an attribute, but this can also be done inside the style.
I don't like reusing
<link>
I agree for the same reasons, and I also do not want to use an element at all for this. Having extra elements in the Shadow DOM is a performance penalty and potentially confuses equivalency between declarative and imperatively created elements.
What's tricky about this area is that unlike references from scripts, we don't have any known use cases for declarative references to JSON or JS modules, so we risk over-designing something
This is one spot I think it makes sense to leave room for HTML.