Skip to content

css bundler: print the @namespace rules of a chunk once, in an order entry after its external @import rules - #46232

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/5f9bcb14/css-namespace-hoist
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/5f9bcb14/css-namespace-hoist

Conversation

@robobun

@robobun robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • No user reported this (the need comes from css: keep the namespace prefix on attribute selectors that have an operator #45159). @namespace is in 115 of 96,778 npm .css files.
  • A chunk prints a file's @namespace rule after another file's rules, or inside a conditional @import block. A browser ignores it there and drops each rule with its prefix. A second bun build fails: error: @namespace rules must come before any other rules except @charset, @import, and @layer.
  • Cause: the rule stays in the rules of its file (prepareCssAstsForChunk.rs:360).

Fix

  • The bundler parse keeps @namespace rules out of rules, in StyleSheet.namespaces. One new order entry prints them once, after the external @import rules (add_namespaces).
  • A rule that bun's parser takes where its file prints today stays in force. Another rule only adds a free prefix.
  • Verified: 50 new tests (42 fail without the fix) in test/bundler/esbuild/css.test.ts, bundler_html.test.ts, test/bake/dev/css.test.ts. Also test/bundler/css/.
  • Self-reviewed: 42 concerns raised, 38 addressed (Notes).

Background

Downsides

  • One chunk is one namespace scope. A second URL for a prefix, or a default namespace not in place, is left out silently.
  • Of 62 valid test builds, 40 change bytes only, and 6 lose a default namespace behind @import ... all.
  • StyleSheet +24 bytes, .text +5,888 bytes, +187 instructions for a 16-entry chunk with no @namespace.
Notes

State. Draft. It waits for three answers from a maintainer (below) and for #47882, which changes the same two functions. Rebased on 2026-11-06 onto main b1305dd. The diff is the same, and the suites under Checks pass again on a debug build.

Reach and demand.

What breaks. Release builds of main 9365e0c and of this PR, bun build --minify.

Input main this PR
entry.css imports a.css (.a{color:blue}), then declares svg and has svg|a{color:red} .a{color:#00f}@namespace svg "…";svg|a{color:red} @namespace svg "…";.a{color:#00f}svg|a{color:red}
entry.css is @import "./icons.css" screen; .e{color:green} @media screen{@namespace svg "…";svg|a{color:red}}.e{color:green} @namespace svg "…";@media screen{svg|a{color:red}}.e{color:green}
entry.js imports base.css, then icons.css (@namespace xl "urn:example:xlink", a[href^="#"], a[xl|href^="#"]) .icon{fill:red}@namespace xl "…";a[href^=\#],a[xl|href^="#"]{color:green} @namespace xl "…";.icon{fill:red}a[href^=\#],a[xl|href^="#"]{color:green}

A real package. @readium/css 2.0.5 (ReadiumCSS-before.css, -default.css, -after.css, each with 4 @namespace rules) and a two-rule app.css, in Chrome 153 with 171 elements (HTML, inline SVG, MathML).

  • app.css first: Chrome keeps 0 of the 12 rules in main's chunk and 3 in this PR's chunk (the prefixes epub, m, svg). Elements whose computed style differs from the four separate sheets: 42 on main, 30 with this PR. The 30 come from the default namespace, which is not in place and is left out.
  • app.css last: Chrome keeps 4 rules on main and with this PR, and both differ from the separate sheets on the same 33 elements. Main's second build fails, and this PR's passes.

The rule.

  1. The bundler parse keeps each @namespace rule in StyleSheet.namespaces. StyleSheet::parse (no bundle) keeps it in rules.
  2. If a file of the chunk has a rule, the order builder adds one Namespaces entry after the last external @import, or after the leading @layer statement entries when the chunk has no external import.
  3. A rule is in place when bun's own parser takes it where its file prints on main: the file has no import conditions, and only @import rules, @layer statements ahead of each @import, unknown at-rule statements and other @namespace rules print before it. top_level_after walks the order with the states of TopLevelRuleParser for this.
  4. A rule in place is in force as written. A later rule in place for the same prefix, or for the default namespace, replaces it.
  5. A rule that is not in place adds its prefix only if the prefix is free. It never sets the default namespace, because that also governs the files that declare none.
  6. The entry prints one rule for each prefix and at most one default namespace, in the order of first appearance. There is no message.

Cases that stay wrong: one chunk has one namespace scope. These are the same on main and with this PR. I did not open a tracking issue. This text can be one.

  1. One prefix with two URLs. a.css is @namespace p "urn:a"; p|x{…}, b.css is @namespace p "urn:b"; p|y{…}. The chunk keeps urn:a, so p|y matches elements of urn:a.
  2. A default namespace after the rules of another file. A browser ignores it in main's output, and this PR leaves it out. The type selectors of its file also match other namespaces.
  3. A default namespace in place governs the files that declare none. .e{…} of a later file then matches only elements of that namespace.
  4. A prefix that only another file declares. In its own sheet the rule is not valid. In the chunk it applies.

The dev server serves one sheet for each root, so a page can differ between bun ./index.html and bun build. Rows of namespaceCases pin each case, and the field rebound of a row lists the files whose rules change namespace.

Changes against what a browser reads from main's output.

  • A default namespace behind @import "x.css" all. With a minify flag main prints no @media all block, so the rule is valid there. The import still has a condition, so this PR leaves the rule out (6 of the 62 valid test builds, row DefaultOfMediaAllImport).
  • bun's parser ends the place for @namespace at @custom-media, at an unknown at-rule with a block, and at @-moz-document. A browser skips a rule that it does not know. So a default namespace after only such rules is in force in a browser on main, and this PR leaves it out (rows DefaultAfterCustomMedia, DefaultAfterUnknownAtRuleBlock, DefaultAfterMozDocument). The parser is the one fixed reference, and engines differ on which rules they skip.
  • A prefix of a misplaced rule now binds. If another file uses that prefix and does not declare it, its rule was dropped on main and applies now (case 4).

Questions for a maintainer. #46040 asks the second one for @import.

  1. Move the rules, as this PR does, or keep them in place and warn, as esbuild does?
  2. Keep prologue rules out of rules in the parser (this PR, css: keep bundled @import rules in StyleSheet.imports, out of the printable rule list #46040), or strip them in the linker (the @layer statements of css: print @layer statements before @import and keep the layers of duplicate imports #47797, the @import of css: fix two layer ordering bugs left after #47797 #47882)?
  3. Keep the default namespace of a file in place in force for the whole chunk (main, and this PR), or drop it? To keep it changes no chunk that is valid today. To drop it is right for the other files and wrong for the declaring file.

Other PRs.

What the references do. esbuild 0.21.5 (run) warns "@namespace" rules are not supported [unsupported-@namespace] and prints the rule in place. esbuild 0.28.2 (source, internal/css_parser/css_parser.go:1649-1666) declines two rewrites: a default namespace into a prefix, and a rename of a prefix that two files use. lightningcss 1.0.0-alpha.72 (source, src/bundler.rs:713-815) leaves the rule in the rules of its file. css-namespaces-3 says that a misplaced rule "must be ignored".

Checks. Release builds of main 9365e0c and of this PR.

  • The 44 rows of namespaceCases, --minify-whitespace: a second build has no @namespace error or warning for 17 rows on main and for 44 with this PR.
  • 18 fixtures that are valid on main with at least one flag set, each built with no flag, --minify, --minify-whitespace and --minify-syntax (72 builds). 62 builds are valid on main: 16 are byte-identical, 40 change bytes only (Chrome 153 reads the same namespaces and the same number of rules), and 6 lose the default namespace behind @import ... all. The bytes change because the rules print above the /* file */ comment, because a @layer or unknown at-rule statement of the file now follows them, or because one prefix had two rules.
  • A chunk with no @namespace (one entry that imports the 15 sheets of test/js/bun/css/files): 4 of 4 flag sets are byte-identical.
  • 17 source mutations of add_namespaces, top_level_after and the prepare step (for example rposition to position, or a @layer statement that does not end the place): 16 fail at least one row, and the one that keeps the list in the copy of a file trips the debug assertion.
  • Debug build: test/bundler/esbuild/css.test.ts (150 pass), test/bundler/bundler_html.test.ts (30), test/bake/dev/css.test.ts (19), test/bundler/css/ (217), test/js/bun/css/css.test.ts (2,018), test/regression/issue/20546.test.ts (2), test/regression/issue/28914.test.ts (5). cargo clippy -p bun_css -p bun_bundler -- -D warnings is clean.

Measured. The same two release builds.

  • size -A: .text 110,626,985 to 110,632,873 bytes (+5,888), .rodata 26,772,460 both. The stripped binary grows by 4,096 bytes.
  • CI, linux x64 (test/bundler/compile-aot-size.test.ts): a bun build --compile --aot hello world maps 68,113,459 bytes on the base and 68,125,747 with this PR (+12,288). With --trim: 25,986,099 and 25,994,291 (+8,192). The base numbers are from the CI build of ci: leave the SSH server out of the Linux images #47981, which has the source of main b1305dd. The limit of that test for --trim is 25,880,000, so the test fails on both.
  • sizeof (gdb, debug builds): StyleSheet 520 to 544 bytes, BundlerAtRuleParser 96 to 120, CssImportOrderKind 56 both, CssImportOrder 104 both. NamespaceRule is 56 bytes.
  • Instructions for the 16-entry chunk with no @namespace, from a qemu-x86_64 trace, 3 of 3 runs equal: order builder 4,347 to 4,452, its visit 78,101 both, prepare 1,927 to 1,977, print 4,119 to 4,151. That is 187 more, about 12 for each entry.
  • Allocator calls for the same build, 3 runs each: main 229,026 to 229,093, this PR 229,016 each time. mi_malloc is 112,860 in each run.
  • Print tasks: 16 both for that chunk. The first repro goes from 2 to 3.
  • Two JS entry points that import the same two sheets share 1 CSS file, and two HTML pages that link the same pair share 1, as on main (css/CSSAtNamespaceOfTwoEntryPoints, html/SharedCSSWithNamespace).
  • perf, valgrind, bloaty and strace are not in the container.

Self-review, not done.

Earlier designs of this PR. The first design rewrote selectors to keep a scope for each file. It broke composes and used 572 MB in place of 62 MB on a 150-entry build. The second design kept the rules in their files and modelled what each entry prints, on the branch of #46040. #47797 and #47025 changed two of the three printer facts that it copied. This design has no model of the printer: it walks the order with the states of bun's parser.


[human-review] gate passed · iteration 3 · 10 files touched

fails on main (without fix)
ASAN without fix: 42 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/css.test.ts test/bundler/bundler_html.test.ts test/bundler/esbuild/css.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/bundler_html.test.ts:
(pass) bundler > html/basic [209.65ms]
(pass) bundler > html/implicit-relative-paths [58.97ms]
(pass) bundler > html/multiple-assets [60.20ms]
(pass) bundler > html/image-hashing [55.98ms]
(pass) bundler > html/external-assets [54.49ms]
(pass) bundler > html/mixed-assets [64.64ms]
(pass) bundler > html/js-imports [58.10ms]
(pass) bundler > html/script-that-is-also-an-entry-point [59.48ms]
(pass) bundler > html/css-imports [59.54ms]
(pass) bundler > html/multiple-entries [75.90ms]
(pass) bundler > html/script-src-is-entry-chunk-with-splitting [71.06ms]
(pass) bundler > html/modulepreload-links [73.91ms]
(pass) bundler > html/shared-chunks [81.05ms]
(pass) bundler > html/js-importing-html [53.86ms]
// in/entry.js
console.log("Loaded HTML!");

// in/2nd.js
console.log("2nd");

(pass) bundler > html/js-importing-html-and-entry-point-side-effect-import [61.06ms]
(pass) bundler > html/js-
... (truncated)

release without fix: 104 failed, 1 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/bundler/bundler_html.test.ts:
(pass) bundler > html/basic [37.07ms]
(pass) bundler > html/implicit-relative-paths [4.43ms]
(pass) bundler > html/multiple-assets [6.28ms]
(pass) bundler > html/image-hashing [5.14ms]
(pass) bundler > html/external-assets [5.00ms]
(pass) bundler > html/mixed-assets [5.58ms]
(pass) bundler > html/js-imports [4.59ms]
(pass) bundler > html/script-that-is-also-an-entry-point [4.04ms]
(pass) bundler > html/css-imports [6.87ms]
(pass) bundler > html/multiple-entries [5.32ms]
(pass) bundler > html/script-src-is-entry-chunk-with-splitting [6.50ms]
(pass) bundler > html/modulepreload-links [5.67ms]
(pass) bundler > html/shared-chunks [5.11ms]
(pass) bundler > html/js-importing-html [4.26ms]
// in/entry.js
console.log("Loaded HTML!");

// in/2nd.js
console.log("2nd");

(pass) bundler > html/js-importing-html-and-entry-point-side-effect-import [3.79ms]
(pass) bundler > html/js-importing-html-and-entry-point-default-import-fails [4.54ms]
(pass) bundler > html/circular-js-html [5.73ms]
(pass) bundler > html/css-only [5.80ms]
(pass) bundler > html/absolute-paths [5.67ms]
862 |       "/logo.png": "png bytes"
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/css.test.ts test/bundler/bundler_html.test.ts test/bundler/esbuild/css.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/bundler_html.test.ts:
(pass) bundler > html/basic [243.16ms]
(pass) bundler > html/implicit-relative-paths [64.06ms]
(pass) bundler > html/multiple-assets [69.40ms]
(pass) bundler > html/image-hashing [60.13ms]
(pass) bundler > html/external-assets [58.93ms]
(pass) bundler > html/mixed-assets [76.42ms]
(pass) bundler > html/js-imports [70.42ms]
(pass) bundler > html/script-that-is-also-an-entry-point [65.84ms]
(pass) bundler > html/css-imports [79.58ms]
(pass) bundler > html/multiple-entries [80.59ms]
(pass) bundler > html/script-src-is-entry-chunk-with-splitting [80.57ms]
(pass) bundler > html/modulepreload-links [78.08ms]
(pass) bundler > html/shared-chunks [86.25ms]
(pass) bundler > html/js-importing-html [57.75ms]
// in/entry.js
console.log("Loaded HTML!");

// in/2nd.js
console.log("2nd");

(pass) bundler > html/js-importing-html-and-entry-point-side-effect-import [65.35ms]
(pass) bundler > html/js-
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 691ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/27] rustc bun_transpiler 
[2/27] rustc bun_bunfig 
[3/27] rustc bun_standalone_graph 
[4/27] rustc bun_sys_jsc 
[5/27] rustc bun_patch_jsc 
[6/27] rustc bun_semver_jsc 
[7/27] rustc bun_ast_jsc 
[8/27] rustc bun_bundler_jsc 
[9/27] rustc bun_css_jsc 
[10/27] rustc bun_js_parser_jsc 
[11/27] rustc bun_css 
[12/27] rustc bun_install_jsc 
[13/27] rustc bun_http_jsc 
[14/27] rustc bun_sourcemap_jsc 
[15/27] rustc bun_sql_jsc 
[16/27] rustc bun_jsc 
[17/27] rustc bun_bundler 
[18/27] rustc bun_install 
[19/27] rustc bun_executable 
[20/27] rustc bun_runtime 
[21/27] check the generated files each crate waits for
[22/27] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/src/simdutf_sys/bun-simdutf.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-linux-gnu/deps/libbun_lint_plugins-d7995b95849b370c.rlib(bun_lint_plugins-d7995b95849b370c.bun_lint_plugins.fad7de3225560346-cgu.0.rcgu.o at 717730)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two 
... (truncated)
diff hotspot
src/bundler/Chunk.rs                               |  18 +
 .../linker_context/findImportedFilesInCSSOrder.rs  | 160 ++++++-
 .../generateCompileResultForCssChunk.rs            |   4 +-
 .../linker_context/prepareCssAstsForChunk.rs       |  13 +
 src/css/css_parser.rs                              |  78 +++-
 src/css/rules/namespace.rs                         |  16 +
 src/css/rules/unknown.rs                           |   5 +
 test/bake/dev/css.test.ts                          |  51 +++
 test/bundler/bundler_html.test.ts                  |  34 ++
 test/bundler/esbuild/css.test.ts                   | 497 +++++++++++++++++++++
 10 files changed, 859 insertions(+), 17 deletions(-)

gate history · 2 passed · 0 rejected · iteration 3

evidence per changed file
file                                                      reads  edits  tests
src/bundler/Chunk.rs                                          1      0    114
…c/bundler/linker_context/findImportedFilesInCSSOrder.rs      7      0    114
…dler/linker_context/generateCompileResultForCssChunk.rs      0      0    114
src/bundler/linker_context/prepareCssAstsForChunk.rs          4      0    114
src/css/css_parser.rs                                         1      0    114
src/css/rules/namespace.rs                                    3      1    114
src/css/rules/unknown.rs                                      0      0    114
test/bake/dev/css.test.ts                                     0      0     46
test/bundler/bundler_html.test.ts                             0      0     46
test/bundler/esbuild/css.test.ts                              1      0     96

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:38 PM PT - Nov 5th, 2026

❌ @robobun, your commit 2f7aa51 has 38 failures in Build #137895 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46232

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

bun-46232 --bun

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on main 9365e0c (release build). entry.css is @import "./a.css"; @namespace svg url(http://www.w3.org/2000/svg); svg|a { color: red } and a.css is .a { color: blue }. bun build ./entry.css --minify --outdir out prints .a{color:#00f}@namespace svg "http://www.w3.org/2000/svg";svg|a{color:red}. A second bun build of that file fails: error: @namespace rules must come before any other rules except @charset, @import, and @layer.
  • With this PR the same command prints @namespace svg "http://www.w3.org/2000/svg";.a{color:#00f}svg|a{color:red}, and the second build passes.
  • The design changed on 2026-11-04. The base is now main, and the bundler parse keeps @namespace rules out of the rule list of a file. The PR body describes it.
  • This PR is a draft. It waits for the answers to the three questions in the comment below, and for css: fix two layer ordering bugs left after #47797 #47882.
  • The branch was rebased on 2026-11-06 onto main b1305dd. The head is 2f7aa51. The diff is the same. The three test files of this PR pass on a debug build of that head.
  • CI on 2f7aa51 (build 137895, finished): no test of this PR fails. The red tests are 27 files in test/aot/ and test/bundler/compile-aot-size.test.ts. The same 28 files are red on the CI build of ci: leave the SSH server out of the Linux images #47981, which has the source of the base of this PR. The size test is over its limit on the base (25,986,099 bytes, limit 25,880,000), and this PR adds 8,192 bytes.

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

The base of this PR (robobun/1ca36eeb/css-import-partition, #46040) is now rebased on main. #46021 merged the part of #45604 that #46040 needs, so #46040 no longer sits on #45604. It is one commit (6813963) on 9b4284f.

This branch still holds the old commits of #46040 and of #45604, so the diff of this PR shows them. To show only this PR again:

git fetch origin robobun/1ca36eeb/css-import-partition
git rebase --onto origin/robobun/1ca36eeb/css-import-partition 563f2fcf45

Two things changed in the base:

@robobun
robobun force-pushed the robobun/5f9bcb14/css-namespace-hoist branch from d3e0ea3 to 8224754 Compare October 22, 2026 06:10
@robobun robobun changed the title css bundler: print misplaced @namespace rules once, after the @import rules of the chunk css bundler: print the @namespace rules of a chunk once, in an order entry after its external @import rules Nov 4, 2026
@robobun
robobun changed the base branch from robobun/1ca36eeb/css-import-partition to main November 4, 2026 07:22
@robobun
robobun force-pushed the robobun/5f9bcb14/css-namespace-hoist branch from 8224754 to cdb5c5f Compare November 4, 2026 07:22
@robobun

robobun commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

@alii three questions decide the shape of this draft. The second one is also open on #46040.

  1. Move or warn? This draft moves the @namespace rules of a chunk to the place where one style sheet takes them. esbuild keeps them in place and warns "@namespace" rules are not supported.
  2. Parser or linker? This draft keeps @namespace out of rules at parse time (StyleSheet.namespaces), as css: keep bundled @import rules in StyleSheet.imports, out of the printable rule list #46040 does for @import. css: print @layer statements before @import and keep the layers of duplicate imports #47797 records the @layer names in the parser and strips the statements in prepare. css: fix two layer ordering bugs left after #47797 #47882 strips a leftover @import in prepare. Which way should a prologue rule leave the rules of its file?
  3. The default namespace of a file that prints first. Main keeps it in force for the whole chunk, and so does this draft. That changes no chunk that is valid today, but the rules of the other files then match only that namespace. The other choice drops it. That is right for the other files and wrong for the file that declares it.

Reach, from the body: an @namespace rule is in 115 of 96,778 .css files of an npm sweep, and in none of a 10,155-file rebuild of the package list of #47025. If that is too little for a linker change, close this PR.

After #47882 merges, I will rebase this branch and add the one match arm that its change needs.

…entry after its external @import rules

A CSS chunk printed the @namespace rules of a file where that file
prints. After the rules of another file, or inside the block of a
conditional @import, a browser ignores the rule and drops each rule
that uses its prefix.

The bundler parse now keeps each @namespace rule in
StyleSheet.namespaces, out of the rule list. The order builder adds one
entry after the external @import rules of the chunk, and that entry
prints the rules of the files once.

One style sheet has one namespace scope. A rule is in place when bun's
parser takes it where its file prints. A rule in place is in force as
written. Another rule only adds a prefix that is free, so a default
namespace is never put in force where it was not.

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.

1 participant