Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-cascade] Specify how @import cycles work #9171

Open
evanw opened this issue Aug 8, 2023 · 4 comments
Open

[css-cascade] Specify how @import cycles work #9171

evanw opened this issue Aug 8, 2023 · 4 comments

Comments

@evanw
Copy link

evanw commented Aug 8, 2023

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 rule

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

@romainmenke
Copy link
Member

romainmenke commented Aug 8, 2023

Edit : I was wrong :)


Problem 2
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.

This is green for me in all browsers.
It is only Firefox when running with puppeteer or marionette that has an issue.

I filed a bug report for that failure here : https://bugzilla.mozilla.org/show_bug.cgi?id=1844683

Screenshot 2023-08-08 at 12 19 35

To prevent infinite loops/expansion I looked at the WebKit source code and found that they create a chain of ancestor stylesheets.

https://github.com/WebKit/WebKit/blob/main/Source/WebCore/css/StyleRuleImport.cpp#L123-L131


I agree that this seems underspecified.
I also couldn't find any detailed description.

But I do think there is interop between browser implementations.

@evanw
Copy link
Author

evanw commented Aug 8, 2023

Firefox (116.0.1) does behave differently than Chrome (115.0.5790.170) in that case (specifically the code I posted in this issue above, not necessarily a test from your test suite):

This behavior difference does not have to do with puppeteer or marionette.

@romainmenke
Copy link
Member

romainmenke commented Aug 8, 2023

I attributed the difference to the wrong thing.

Firefox gives the same result as Chrome/Safari when opening files from the local file system. If a web server is used it indeed shows it as red.

@emilio
Copy link
Collaborator

emilio commented Aug 15, 2023

I commented on Firefox's behavior in https://bugzilla.mozilla.org/show_bug.cgi?id=1844683#c9.

TLDR, this is Firefox more aggressively reusing stylesheets than other browsers...

For the file origin it seems our cache is not as aggressive as it could (each stylesheet ends up being in a different origin, and we avoid sharing stylesheets loaded by different origins, which means that the parent stylesheet ends up implicitly being part of the cache key).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants