Skip to content

css: keep the @import prologue valid when an external @import is duplicated - #45675

Closed
robobun wants to merge 2 commits into
mainfrom
robobun/286d8c58/css-keep-masked-external-import
Closed

robobun wants to merge 2 commits into
mainfrom
robobun/286d8c58/css-keep-masked-external-import

Conversation

@robobun

@robobun robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Two stylesheets that import one URL with layer() can make bun build print a rule before the hoisted imports: @media print{@layer a;}@import "https://example.com/b.css" layer(b);. Every @import after it is invalid. Chrome loads none, and Bun's parser rejects the output: @import rules must come before any other rules except @charset and @layer.
  • Cause: the duplicate-removal pass (src/bundler/linker_context/findImportedFilesInCSSOrder.rs:438) overwrites the earlier copy with a layer list, after the hoist. esbuild 0.28.1 does the same. Found by fuzzing, no user report.

Fix

  • The layer list now remembers its import (Layers::Masked). After the redundant-layer pass, a leading @layer a; stays and a list that the next entry makes redundant is dropped. Any other list becomes the @import again.
  • Correct: the author's own import goes back in its own place, so the layer order stays.
  • Verified: test/bundler/esbuild/css.test.ts (19 new cases, 15 fail on main). Also test/bake/dev/css.test.ts, bundler_html.test.ts.
  • Self-reviewed: 9 concerns raised, 9 addressed. One differs: a follow-up gets the layer-order fault in Notes, with no todo cases here.

Background

  • A cascade layer gets its priority where its name first appears. A duplicate @import can be that place.
  • The bundler flattens @import into one list and hoists the external imports to the top.
  • Considered: the rule after the imports (layer order flips), a data: import (a CSP can block it), always the duplicate (10 identical imports stay 10).

Downsides

  • An affected bundle has one more @import (150 to 178 bytes, example in Notes).
  • CssImportOrder grows from 104 to 112 bytes, release .text by 2,048 bytes.
Notes

Example (one JS entry that imports two stylesheets):

/* one.css */
@import "https://example.com/a.css" layer(a) print;
.one { color: red }
/* two.css */
@import "https://example.com/b.css" layer(b);
@import "https://example.com/a.css" layer(a) print;
.two { color: red }
  • main: @media print{@layer a;}@import "https://example.com/b.css" layer(b);@import "https://example.com/a.css" layer(a) print;.one{color:red}.two{color:red}
  • this PR: @import "https://example.com/a.css" layer(a) print;@import "https://example.com/b.css" layer(b);@import "https://example.com/a.css" layer(a) print;.one{color:red}.two{color:red}

Other shapes that printed an invalid prologue (each is a test case):

  • @import A layer(a) print; @import A layer(a); printed @media print{@layer a;} first. It now prints the one import.
  • supports() in place of the media query printed an @supports block first.
  • Nested layers printed @layer outer{@layer inner;} first. A layer block is not a layer statement.
  • @import F layer(first); @import A layer(a); @import B layer(b); @import A layer(a); printed @layer a; between two imports. A layer statement is only valid ahead of the first import.

