Skip to content

css: keep layer order across hoisted external imports and redundant imports, and make a non-CSS @import an error - #48521

Draft
robobun wants to merge 9 commits into
mainfrom
robobun/edafe568/css-import-order
Draft

robobun wants to merge 9 commits into
mainfrom
robobun/edafe568/css-import-order

Conversation

@robobun

@robobun robobun commented Nov 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #48421

Problem

  • bun build can order cascade layers differently from the source files, so a browser applies another rule. A hoisted external @import with a layer passes earlier layers (items 1b, 2). A redundant import declares a layer without its @media condition (item 3).
  • An @import of a non-CSS file is dropped with exit code 0 and no message (item 4). scan_css_imports (src/bundler/LinkerContext.rs) did not read the record kind.

Fix

  • The hoist in find_imported_files_in_css_order declares each passed layer ahead of the import: an @layer statement, or @import "data:text/css," layer(w) screen; where a statement is not valid.
  • A redundant import of such a file prints @media print{@layer b;}. The parser is not changed.
  • A non-CSS @import is the error Cannot import "a.svg" into a CSS file, with a note.
  • Verified: test/bundler/esbuild/css.test.ts (50 of 173 fail on main), bundler_html.test.ts (2), test/bake/dev/css.test.ts (3).

Background

  • A cascade layer gets its priority where its name first appears.
  • CSS allows @import only at the top, so Bun hoists external imports. Only @layer statements can precede an @import.
  • Not built: one @layer statement for all names, a warning, generated layer names, rules in a data: import. Items 1a and 1c get a docs section. Costs: reply on the issue.

Downsides

  • Builds with a non-CSS @import fail, for example @import "bulma"; (main is bulma.scss).
  • Each data: stand-in gives one CSP report per page load under style-src 'self'. Bundle bytes: +41 (item 2), +9 (1b), +14 (item 3), 0 for 12,289 real style sheets.
  • Release binary: +28,672 bytes.
Notes

Commits. One per item, in this order: item 4, item 3, item 2, a test move, item 1b, docs, a size cut. Item 1b is its own commit (29fae81) so that it is easy to change or to drop. Open question for a maintainer, asked on the issue: is an error right for item 4, or a warning for one release first?

Item 4, a non-CSS @import.

  • scan_css_imports walked every import record with one loader table, which is the rule for url(). It gave an error for js, ts, json and similar targets, and accepted file, text, toml, wasm, base64, dataurl and sh targets, because a url() can point at those. It now branches on the record kind first. An @import whose target has no CSS AST and another loader than css is an error. url() records keep the table and its message.
  • An @import of a .ts or .json file gets the same new message in place of Cannot import a ".ts" file into a CSS file, so the two tests that pin it change.
  • Three tests of css: fix layer ordering bugs left after #47797 #47882 imported an .svg as an import that loads nothing. They now use a cyclic import, which prints the same bytes on main.
  • Dev server: a style sheet with an invalid import was removed from the CSS roots and then put back by a later importer or by the entry point list. A hot update that added @import "./x.ts" to a linked style sheet printed the error and then served the page with the import dropped. The roots are now removed after both places. A target that did not load (an onLoad plugin that threw) has the error itself, and its importer gets none.
  • @import "bulma"; with bulma 1.0.4: main builds with exit code 0 and the bundle has only the other rules. esbuild 0.27.2 stops with No loader is configured for ".scss" files.

Item 3, a layer under a condition in a redundant import.

  • A new import order entry, LayersOf(file), stands for a redundant earlier import of a file whose flat list of layer names does not say what its rules declare. It prints the layer skeleton of the file, built at link time from the rules after the minifier.
  • A file keeps the flat list, and the bytes of main, when it has no layer, when its top-level @layer rules are exactly its layer names (no walk of nested rules, no allocation), or when one walk finds no layer that is first declared under a condition and the same order (once per file per build).
  • It also keeps the flat list directly ahead of a file that starts with @namespace: a block there makes @namespace invalid.
  • Conditions are @media (unless it always matches), @supports and @-moz-document. Chrome 153 declares a layer inside @container, @scope, @starting-style and a style rule in every case, so those are not conditions.
  • Credit: css: keep the earlier copy of a file with a conditional layer in every import order #46934 (closed) had the list of rules that count. Jarred closed it with "The fix is right, but none of the 10,116 real stylesheets it was checked against has this input". This version does not touch the parser, and a file with no layer under a condition prints the same bytes.

