Description
I'm the author of esbuild and I'm trying to write a CSS bundler. As far as I can tell, there is no CSS specification that explains what to do when there's an import cycle. This is surprising to me so I'm guessing I'm wrong and there is such a specification. If so, please disregard this issue!
Background
Here's everything I could find about how to interpret @import
rules from the CSS cascade specification (where the behavior of @import
appears to be specified):
(Click for quotes from the specification)
From https://drafts.csswg.org/css-cascade-5/#at-import:
2. Importing Style Sheets: the
@import
ruleThe
@import
rule allows users to import style rules from other style sheets. If an@import
rule refers to a valid stylesheet, user agents must treat the contents of the stylesheet as if they were written in place of the@import
rule
and from https://drafts.csswg.org/css-cascade-5/#import-processing:
2.2. Processing Stylesheet Imports
When the same style sheet is imported or linked to a document in multiple places, user agents must process (or act as though they do) each link as though the link were to an independent style sheet.
This doesn't say anything about how cycles work. It also doesn't disallow them, and indeed they are allowed by all browsers. A literal interpretation of the specification would cause a hang when a cycle is encountered due to infinite expansion. One such implementation is postcss-import
which can have this exact problem: postcss/postcss-import#462. But browsers don't hang, so they must be doing something else. The only hint that I've found about how browsers might do this is in the description for issue #4287:
That makes sense — although I’d found no spec text specifically addressing circularity, it seems implicitly permitted because I think the implementation only needs to ‘insert’ them once, in the ‘furthest-down’ place they’re referenced, in order to achieve the specified behavior.
This makes sense. You can traverse the import graph in reverse to find this "furthest-down" ordering and you can handle cycles by not visiting a given node more than once. That lets you handle cases like this:
(Click for example code with a cycle)
-
entry.css
@import "foreground.css"; @import "background.css";
-
foreground.css
@import "reset.css"; body { color: red; }
-
background.css
@import "reset.css"; body { background: green; }
-
reset.css
@import "entry.css"; body { color: green; background: red; }
This example should set both the body's color
and background
to green. Following the "furthest-down" algorithm gives the order foreground.css
+ reset.css
+ background.css
+ entry.css
which successfully reproduces the behavior observed in browsers. This is what esbuild currently implements.
Problem 1
The "furthest-down" algorithm doesn't actually work. The problem is that @layer
is specified to take effect in the "furthest-up" location instead of the "furthest-down" location (defined here). For example, this doesn't work:
(Click for example code with this edge case)
-
entry.css
@import url("a.css"); @import url("b.css"); @import url("a.css");
-
a.css
@layer a { body { background: red; } }
-
b.css
@layer b { body { background: green; } }
Following the "furthest-down" algorithm gives the order b.css
+ a.css
+ entry.css
which is incorrect. It causes layer b
to come before a
(and therefore the color red
wins) while in the browser layer a
comes before b
(and therefore the color green
wins).
Problem 2
Browsers don't even have consistent behavior in the presence of import cycles. This is understandable because the behavior import cycles doesn't appear to be specified, but it seems undesirable to leave this unspecified and for browsers to have divergent behavior. Here's an example of a case with inconsistent behavior:
(Click for example code with inconsistent browser behavior)
-
entry.css
@import url("b.css"); @import url("c.css");
-
a.css
@import url("red.css"); @import url("b.css");
-
b.css
@import url("green.css"); @import url("a.css");
-
c.css
@import url("a.css");
-
red.css
body { background: red; }
-
green.css
body { background: green; }
This CSS sets the body to green in Chrome but red in Firefox. Following the "furthest-down" algorithm gives the order red.css
+ green.css
+ b.css
+ a.css
+ c.css
+ entry.css
which results in a green body. But it's not clear which browser is "correct" without a specification.
Conclusion
It would be helpful for me if this behavior was specified. Then I could build esbuild to a specification instead of what I have been doing, which is trying to reverse-engineer what the behavior is supposed to be from how existing browsers work. What got me to file this issue was a) the realization that browsers aren't even consistent so reverse-engineering won't work and b) the realization that I have no idea how to implement @layer
in combination with @import
in a CSS bundler, especially in the presence of cycles. I'm creating a new issue for this because while #4287 is related, it's discussing how the JavaScript API should behave while I'm interested in how CSS is supposed to behave.