Measurements. Release builds of the merge base and of this PR, same commit. Fixtures are 11 small import graphs, 7 of them invalid on main.

  • Invalid prologues: 7 of 11 fixtures on main, 0 on this PR (the bundle is bundled again, exit code). The second bundle is identical to the first for 6 of 11. The other 5 differ because bun bundles a data: import as a file (3) or prints a pre-import @layer statement after the imports (2, see css: emit @layer statements that precede @import before the imported files #41572).
  • Outputs that are valid on main: 0 of 4 fixtures changed, 0 snapshot lines changed.
  • Random import graphs: 13,500 graphs from 2 seeds. 1,755 are invalid on main, 0 on this PR. 0 of the valid outputs changed. 1,500 of these ran on a debug build with no assertion failure.
  • Chrome 153, five shapes in screen and print: the computed style of the bundle differs from the unbundled source in 0 of 10 pairs (main: 10 of 10, Chrome drops the imports). For the three imports of the example, the bundle has 3 CSSImportRule and Chrome makes 1 request for a.css.
  • Allocator calls inside the order function, main vs PR (gdb, 3 identical runs): no externals 17/17, one external 27/27, duplicate without a layer 31/31, duplicate with a layer 34/34, invalid-on-main shape 36/36.
  • Self instructions of the order function (gdb single-step): 2,000 external imports plus 200 local files 725,587 on main, 682,591 on this PR. Duplicate with a layer: 1,801 to 2,052. Duplicate without a layer: 1,344 to 1,278.
  • find_imported_files_in_css_order: 9,927 to 11,739 bytes.

Not in this PR

  • The hoist keeps a leading layer list ahead of the imports without a check of how it prints. No layer list exists at that point on main, so that code cannot run. css: emit @layer statements that precede @import before the imported files #41572 adds the producer, and the check belongs there with a test.
  • The redundant-layer pass calls a layer list redundant when an earlier entry has no conditions at all. @import "first.css"; @import A layer(a); @import B layer(b); @import A layer(a); prints b before a. Local files have the same fault. This PR does not change those bytes.
  • The rule "a later copy masks an earlier copy" does not hold for !important declarations in different layers. That rule is older than this PR and also applies to local files.
  • A kept import with more than one condition prints as a nested data: import, as every external import with nested conditions does on main.
  • bundler: remove unsafe from bundle_v2, ParseTask, LinkerContext and the bundler thread pool #40438 rewrites this function. The two changes conflict in the hoist and the layer loop. The one that lands second needs a rebase. In that branch the Masked arm can hold the condition slice, with no unsafe.

…icated

A later copy of an external @import masks an earlier copy. The
duplicate-removal pass replaced the earlier copy in place with a layer
list, inside the hoisted import block. That entry printed as a rule in
front of or between the @import rules, for example
`@media print{@layer a;}`, so every @import after it was invalid.

The layer list now remembers the import that it replaced. After the
redundant-layer pass, a leading `@layer a;` statement stays as before.
A layer list that the next entry makes redundant is dropped. Any other
one becomes the import again, with its conditions.
@robobun

robobun commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:07 AM PT - Oct 18th, 2026

❌ @robobun, your commit 3b2f793 has 1 failures in Build #128338 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45675

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

bun-45675 --bun

@robobun

robobun commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed. #47797 supersedes this PR and covers the problem below (checked at 0400ee7).

How the problem was reproduced, on bun 1.4.3 and on main:

mkdir -p sub
echo '@import "https://example.com/fonts.css" screen;' > sub/a.css
echo '.c0 { color: red }' >> sub/a.css
echo '@import "sub/a.css" layer(theme) print;' > entry.css
echo '@import "sub/a.css" layer(theme) print;' >> entry.css
echo '.c0 { color: blue }' >> entry.css
bun build ./entry.css --minify --outdir out
cat out/entry.css
bun build ./out/entry.css --outdir again

The first build exits 0 and its output starts with @media print{@layer theme;}. The second build exits 1 with @import rules must come before any other rules except @charset and @layer. On the branch of #47797 the output starts with the @import rules and the second build exits 0.

@coderabbitai

coderabbitai Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 56b00559-3c66-432b-802e-4df4933841d6
📥 Commits

Reviewing files that changed from the base of the PR and between 0ffddcd and 9dd0ac0.

📒 Files selected for processing: 4
  • src/bundler/Chunk.rs
  • src/bundler/linker_context/findImportedFilesInCSSOrder.rs
  • src/bundler/linker_context/prepareCssAstsForChunk.rs
  • test/bundler/esbuild/css.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Walkthrough

The CSS import-order optimizer now preserves metadata for conditionally redundant external imports while resolving layer ordering. New tests check emitted CSS across duplicate-import cases, CLI output, shared imports, and CSS reparsing.

Changes

CSS External Import Masking

Layer / File(s) Summary
Masked import representation
src/bundler/Chunk.rs
Layers gains a masked variant that stores an external import path and condition count. A helper identifies entries that print as layer statements.
Duplicate import resolution
src/bundler/linker_context/findImportedFilesInCSSOrder.rs
The optimizer retains masked imports through layer deduplication, then removes, converts, or restores them based on their conditions and position. It also checks the external-import prologue.
Output validation
src/bundler/linker_context/prepareCssAstsForChunk.rs, test/bundler/esbuild/css.test.ts
Debug assertions check generated layer statements. Tests check duplicate-import output, reparsing, CLI output, and imports shared across CSS files.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 3b2f7

Merge Risk: ⚪ Minimal · up to 3b2f7

No merge-blocking issue was established; the change is ready for normal validation and merge.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: preserving a valid CSS @import prologue when an external @import is duplicated.
Description check Passed The description explains the problem, root cause, fix, verification steps, test coverage, trade-offs, and out-of-scope behavior. It does not use the template headings exactly, but it provides the requ…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the problem, root cause, fix, verification steps, test coverage, trade-offs, and out-of-scope behavior. It does not use the template headings exactly, but it provides the required change summary and verification information in complete detail.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked whether the new unreachable!() arm for Layers::Masked in Layers::to_owned (src/bundler/Chunk.rs) can be hit from the adjacent-layer merge loop: a masked import with no layer condition is always removed by the trailing-trim plus empty-entry check (findImportedFilesInCSSOrder.rs:541-560), and when a layer is present the post-pass at :700 rewrites every surviving Masked entry to Owned, ExternalPath, or drops it before memcpy_and_reset, so no Masked entry reaches the merge loop. The https://example.com strings in the new tests are only unresolved external @ import targets; nothing fetches them.

Extended reasoning...

The change adds a Layers::Masked variant and a post-pass in the CSS import-ordering code so a duplicated external layered @ import is restored instead of printing a layer rule ahead of later imports; it touches no security-sensitive surface. Two inline findings (the second unsafe set_len restore and a pre-existing layer-order fault) are posted separately; this note records the one additional invariant I verified so a human reviewer need not re-derive it.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/bundler/linker_context/findImportedFilesInCSSOrder.rs — pre-existing: a duplicated layered import loses its cascade position whenever a plain external @ import precedes it, so the bundle orders layers differently from the source. The redundant-layer pass at findImportedFilesInCSSOrder.rs:623 calls a layer entry redundant against any earlier entry with the same (empty) layer-name key, and is_conditional_import_redundant returns true for an empty later list, so @ import "https://x/f.css"; @ import "./t.css" layer(theme); @ import "./b.css" layer(base); @ import "./t.css" layer(theme); prints base before theme. Fix: only treat the layer entry as redundant when the suffix of its conditions beyond the earlier entry's length declares no layer, for both the local and the masked-external case.

    Why this was flagged

    Entry stylesheet: @ import "https://example.com/fonts.css"; @ import "./theme.css" layer(theme); @ import "./base.css" layer(base); @ import "./theme.css" layer(theme); where theme.css and base.css contain only ordinary rules and no @ layer. The backward pass at findImportedFilesInCSSOrder.rs:407-421 turns the first theme entry into Layers(Borrowed) with conditions [layer(theme)] and an empty name list. In the forward pass the fonts entry gets the empty key at findImportedFilesInCSSOrder.rs:581 and is registered at findImportedFilesInCSSOrder.rs:613; the theme layer entry gets the same empty key at findImportedFilesInCSSOrder.rs:580, and is_conditional_import_redundant(&[layer(theme)], &[]) at findImportedFilesInCSSOrder.rs:623 returns true because later is empty (findImportedFilesInCSSOrder.rs:906-910), so findImportedFilesInCSSOrder.rs:679 drops it. Output is @ import "https://example.com/fonts.css";@ layer base{...}@ layer theme{...}: base is declared before theme, while the source declares theme first. The base branch produces the same output.

    Verification: pre-existing (acknowledged in PR description under "Not in this PR"). At findImportedFilesInCSSOrder.rs:623-626 is_conditional_import_redundant([layer(theme)], []) returns true (958) because later.len() is 0, and continue 'next_forward (679) drops the entry. Nothing in the diff guards the later.len() == 0 case. The base branch already fails identically.

@alii

alii commented Nov 3, 2026

Copy link
Copy Markdown
Member

Superseded by #47797

@alii alii closed this Nov 3, 2026
@robobun

robobun commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

Checked #47797 at 0400ee7 with the inputs of this PR. It covers the problem.

  • The reported input (a file with an external @import, imported twice with layer(theme) print): the output starts with the @import rules, and bundling the output again succeeds.
  • The 18 input shapes of the css/CSSAtImportExternalDuplicate* cases: all 18 print a valid import prologue and bundle again.
  • 1,500 random import graphs with external imports, layer(), media and supports() conditions: 206 have an @import in an invalid position on bun 1.4.3, 0 on css: print @layer statements before @import and keep the layers of duplicate imports #47797.

One difference in output size. In 6 of the 18 shapes #47797 keeps a masked copy as an @import where this PR dropped it. One of the 6 is valid on main with fewer rules:

@import "https://example.com/a.css" layer(a) print;
@import "https://example.com/a.css" layer(a) print;

main prints the import once. #47797 prints it twice. The output is valid and the cascade is the same.

The 6 shapes
Input imports #47797 this PR
A layer(a) print; A layer(a) both imports A layer(a)
F screen inside a file that is imported twice with layer(theme) print the data: import twice the data: import once
A layer(a) print; A layer(a) print both imports one import (as main)
X layer(a) print; Y layer(a) print; X layer(a) print all three imports Y, then X (as main)
X layer(a) supports(display: grid) print; X layer(a) print; X layer(a); X all four imports @layer a; then X
X layer(a) print; X layer(a) screen; X layer(a) all three imports the first and the third

alii added a commit that referenced this pull request Nov 3, 2026
…plicate imports (#47797)

### What was wrong

- `@layer first; @import "i.css" layer(second);` printed `@layer
second{...}@layer first;`. A layer gets its priority where its name
first appears, so the two layers swapped. The bundler printed the
imported files ahead of the whole body of the importer, and the
statement is in that body.
- The bundler replaces an earlier copy of a file or an external import
with a list of its layers, when a later copy covers it.
`is_conditional_import_redundant` said "covered" when a condition list
was a prefix of the other, and it ignored a `layer()` that only the
earlier copy has. So a later bare copy removed rules in another layer,
and an unrelated file with no conditions removed the list that declared
the layer.
- An `@import` that closes a cycle declared no layer. css-cascade-5 says
that the layer "is added to the layer order even if the import fails to
load the stylesheet".
- A list for a duplicate external `@import` could print ahead of the
hoisted imports as a block, or between two imports. Each makes later
`@import` rules invalid, and a second bundle fails.
- `CssImportOrder::hash` leaves out the import conditions. Two entry
points with different orders could share one stylesheet.

### The fix

- The parser moves the layer names seen before the first `@import` into
`StyleSheet.layers_pre_import`. Each visit of a file pushes those names
as an entry ahead of its imports, and the file no longer prints those
statements.
- Each visit of a bare `layer` import gets an id in
`Layer.anonymous_id`. `@import "x.css" layer` puts x.css and all its
imports into one anonymous layer, and two visits make two layers, also
when they are the same rule. `Layer::eql` compares the id, so equality
and redundancy checks treat two bare imports as different layers.
- Entries with the same anonymous layer print in one `@layer { }` block.
Each entry prints only the block edges that are its own: it opens what
the next entry uses, or closes what the previous entry opened. The
entries stay separate compile results, because each file resolves
`url()` from its own import records. The block can sit under other
conditions, and these print once too.
- A masked copy under a bare `layer` keeps its layer names, because they
order the layers of the shared block.
- `is_conditional_import_redundant` returns false when the earlier copy
has a `layer` condition beyond the later one. No per-chunk mode: this
holds for every bundle.
- The cycle check pushes an empty layer list under the conditions of the
import.
- A list prints ahead of the hoisted imports only as a plain `@layer`
statement. A duplicate external import with a layer stays an `@import`
unless a leading statement or an earlier equal copy declares the layer.
- A file's layer names can be inside `@media`, so a file entry no longer
removes a list of names. Only a list of the same file is dropped when
that file follows it directly.
- `compute_chunks` compares the import orders on a hash hit and takes
the next free key if they differ.

### Downsides

- A file imported both under `layer(x)` and bare now prints twice. Its
rules are in two layers, so both copies are needed. An external import
in that shape stays two `@import` rules.
- Bundles that have a `@layer` statement ahead of an `@import` change
bytes. That is the fix.
- A file imported twice with a bare `layer` now prints twice, because
the copies are in two layers. `css/CSSAtImportConditionsAtLayerBundle`
case5 has one more copy in its snapshot, and differs from esbuild's
snapshot there.
- With `minifyWhitespace` off, a `/* file */` comment inside a shared
block prints at column 0.

### Known wrong cases

- An external `@import` under a bare `layer` prints as `@import "x.css"
layer`, because external imports are hoisted. That layer is not the one
of the files that were imported with it, so the single anonymous layer
is split in two. The random graph check has no external imports.
- A layer that is declared only inside `@media` still gets an
unconditional list for a duplicate copy, as on main.

### Tests

On a debug build of this branch, `test/bundler/esbuild/css.test.ts`,
`test/bundler/bundler_html.test.ts`,
`test/regression/issue/20546.test.ts`,
`test/regression/issue/28914.test.ts`, `test/bundler/css/` and
`test/bake/dev/css.test.ts` give 369 pass, 1 todo, 0 fail. The todo is
not about layers. Run on Bun 1.4.3, the first three files give 51
failures, including the new cases. Some of those 41 come from other
changes since 1.4.3.

`css/CSSAtImportConditionsBundleLOL` now expects one more external
`@import`. The old output dropped it only because a duplicate list was
removed by an unconditional copy in another layer. A smaller input
without that copy prints an `@media` block between two `@import` rules
on 1.4.3, and the rule after it is invalid.

The random graph check from #41572 (not in this PR), 200 graphs each,
seeds from 1:

| Options | Right here | Wrong with 1.4.3, right here | Right rule with
1.4.3, another here |
| --- | --- | --- | --- |
| default | 200 | 147 | 0 |
| cycles | 200 | 156 | 0 |
| bare `layer` | 200 | 122 | 0 |
| empty blocks | 200 | 174 | 0 |
| bare `layer`, cycles and empty blocks | 200 | 161 | 0 |

The baseline was Bun 1.4.3, not main. The check has no `@media`,
`@supports` or `!important`.

Fixes #20546

Supersedes #41572
Supersedes #45675
Supersedes #46357
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.

3 participants