w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.5k stars 666 forks source link

[CSS Import Once] @import-once, or some syntax for importing a stylesheet only once #6130

Open trusktr opened 3 years ago

trusktr commented 3 years ago

Cascade Layers (https://github.com/w3c/csswg-drafts/issues/4470) does give us the ability to manage duplication caused by files @importing 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.

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.

nuxodin commented 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.

trusktr commented 3 years ago

Here is @nuxodin's polyfill: https://twitter.com/tobiasbu/status/1374489633325674500

trusktr commented 1 year ago

Here's the issue described in ESBuild's "CSS caveats" section:

https://esbuild.github.io/content-types/#css-import-order

trusktr commented 11 months ago

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.

trusktr commented 11 months ago

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

emilio commented 11 months ago

Some questions:

romainmenke commented 11 months ago

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.

nuxodin commented 11 months ago

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.

romainmenke commented 11 months ago

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.

nuxodin commented 11 months ago

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.

nuxodin commented 11 months ago

For reference. Arguments for “once” in the comments: https://jakearchibald.com/2016/link-in-body/

romainmenke commented 11 months ago

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 :

https://discourse.wicg.io/t/importing-css-only-once/1933/11

trusktr commented 8 months ago

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

trusktr commented 8 months ago

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?

trusktr commented 8 months ago

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

astearns commented 8 months ago

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

xiaochengh commented 8 months ago

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

romainmenke commented 8 months ago

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

xiaochengh commented 8 months ago

Yeah, they are different languages after all.

If there's no use case, I suggest we close this issue?

romainmenke commented 8 months ago

@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;
}
trusktr commented 8 months ago

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 commented 8 months ago

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

romainmenke commented 8 months ago

@trusktr I asked because you drew the parallel here :

Sidenote, CSS ES Modules will behave like @import-once.

xiaochengh commented 8 months ago

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.

romainmenke commented 8 months ago

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.

xiaochengh commented 8 months ago

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.

romainmenke commented 8 months ago

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.

xiaochengh commented 8 months ago

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 commented 8 months ago

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

trusktr commented 8 months ago

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?

romainmenke commented 8 months ago

Hi @trusktr,

Can you specify your question? I don't want to answer beside the point and confuse the topic :)

trusktr commented 8 months ago

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?

romainmenke commented 8 months ago

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

LeaVerou commented 8 months ago

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 @imports 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!

nuxodin commented 8 months ago

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?

LeaVerou commented 8 months ago

What does "executed only once" mean in terms of the cascade?

nuxodin commented 8 months ago

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 {}

grafik