Repository navigation
Conversation
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.
Collaborator
Author
|
Updated 7:04 PM PT - Nov 10th, 2026
❌ @robobun, your commit b84040f has 1 failures in
🧪 To try this PR locally: bunx bun-pr 48521That installs a local version of the PR into your 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.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #48421
Problem
bun buildcan order cascade layers differently from the source files, so a browser applies another rule. A hoisted external@importwith a layer passes earlier layers (items 1b, 2). A redundant import declares a layer without its@mediacondition (item 3).@importof 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
find_imported_files_in_css_orderdeclares each passed layer ahead of the import: an@layerstatement, or@import "data:text/css," layer(w) screen;where a statement is not valid.@media print{@layer b;}. The parser is not changed.@importis the errorCannot import "a.svg" into a CSS file, with a note.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
@importonly at the top, so Bun hoists external imports. Only@layerstatements can precede an@import.@layerstatement for all names, a warning, generated layer names, rules in adata:import. Items 1a and 1c get a docs section. Costs: reply on the issue.Downsides
@importfail, for example@import "bulma";(mainisbulma.scss).data:stand-in gives one CSP report per page load understyle-src 'self'. Bundle bytes: +41 (item 2), +9 (1b), +14 (item 3), 0 for 12,289 real style sheets.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_importswalked every import record with one loader table, which is the rule forurl(). It gave an error for js, ts, json and similar targets, and acceptedfile,text,toml,wasm,base64,dataurlandshtargets, because aurl()can point at those. It now branches on the record kind first. An@importwhose target has no CSS AST and another loader thancssis an error.url()records keep the table and its message.@importof a.tsor.jsonfile gets the same new message in place ofCannot import a ".ts" file into a CSS file, so the two tests that pin it change..svgas an import that loads nothing. They now use a cyclic import, which prints the same bytes on main.@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 (anonLoadplugin 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 withNo loader is configured for ".scss" files.Item 3, a layer under a condition in a redundant import.
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.@layerrules 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).@namespace: a block there makes@namespaceinvalid.@media(unless it always matches),@supportsand@-moz-document. Chrome 153 declares a layer inside@container,@scope,@starting-styleand a style rule in every case, so those are not conditions.Items 2 and 1b, the hoist.
@layerstatements ahead of an@import, and from the rules of a file whose flat list is exact (the same check as item 3).@importrules, as a hot update of the dev server is, has the layers it had.layer()of the import, not in thedata:sheet. So the layer is declared also where a content security policy blocks the URL (checked in Chrome 153: one report, the right winner).supports()conditions, a layer that a file declares under@media, and every layer after one of these.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):
layerimport.@media, ahead of a layered external import.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.
@mediaand@supportsblocks in fileslayerimportsChrome 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.
reset.cssthen 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_ofis the same on main and the PR:CssImportOrderKind56,CssImportOrder104,Layers24 bytes.src/css/css_parser.rshas no change.size -A, main af566a3 against 4b0bd9b):.text+22,528,.rodata+4,096, stripped file +28,672 bytes.stracein the container), and allocations in the scan step (it waits on the thread pool, which the gdb counter stops). The new branch ofscan_css_importsallocates 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.