Open trusktr opened 3 years ago
I would appreciate this syntax as it is polyfillable:
@import url("style.css") once;
Although once
does not really represent a media in the literal sense.
Here is @nuxodin's polyfill: https://twitter.com/tobiasbu/status/1374489633325674500
Here's the issue described in ESBuild's "CSS caveats" section:
This would import a style sheet once in the document where it is used. If used in a ShadowRoot
it would import once within that ShadowRoot
.
Why is there not much attention to this issue?
This is the single most notable feature of native CSS preventing everyone from writing highly re-usable modular vanilla CSS code.
This has long been solved in non-native CSS-alternative languages like Less (see @import (once)
) or Sass (see deprecation of @import
and its replacement @use
which strictly only imports once).
Some questions:
Is there any precedent in CSS where the first occurrence of something wins? Don't get me wrong, that is the only reasonable way to implement this I think, but seems like a weird thing which I'm not sure has a precedent.
Cascade layers.
This is the single most notable feature of native CSS preventing everyone from writing highly re-usable modular vanilla CSS code.
I don't see how this would be solved by @import-once
.
Can you give more detailed examples where vanilla CSS could have been more re-usable and modular but that this was prevented by this specific aspect of @import
?
I feel like there is some step or aspect that I am missing.
Hi Emilio, good questions.
* What is the "deduplication key", just the whole resolved URI? That is potentially problematic if the same URI is imported from two different-origin stylesheets, I think...
I think this is exactly the purpose of this proposal. Both require this stylesheet, but do not override any rules that may have been changed.....
* Is there any precedent in CSS where the _first_ occurrence of something wins? Don't get me wrong, that is the only reasonable way to implement this I think, but seems like a weird thing which I'm not sure has a precedent.
It also worked with es-modules. And you can see that there is a need for this by the fact that both Less and Sass do exactly this. (by default!)
* What happens if you import once conditionally, but with different conditions? What if those conditions change?
Only the first appearance of @import_once should be taken into account. This way, anyone who needs the stylesheet can still modify its rules, even if conditions vary over time.
And you can see that there is a need for this by the fact that both Less and Sass do exactly this.
I don't think this is necessarily true.
postcss-import
for example also has the behavior of @import-once
but that was just a mistake/bug and then it became too hard to change the default to the correct behavior.
Do we know that the same isn't true for Less and Sass?
I don't think we can say : "tools do this, so it's needed". I think it needs to be demonstrated on it's own why it would be useful.
I don't think we can say : "tools do this, so it's needed".
I agree with you, but I was already bothered by @import
's behaviour when sass and less did not yet exist.
For reference. Arguments for “once” in the comments: https://jakearchibald.com/2016/link-in-body/
Thank you for linking that @nuxodin
But those comments are 8 years old and predate cascade layers, @scope
, ...
I think it would be better if someone makes a structured case for @import-once
with concrete examples here.
This thread also seems to contain part of the examples and reasoning :
@romainmenke
Can you give more detailed examples where vanilla CSS could have been more re-usable and modular but that this was prevented by this specific aspect of
@import
?I feel like there is some step or aspect that I am missing.
I don't think we can say : "tools do this, so it's needed". I think it needs to be demonstrated on it's own why it would be useful.
It is no accident! To reproduce the main problem with today's @import
, try making a.css
, b.css
, and c.css
where b.css
depends on features (overrides things, or uses variables (in the future uses mixins and functions)) from a.css
, and where c.css
depends on b.css
. Pretend these files are in a library call css-lib
they installed locally.
Now, user needs features from b.css
, so they should be able to write this:
/*my-feature-1.css*/
@import '/node_modules/css-lib/b.css'
/* override things from b, use variables from b or a */
/*some-page.css*/
@import './my-feature-1.css'
For now, it works fine. There's no problem yet.
Later, the user learns that they want to use feature c
from css-lib
, and they try to do that in another file:
/*my-feature-1.css*/
@import '/node_modules/css-lib/b.css'
/* override or use things from b */
/*my-feature-2.css*/
@import '/node_modules/css-lib/c.css'
/* override or use things from c */
/*other-page.css*/
@import './my-feature-1.css'
@import './my-feature-2.css'
Now what the user did not realize while trying to modularize their code is that b.css
is loaded twice due to how @import
currently works. This causes at least one problem:
If they try to use my-feature-1.css
and my-feature-2.css
on the same page, they'll get differing and potentially unexpected results depending on the order in which they imported my-feature-1.css
and my-feature-2.css
. The import of c.css
in my-feature-2.css
will undesirably reset the overrides for b.css
that the user defined in my-feature-1.css
. Essentially, by writing two features, one that depended on b
, and one that depended on c
, and then trying to use both feaatures on the same page, the last feature undid the first feature!
Another problem is, in order to solve the above issue, @import
statements have to be hoisted out of the files that depend on the things they depend on. Instead, the app author has to carefully @import
all things separately, before running my-feature-1
and my-feature-2
. Essentially, there's no such thing as a module graph that will automatically resolve dependencies and run them in order.
Today's @import
is more like a pre-processor that includes the imported content inline (that's the behavior, at least, although technically at runtime there are separate sheets, but it is the behavior we're referring to).
Imagine if with JavaScript modules that multiple imports of the same b.js
file resulted in multiple executions of b.js
.
Imagine if with JS we did not get modules, but instead got #include
which would simply include other files inline, and we'd still have to include all main scripts as non-module script tags. We'd be back in the C/C++ caves holding wooden clubs and trying to invent #ifndef
again.
Basically the ask here is to have something more aligned with modern modules concepts and automatic dependency graph resolution, making it possible to easily modularize code and share libraries.
Perhaps what we need is to start aligning with ES Modules. We already have CSS Modules. Maybe we could expand on that by adding new a new import
feature behaves more like modules?
This thread also seems to contain part of the examples and reasoning :
That forum has been taken down. Have the threads been migrated elsewhere?
In the comment here:
I've shown an idea for importing things in a more module like way. For example, import a mixin from somewhere:
@import --foo from './my-mixins.css'
/* --foo is usable only within this file, not in any other file, not global. */
.some-class {
@apply --foo;
}
The idea there is it behaves more like ES Modules: we import what we need into our files, the engine determines the dependency graph for us, and modules do not re-evaluate more than once.
Some things like functions and mixins may perhaps always be module-scoped (even if the module that executed created global styles) and imported into other files explicitly (just like a JavaScript file that both creates globals and exports things).
We could potentially then also add features like module variables which are scoped to modules, and usable only where imported (current CSS vars/properties would still exist as a "global" feature).
This thread also seems to contain part of the examples and reasoning : https://discourse.wicg.io/t/importing-css-only-once/1933/11
That forum has been taken down. Have the threads been migrated elsewhere?
Not sure if it’s anywhere else, but there is https://web.archive.org/web/20220703110804/https://discourse.wicg.io/t/importing-css-only-once/1933
Now that we already have cascade layers, is there still any broken use case without @import-once
?
Duplicate imports are unfortunate, but if no functionality is broken, then we just need some browser-internal performance optimizations (like deduplicating consecutive imports in the same layer).
Edit: typo
Yes, I think it is important to separate the mental model issues from other potential uses cases for @import-once
.
I see the value in having a shared mental model for imports between JS and CSS but I am not sure that this alone is worth having two different ways imports can be done in the same language. We see how much issues this is causing in JS/Node with commonjs and es modules.
The other mentioned issues can be solved with cascade layers, scoping, ...
Yeah, they are different languages after all.
If there's no use case, I suggest we close this issue?
@trusktr Can you clarify how CSS Modules have the same behavior as the proposed @import-once
.
If I am reading the relevant specifications correctly and after some testing I don't see how they are similar.
<script type="module">
import a from './a.css' assert { type: "css" };
import b from './b.css' assert { type: "css" };
document.adoptedStyleSheets = [a, b, a];
</script>
This applies a
twice and in the same order as the array.
It doesn't only apply the first a
.
Full example:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<body>
<script type="module">
import a from './a.css' assert { type: "css" };
import b from './b.css' assert { type: "css" };
document.adoptedStyleSheets = [a, b, a];
</script>
<div class="box">
Layered (green when a is applied before b)
</div>
<div class="other">
Unlayered (green when a is applied after b)
</div>
<i>Only both are green when a is applied twice</i>
</body>
</html>
/* a.css */
@layer a, b;
@layer a {
.box {
color: red;
}
}
@layer b {
.box {
color: green;
}
}
.other {
color: green;
}
/* b.css */
@layer b, a;
.other {
color: red;
}
Now that we already have cascade layers, is there still any broken use case without
@import-once
?Duplicate imports are unfortunate, but if no functionality is broken, then we just need some browser-internal performance optimizations (like deduplicating consecutive imports in the same layer).
Using layers as a workaround is by far not the ideal dev experience. Layers are a global feature, and they'd simply be something that any CSS module could define. Plus having a proper module system also allows additional features like module-scoped features to be added.
Layers are like namespaces. You can make namespaces in JavaScript, but that's not exactly the same as sharing things via JS modules. A JS namespace can be defined and augmented by any JS module. So the idea here is to provide a proven design pattern for CSS developer to write expandable module code with (regardless if they use layers or not).
@trusktr Can you clarify how CSS Modules have the same behavior as the proposed
@import-once
.
They currently don't for code written in CSS, because @import
is disallowed in constructible stylesheets (hence disallowed in CSS Modules).
It makes a lot of sense though, for an import
feature to be restored to CSS modules and having it align with developer mentality of JS modules (modules being singletons, not includes).
This is the future of CSS development that is at stake here. Settling for "just use namespaces" (i.e. layers) is doomed to fail, once developers working in a specific namespace start to clobber things within that namespace.
Modules are the next proven evolution after namespaces.
This applies a twice and in the same order as the array. It doesn't only apply the first a.
This is certainly not ideal, a pattern to be avoided.
That is also somewhat tangential to import
. It's like saying "apply a, then b, then a", and it does exactly that.
A CSS import feature would operate in a better way, and that would be the best practice to follow.
Same thing in JavaScript:
import a from './a.js'
import b from './b.js'
a()
b()
a()
Apply a, then b, then a. It is tangential to a proper import mechanism.
If a developer wants to apply stylesheets multiple times, they can do that, while a new import feature would work properly when a root level style sheet is applied only once.
There is no way to fix existing APIs, we can only provide a new API to create a new best practice, and existing APIs should not prevent us from doing so. We can only create the new way to do things well, but we can't erase existing features (well, it is possible, but unlikely).
@trusktr I asked because you drew the parallel here :
Sidenote, CSS ES Modules will behave like
@import-once
.
Using layers as a workaround is by far not the ideal dev experience.
I don't see layers as a workaround but the canonical solution, unless I see a use case where layer-based solutions are fundamentally broken.
And layers are not global. With concepts like sublayers and layered imports, layers work pretty much the same way as modules. For example:
/* base.css */
@layer a { ... }
@layer b { ... }
/* main.css */
@import url('base.css') layer base;
@layer my-own-business { ... }
The styles in base.css
are imported into layers base.a
and base.b
, with the "global" namespace untouched. And you can have another library using main.css
by importing it into another layer (say main
), and then everything goes into layers named main.*
.
In fact, there's no such a thing as a global layer namespace -- you can always import things into another layer.
Btw there are still open issues where CSS modules and layers don't work well with each other (like #7002). They still seem fixable within the scope of layers, though.
There is one thing that a JS modules can, that cascade layers cannot mimmic. In JavaScript it is possible to import a module or parts of a module while renaming those things.
This implies that two modules can export different things under the same name and that conflict resolution can be handled by the consumer.
This isn't possible in CSS. You can influence which named thing (keyframe animation, custom property,...) is declared last and thus "wins", but you cannot use both side by side.
But it is relatively rare for such naming conflicts to occur in such a way that it cannot be resolved by the author.
Hmm, actually I found a case where layers can't fix (modified from https://esbuild.github.io/content-types/#css-import-order):
Say we are using two libraries a.css
and b.css
, and in case of conflict, we want b
to take precedence. So it ends up like:
@import url(a.css) layer(a);
@import url(b.css) layer(b);
However, a.css
and b.css
both have a reset:
@import url(reset.css) layer(reset);
/* real business*/
Then the import order is still reset, a, reset, b
, causing the double-reset issue as in the original example.
@import-once
will fix this case, but only if a
and b
use the same reset. If eg they are from different sources with different reset sheets, then @import-once
doesn't help, either.
I feel like this is a more general issue that we can't arbitrarily re-order imported sheets, and have limited ways to tackle it if these sheets do overlapping things. One way is @layer
, and the other is !important
, which is known to be an antipattern if it's used to reorder style rules.
I don't have a good idea how this can be fixed from the CSS language side. Maybe it's much simpler if CSS authors make sure one sheet has one unique purpose, for example, a sheet doing actual styling should not perform a reset on its own. In other words, each style sheet should be targetting a particular layer in the overall business -- which perfectly matches the purpose of @layer
...
You can influence which named thing (keyframe animation, custom property,...) is declared last and thus "wins", but you cannot use both side by side.
That's unfortunate. But CSS modules can't help here, either.
It's a general design issue of CSS that everything is the global namespace (except layers), and the current solution/relief is to scope things by shadow DOM.
Then the import order is still reset, a, reset, b, causing the double-reset issue as in the original example.
I don't think this is correct.
Any imports in an already layered stylesheet would be further layered.
So that would be a.reset
and b.reset
.
Layer names are global but you cannot define layers outside your own layer and you cannot place styles in another layer that is not your own.
Sorry if my previous comment was a bit sloppy.
The final layer ordering should be a.reset, a, b.reset, b
. The equivalent all-in-one sheet after importing and layer-reordering is:
contents of reset.css
remaining contents of a.css
contents of reset.css
remaining contents of b.css
remaining contents of the main style sheet
Here reset.css
is applied twice, and some of a.css
may be reset.
@trusktr I asked because you drew the parallel here :
Sidenote, CSS ES Modules will behave like
@import-once
.
@romainmenke That's indeed the chat that I heard regarding CSS Modules. EDIT: I don't recall where that was. Maybe it was in https://github.com/WICG/webcomponents/issues/759.
Looks like I overlooked a behavior of Layers with respect to @import
.
To make the topic easier to understand, I decided to make a working reproduction of the original issue on CodePen. See this set of pens:
We can see that in the final result, the text is pink, although we expect it to be cyan. This is because a.css
gets instantiated as a stylesheet one time during the import of my-feature1.css
which has the cyan override, then a.css
gets instantiated as a second subsequent stylesheet during the import of my-feature2.css
which does not have the cyan override, thus the second a.css
stylesheet sets the color back to pink
.
Now, here's the same thing ported to Layers:
In the layers example, the final result is cyan as we wanted, not pink, although it seems like this will get confusing because we still have all the duplicate sheets, but now we have more layers than is obvious across all of them, and this may cause other issues if I'm seeing things correctly.
What exactly is happening with the layers version?
Hi @trusktr,
Can you specify your question? I don't want to answer beside the point and confuse the topic :)
This question?
What exactly is happening with the layers version?
Can you explain how we end at the cyan result in the layers version? What is happening with the layers? Can you describe the structure of the stylesheets and the layers as they relate to those sheets?
Also just read through most of https://github.com/WICG/webcomponents/issues/759 and if I am reading that correctly there are a lot of voices who want to align JS and CSS dependency graph behavior. But there is also very good info on why they are different. Most notable https://github.com/WICG/webcomponents/issues/759#issuecomment-490685490
What is happening with the layers?
I am assuming you are referring to :
@layer some-lib.some-lib.some-lib {
h1 {
text-decoration: underline;
}
}
Where @layer some-lib.some-lib.some-lib
can be surprising given that you each time only wrote a single some-lib
.
When writing @import "foo.css" layer(some-lib)
you are loading "foo.css" and applying all of its contents into a new some-lib
layer.
Any cascade layers inside the loaded stylesheet would become nested cascade layers.
Do this a few times and you end up with some-lib.some-lib.some-lib
.
This is surprising when coming at this from a JS module graph mental model but important to keep in mind that cascade layers are a different feature and they do not need to fit that mental model.
They there are different doesn't meant that they aren't useful for conflict resolution between loaded styles.
I don't want to go into too much detail here because there are good docs for cascade layers and they aren't the focus of this thread.
Can you explain how we end at the cyan result in the layers version?
pink
is declared in some-lib.some-lib.some-lib.some-lib
which is a more deeply nested cascade layer than cyan
which is in some-lib.some-lib
, so cyan wins out.
But this is a really artificial example and I think that it isn't a good demonstration of the utility of cascade layers. That all the nested cascade layers share the same name part also doesn't help in discussing it :)
Can you describe the structure of the stylesheets and the layers as they relate to those sheets?
See the first note :)
I think this specific example mainly demonstrates that CSS becomes a mess when you import a complex dependency graph with nested cascade layers while re-using the same name part for each cascade layer. Trying to coherently describe its structure would be very hard.
I want to try to do that, but not sure how helpful that would be to the overal thread :)
On first occurrence of @import-once "/some.css" it behaves just like @import. On every occurrence after that for the same URL (based on it's fully-qualified value), it is effectively skipped (no-op).
I think this would create a very unpredictable mental model when it comes to predicting the outcome of the cascade. Even in JS, it's not exactly how modules behave, since you still get the order of dependencies and exports, it's only the side effects and HTTP requests that are skipped.
I think we need a discussion about the use cases this is trying to solve.
One component is that duplicate @import
s should not fire more than 1 HTTP request. That is an internal optimization that doesn't require new syntax.
Is there more than that that use cases need? If so, I'd love to hear more about them!
We all agree that this isn't about optimizing HTTP requests. I think comparing it to JS modules is very apt:
Perhaps @need url.css
or @require url.css
would be more fitting terms?
What does "executed only once" mean in terms of the cascade?
My apologies for the confusion with the term 'executed'. What I meant was 'applied'.
/* website.css */
@import 'https://cdn.skypack.dev/sanitize.css';
:where(body) {
margin:2rem;
}
/* Some other dependency.css */
@import 'https://cdn.skypack.dev/sanitize.css';
.more {}
Cascade Layers (https://github.com/w3c/csswg-drafts/issues/4470) does give us the ability to manage duplication caused by files
@import
ing the same shared files: it allows us to place the (duplicate) imports in a high-cascade layer so that duplicates will not override the overrides of files that import the shared code to override the shared values.(If that's not clear, let me know and I'll make a live example of the unexpected problems it causes.)
Although this will help, it won't actually prevent the duplication.
Adding something like
@import-once
would solve the duplication problem regardless of cascade layers, but would still be complimentary to cascade layers.@import-once
would do what the name implies: it only does what@import
normally does when a fully-qualified URL has not been imported before.@import-once "/some.css"
it behaves just like@import
.@import-once
will have a link to the sameCSSStyleSheet
in theirimport-once
rule object.Only one stylesheet with content from
some.css
will be loaded. The behavior is based on the fully-resolved URLs, otherwise differing relative paths pointing to the same file would still load, causing duplication.So it's not completely a no-op after the first occurrence: it still needs to resolve the URL, and it determines whether to continue or not based on the fully-qualified URL.
Sidenote, CSS ES Modules will behave like
@import-once
. They also talked about making@import
in CSS Modules behave like the@import-once
idea here for various reasons (but that would be a hack, better to let the CSS work as expected, and give CSS@import-once
in addition).Also see the
@import (once)
syntax from Less. It is the default behavior for@import
in Less.@import-once
syntax is bike-sheddable.