Items 2 and 1b, the hoist.

  • The new walk runs only when an external import has a layer. Otherwise the hoist is the two loops it was.
  • Each layer that an entry behind the imports declares becomes a stand-in ahead of the first layered external import that would pass it. The layers come from import conditions, from @layer statements ahead of an @import, and from the rules of a file whose flat list is exact (the same check as item 3).
  • The entry itself stays where it was. A style sheet that is applied with no @import rules, as a hot update of the dev server is, has the layers it had.
  • The name is in layer() of the import, not in the data: sheet. So the layer is declared also where a content security policy blocks the URL (checked in Chrome 153: one report, the right winner).
  • No stand-in, and the order of main, from the first layer that a statement or one import cannot declare: a layer inside an anonymous layer, a layer under two media query lists or two supports() conditions, a layer that a file declares under @media, and every layer after one of these.
  • Two pinned outputs were wrong and are now right (css/CSSAtLayerOrderConditionalImportOfACycleAheadOfAStatement, css/CSSImportPrologueStatementUnderConditionsBeforeExternalImport). One right output gets a stand-in (css/CSSAtLayerOrderConditionalImportsBehindAnUnknownAtRule, +61 bytes).

Still different from the source files (main prints the same bytes for each):

  • 1a: rules in no layer, or in the layer of the external import, that come before it. Only the order of the text orders them.
  • 1c: an external import inside a bare layer import.
  • A layer that a file declares only under @media, ahead of a layered external import.
  • Layers that only the external style sheet declares.

Random import graphs. 480 graphs for each set of options. A model of CSS Cascading 5 compares every pair of rules and the layer order, for every state of the conditions. The model agrees with Chrome 153 on 1,200 of 1,200 evaluations. Wrong graphs on main (af566a3), then with this PR. No graph that is right on main is wrong with this PR.

Options main PR
@media and @supports blocks in files 103 0
External imports 252 0
External imports, cycles, empty files, import conditions 271 38
The same with bare layer imports 184 109

Chrome 153, source files against the bundle, 60 named cases, screen and print: 44 differ on main. With this PR 10 differ (the cases above), 4 are build errors, 46 are the same.

Cost for a build that hits none of the cases.

  • Allocations inside the import order pass (gdb counters on the allocator, debug builds, main against PR): a diamond with no layers 33 and 33, a Tailwind layer statement plus a fonts import 32 and 32, reset.css then a fonts import 31 and 31, a redundant import of a file with top-level layers 30 and 30. A redundant import of a file with nested layers 30 and 31. Item 3's input 30 and 35, item 2's input 33 and 45, 1b's input 34 and 47.
  • size_of is the same on main and the PR: CssImportOrderKind 56, CssImportOrder 104, Layers 24 bytes. src/css/css_parser.rs has no change.
  • Real style sheets: 12,289 files of 568 npm packages, each bundled with release builds of main and of the PR. 11,540 print the same bytes, 749 fail with both, 0 change.
  • Release binary (size -A, main af566a3 against 4b0bd9b): .text +22,528, .rodata +4,096, stripped file +28,672 bytes.
  • Not measured: syscalls (no strace in the container), and allocations in the scan step (it waits on the thread pool, which the gdb counter stops). The new branch of scan_css_imports allocates only when it reports an error.

Other suites run with the debug build: test/bundler/css (217 pass), test/bundler/esbuild/loader.test.ts, test/js/bun/css/css.test.ts, test/regression/issue/20546.test.ts, test/regression/issue/28914.test.ts.

Tools. The cases, the Chrome probes, the cascade model and the counters are on the branch robobun/edafe568/css-order-tools (scripts/css-order-check/). They are not part of this PR.

Open PRs that edit the same functions: #46232, #46040.

An `@import` whose target got a loader other than `css` (an asset, a
file with an unknown extension or with none, `.txt`, `.toml`, `.wasm`,
`.sh`) was dropped from the bundle with exit code 0 and no message.
`@import "./theme.foo"` gave an empty bundle.

`scan_css_imports` now reads the kind of the import record. An
`@import` takes a style sheet only, and the loader table stays as the
rule for `url()`. The message names the file, and a note names its
loader:

    error: Cannot import "a.svg" into a CSS file
    note: An "@import" rule can only import a CSS file, and "a.svg" is
    not a CSS file (it was loaded with the "file" loader). ...

An `@import` of a `.ts` or `.json` file gets the same message in place
of `Cannot import a ".ts" file into a CSS file`.

Dev server:
- A style sheet with an invalid import was removed from the CSS roots
  and then put back by a later importer or by the entry point list, so
  a hot update served it with the import dropped. It is now removed
  after both.
- A file that did not load (for example an `onLoad` plugin that threw)
  has the error itself. Its importer gets no second error.

Tests: the three cases of #47882 that imported an `.svg` as "an import
that loads nothing" now use a cyclic import, which prints the same
bytes.
…le has

When a file is imported twice, the bundle has the rules of the last
import, and a list of the file's layer names at the place of the
earlier one. The list has no conditions. A layer that the file declares
only inside `@media`, `@supports` or `@-moz-document` was declared in
every case:

    @import "./f.css";   /* @media print { @layer b { ... } } */
    @import "./g.css";   /* @layer a { ... } */
    @import "./f.css";

printed `@layer b;@layer a{...}`, so on a screen `b` came before `a`.
It now prints `@media print{@layer b;}@layer a{...}`.

A new kind of import order entry, `LayersOf`, stands for the earlier
import of such a file. It prints the layer skeleton of the file: its
`@layer` names as statements, inside copies of the conditional rules
around them. The skeleton comes from the rules after the minifier, at
link time. The parser is not changed.

A file keeps the flat list, and the bytes it has today, when the list
says what its rules declare:
- it has no layer,
- its top-level `@layer` rules are exactly its layer names (no walk of
  nested rules, no allocation), or
- a walk of its rules finds no layer that is first declared under a
  condition, and the same order (once per file per build).

It also keeps the list directly ahead of a file that starts with
`@namespace`, because a block there makes `@namespace` invalid.

`@container`, `@scope`, `@starting-style` and style rules are not
conditions: a browser declares a layer inside them in every case.
…me before

CSS allows `@import` only at the top, so the bundler moves every
external `@import` there. If the import has a layer, it then declared
that layer ahead of every layer of the bundled files, also of those
that came first in the source:

    @import "./entry.css" layer(w) screen;   /* or an empty or a real file */
    @import "https://example.com/ext.css" layer(v);

printed `@import ".../ext.css" layer(v);@media screen{@layer w;}`, so on
a screen `v` came before `w`.

The hoist now walks the order once when an external import has a layer.
Each layer that an entry behind the imports declares, and that such an
import would pass, gets a stand-in ahead of the import:

- an `@layer` statement, while no `@import` is printed yet and the layer
  has no condition,
- otherwise `@import "data:text/css," layer(w) screen;`. Only an
  `@import` is valid between two `@import` rules, and the import of an
  empty style sheet declares its layer also where a content security
  policy blocks the URL.

The entry itself stays where it was, so a style sheet that is applied
with no `@import` rules (a hot update of the dev server) has the layers
it had.

There is no stand-in, and the order stays as it was, from the first
layer that a statement or one import cannot declare: a layer inside an
anonymous layer, a layer under two media query lists or two
`supports()` conditions, and a layer that the rules of a file declare.

An external import with no layer declares none. For a chunk with only
such imports the hoist is the two loops it was, and the bytes are the
same. One rule of those loops changes: an `@layer` statement that
follows an entry with a condition is no longer moved ahead of it.
…s build tests

test/bundler/bun-build-api.test.ts has a stress test that takes minutes
in a debug build. The new test does not need that file.
… with a layer

The stand-ins ahead of a hoisted external import covered the layers of
import conditions and of `@layer` statements ahead of an `@import`. A
file whose own rules declare a layer stopped them. So this still
declared `z` before `y`:

    @import "./a.css";   /* @layer y { ... } */
    @import "https://example.com/ext.css" layer(z);

It now prints `@layer y;@import "https://example.com/ext.css" layer(z);`.

The layers of a file get stand-ins when one `@layer` statement of its
layer names declares what its rules declare. That is the check that
decides whether a redundant import of the file prints as a flat list,
so both use `LayerLists::is_flat`, once per file per build. A file with
an `@layer` block that has no name, or with a layer under `@media` or
`@supports`, still ends the stand-ins, and the order stays as it was
from there.

Each entry gets a statement of its own. The list of names that the
entry has further down is then redundant where it has the same names,
so an `@layer` statement behind an earlier file moves ahead of the
import and the bundle has the same size.
…t order is kept

A new section in docs/bundler/css.mdx. An external `@import` is printed
at the start of the bundle. The order of rules in one layer, or in no
layer, can differ from the source files. The order of cascade layers is
kept when the import has a layer, and the section lists the layers
that cannot be declared ahead of an import.
The walker and `visible_layers` took their callback as a type
parameter, so the release build had one copy for each caller. They take
`&mut dyn FnMut` now. Each runs only for a file that an import order
asks about.
@robobun

robobun commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:04 PM PT - Nov 10th, 2026

❌ @robobun, your commit b84040f has 1 failures in Build #139912 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 48521

That installs a local version of the PR into your bun-48521 executable, so you can run:

bun-48521 --bun

…oads

The test wrote to the file whose onLoad plugin failed and waited for
the rebuild. On macOS the dev server does not see that write, and the
test timed out. The assertion that matters is that the importer gets no
error of its own, so the test stops there.
Comment thread src/bundler/Chunk.rs Resolved
Comment thread src/bundler/LinkerContext.rs Resolved
Comment thread src/bundler/LinkerContext.rs Resolved
Comment thread src/bundler/bundle_v2.rs Resolved
Comment thread src/css/rules/layer.rs Resolved
Comment thread src/css/rules/layer.rs Resolved
Comment thread src/css/rules/layer.rs Resolved
Comment thread src/css/rules/layer.rs Resolved
Comment thread src/css/rules/layer.rs Resolved
Comment thread src/css/rules/layer.rs Resolved
Comment thread src/css/rules/layer.rs Resolved
Comment thread src/css/rules/layer.rs Resolved
Comment thread src/css/rules/layer.rs Resolved

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CSS bundler: cases where the bundle applies rules or layers in a different order than the source files

2 participants