From dfdbcbc74ff2d66bdfb1c5a64843cca293e93f61 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:02:57 +0000 Subject: [PATCH 1/4] css: keep the namespace prefix on attribute selectors that have an operator `parse_attribute_selector` chose the boxed node, the only attribute node with a namespace field, when the selector had a namespace and an uppercase letter in its name. A lowercase name such as `href` took the node for no namespace, so `[xl|href$=".pdf"]` printed as `[href$=".pdf"]` and merged with a real `[href$=".pdf"]` rule. Choose the node from the namespace alone, as the `[ns|name]` form already does. A name with an uppercase letter and no namespace stays in the inline node, so its minified output does not change. --- src/css/selectors/parser.rs | 22 ++- test/bundler/css/css-modules.test.ts | 29 ++++ test/bundler/esbuild/css.test.ts | 42 +++++ test/js/bun/css/css.test.ts | 245 ++++++++++++++++++++++++++- 4 files changed, 325 insertions(+), 13 deletions(-) diff --git a/src/css/selectors/parser.rs b/src/css/selectors/parser.rs index 5e8df3665529..f281b9a86ddd 100644 --- a/src/css/selectors/parser.rs +++ b/src/css/selectors/parser.rs @@ -1981,7 +1981,7 @@ pub enum GenericComponent { local_name: Impl::LocalName, local_name_lower: Impl::LocalName, }, - /// Used only when local_name is already lowercase. + /// `[name op value]` with no namespace prefix. The name can have any letter case. AttributeInNoNamespace { local_name: Impl::LocalName, operator: attrs::AttrSelectorOperator, @@ -1989,7 +1989,7 @@ pub enum GenericComponent { case_sensitivity: attrs::ParsedCaseSensitivity, never_matches: bool, }, - /// Use a Box in the less common cases with more data to keep size_of::() small. + /// An `ns|` or `*|` attribute selector, in a Box to keep size_of::() small. AttributeOther(Box>), /// Pseudo-classes @@ -3505,7 +3505,7 @@ pub(crate) fn parse_attribute_selector( let attribute_flags = parse_attribute_flags(input)?; let value: Impl::AttrValue = std::ptr::from_ref::<[u8]>(value_str); - let (local_name_lower, local_name_is_ascii_lowercase): (Impl::LocalName, bool) = 'brk: { + let local_name_lower: Impl::LocalName = 'brk: { let first_uppercase = 'a: { for (i, &b) in local_name.iter().enumerate() { if b >= b'A' && b <= b'Z' { @@ -3517,22 +3517,20 @@ pub(crate) fn parse_attribute_selector( if let Some(first_uppercase) = first_uppercase { let str_ = &local_name[first_uppercase..]; let lowered: *const [u8] = arena_lowercase(input.arena(), str_); - break 'brk (Ident { v: lowered }, false); + break 'brk Ident { v: lowered }; } else { - break 'brk ( - Ident { - v: std::ptr::from_ref::<[u8]>(local_name), - }, - true, - ); + break 'brk Ident { + v: std::ptr::from_ref::<[u8]>(local_name), + }; } }; let case_sensitivity: attrs::ParsedCaseSensitivity = attribute_flags.to_case_sensitivity(local_name_lower.v(), namespace.is_some()); - if namespace.is_some() && !local_name_is_ascii_lowercase { + // lightningcss boxes uppercase names (`|| !local_name_is_ascii_lowercase`) for its matcher. + if let Some(namespace) = namespace { Ok(GenericComponent::AttributeOther(Box::new( attrs::AttrSelectorWithOptionalNamespace:: { - namespace, + namespace: Some(namespace), local_name: Ident { v: std::ptr::from_ref::<[u8]>(local_name), }, diff --git a/test/bundler/css/css-modules.test.ts b/test/bundler/css/css-modules.test.ts index 81c28666e055..c7064f1e3887 100644 --- a/test/bundler/css/css-modules.test.ts +++ b/test/bundler/css/css-modules.test.ts @@ -23,6 +23,35 @@ describe("css", () => { }, }); + itBundled("css-module/AttributeSelectorNamespacePrefix", { + files: { + "index.module.css": /* css */ ` + @namespace xl "urn:example:xlink"; + .a[xl|href="x"] { + color: red; + } + :global([xl|href="y"]) .b { + color: blue; + } + `, + }, + outdir: "/out", + entryPoints: ["/index.module.css"], + onAfterBundle(api) { + const css = api.readFile("/out/index.module.css").replace(/\.([ab])_[A-Za-z0-9_-]+/g, ".$1_HASH"); + expect(css).toEqualIgnoringWhitespace(` + /* index.module.css */ + @namespace xl "urn:example:xlink"; + .a_HASH[xl|href="x"] { + color: red; + } + [xl|href="y"] .b_HASH { + color: #00f; + } + `); + }, + }); + itBundled("css-module/BundleTwoFilesWithoutCodeSplitting", { files: { "/foo-entry.js": ` diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts index 5e6a6f91ca01..1d932fe5bd08 100644 --- a/test/bundler/esbuild/css.test.ts +++ b/test/bundler/esbuild/css.test.ts @@ -61,6 +61,48 @@ describe("bundler", () => { }, }); + const namespacedAttributeSelectors = /* css */ ` + @namespace xl "urn:example:xlink"; + [xl|href$=".pdf"] { color: red } + [href$=".pdf"] { color: gray } + [*|href^="http"] { color: blue } + use[xl|href="#i"] { color: green } + a[xl|href] { color: black } + `; + + itBundled("css/AttributeSelectorNamespacePrefix", { + files: { + "/entry.css": namespacedAttributeSelectors, + }, + outdir: "/out", + onAfterBundle(api) { + api.expectFile("/out/entry.css").toEqualIgnoringWhitespace(/* css */ ` + /* entry.css */ + @namespace xl "urn:example:xlink"; + [xl|href$=".pdf"] { color: red; } + [href$=".pdf"] { color: gray; } + [*|href^="http"] { color: #00f; } + use[xl|href="#i"] { color: green; } + a[xl|href] { color: #000; } + `); + }, + }); + + itBundled("css/AttributeSelectorNamespacePrefixMinified", { + files: { + "/entry.css": namespacedAttributeSelectors, + }, + minifyWhitespace: true, + outdir: "/out", + onAfterBundle(api) { + api + .expectFile("/out/entry.css") + .toEqualIgnoringWhitespace( + `@namespace xl "urn:example:xlink";[xl|href$=".pdf"]{color:red}[href$=\\.pdf]{color:gray}[*|href^="http"]{color:#00f}use[xl|href="#i"]{color:green}a[xl|href]{color:#000}`, + ); + }, + }); + itBundled("css/CSSAtImportMissing", { files: { "/entry.css": `@import "./missing.css";`, diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 8c308623f3c0..dae02f5f6659 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "path"; import { cssTest, @@ -5339,6 +5339,119 @@ describe("css tests", () => { minify_test('[foo="123"] {color:red}', '[foo="123"]{color:red}'); minify_test('[foo="\\\\"] {color:red}', "[foo=\\\\]{color:red}"); minify_test('.test:not([foo="bar"]) {color:red}', ".test:not([foo=bar]){color:red}"); + + // `[xl|href="x"]` matches `href` in the `xl` namespace and `[*|href="x"]` matches it in any + // namespace. `[href="x"]` matches `href` only in no namespace, so the prefix must survive. + { + const xl = '@namespace xl "urn:example:xlink";'; + for (const prefix of ["xl|", "*|"]) { + for (const operator of ["=", "~=", "|=", "^=", "*=", "$="]) { + for (const flag of ["", " i", " s"]) { + const selector = `[${prefix}href${operator}"x"${flag}]`; + minify_test(`${xl} ${selector} {color:red}`, `${xl}${selector}{color:red}`); + cssTest(`${xl} ${selector} {color:red}`, `${xl} ${selector} { color: red; }`); + } + } + } + + cssTest( + ` + ${xl} + [xl|href$=".pdf"] { color: red } + [*|href^="http"] { color: blue } + use[xl|href="#i"] { color: green } + a[xl|href] { color: black } + `, + ` + ${xl} + [xl|href$=".pdf"] { color: red; } + [*|href^="http"] { color: #00f; } + use[xl|href="#i"] { color: green; } + a[xl|href] { color: #000; } + `, + ); + + // Two selectors that differ only in the prefix are two selectors. + minify_test( + `${xl} [xl|href="a"] {color:red} [href="a"] {background:blue}`, + `${xl}[xl|href="a"]{color:red}[href=a]{background:#00f}`, + ); + minify_test( + `${xl} [xl|href="a"] {color:red} [href="a"] {color:blue}`, + `${xl}[xl|href="a"]{color:red}[href=a]{color:#00f}`, + ); + cssTest( + `${xl} [xl|href="a"] {color:red} [href="a"] {color:blue}`, + `${xl} [xl|href="a"] { color: red; } [href="a"] { color: #00f; }`, + ); + minify_test( + `${xl} [href="e"] {color:red} .z {top:0} [xl|href="e"] {color:blue}`, + `${xl}[href=e]{color:red}.z{top:0}[xl|href="e"]{color:#00f}`, + ); + minify_test( + `@namespace x "urn:x"; @namespace y "urn:y"; [x|foo="a"] {color:red} [y|foo="a"] {color:red} [*|foo="a"] {color:red} [foo="a"] {color:red}`, + `@namespace x "urn:x";@namespace y "urn:y";[x|foo="a"],[y|foo="a"],[*|foo="a"],[foo=a]{color:red}`, + ); + // The same namespaced selector twice is still one selector. + minify_test( + `${xl} [xl|href="x"] {color:red} [xl|href="x"] {background:blue}`, + `${xl}[xl|href="x"]{color:red;background:#00f}`, + ); + minify_test(`${xl} [xl|href="x"] {color:red} [xl|href="x"] {color:red}`, `${xl}[xl|href="x"]{color:red}`); + + // Positions where a compound selector can be written. + minify_test(`${xl} .a { &[xl|href="x"] {color:red} }`, `${xl}.a{&[xl|href="x"]{color:red}}`); + minify_test(`${xl} .a { [xl|href="x"] & {color:red} }`, `${xl}.a{[xl|href="x"] &{color:red}}`); + minify_test( + `${xl} @scope ([xl|href="x"]) to ([*|href^="y"]) { a {color:red} }`, + `${xl}@scope([xl|href="x"]) to ([*|href^="y"]){a{color:red}}`, + ); + minify_test(`${xl} :not([xl|href="x"]) {color:red}`, `${xl}:not([xl|href="x"]){color:red}`); + minify_test( + `${xl} a:is([xl|href="x"], [*|href="y"]) {color:red}`, + `${xl}a:is([xl|href="x"],[*|href="y"]){color:red}`, + ); + minify_test(`${xl} a:where([xl|href="x"]) {color:red}`, `${xl}a:where([xl|href="x"]){color:red}`); + minify_test(`${xl} a:has([xl|href="x"]) {color:red}`, `${xl}a:has([xl|href="x"]){color:red}`); + minify_test(`${xl} :host([xl|href="x"]) {color:red}`, `${xl}:host([xl|href="x"]){color:red}`); + minify_test(`${xl} ::slotted([xl|href="x"]) {color:red}`, `${xl}::slotted([xl|href="x"]){color:red}`); + minify_test(`${xl} ::cue(v[xl|href="x"]) {color:red}`, `${xl}::cue(v[xl|href="x"]){color:red}`); + minify_test( + `${xl} :nth-child(2n of [xl|href="x"]) {color:red}`, + `${xl}:nth-child(2n of [xl|href="x"]){color:red}`, + ); + minify_test( + `${xl} @namespace svg "http://www.w3.org/2000/svg"; svg|a[xl|href~="w"] {color:red}`, + `${xl}@namespace svg "http://www.w3.org/2000/svg";svg|a[xl|href~="w"]{color:red}`, + ); + minify_test(`${xl} [xl|href="x"]::before {color:red}`, `${xl}[xl|href="x"]:before{color:red}`); + + // Lowering for an old target copies the selector. The copy keeps the prefix too. + prefix_test( + `@namespace x "urn:x"; [x|type="a"] {color:red} [x|type="b"] {color:red}`, + `@namespace x "urn:x"; [x|type="a"], [x|type="b"] { color: red; }`, + { ie: 11 << 16 }, + ); + prefix_test( + `@namespace x "urn:x"; .a { &[x|foo^="bar"] {color:red} }`, + `@namespace x "urn:x"; .a[x|foo^="bar"] { color: red; }`, + { ie: 11 << 16 }, + ); + prefix_test( + `@namespace x "urn:x"; :not([x|foo="a"], .b) {color:red}`, + `@namespace x "urn:x"; :not(:is([x|foo="a"], .b)) { color: red; }`, + { ie: 11 << 16 }, + ); + + // A name with an uppercase letter and no prefix prints the short value when minified. + // lightningcss prints `[Foo="bar"]` here. A name with a prefix keeps the quotes. + minify_test('[Foo="bar"] {color:red}', "[Foo=bar]{color:red}"); + minify_test('[viewBox="abc"] {color:red}', "[viewBox=abc]{color:red}"); + minify_test('[tYPE="a"] {color:red}', "[tYPE=a]{color:red}"); + minify_test(`${xl} [xl|Href="x"] {color:red}`, `${xl}[xl|Href="x"]{color:red}`); + minify_test("[|y=z] {color:red}", "[y=z]{color:red}"); + } + minify_test(".test + .foo {color:red}", ".test+.foo{color:red}"); minify_test(".test ~ .foo {color:red}", ".test~.foo{color:red}"); minify_test(".test .foo {color:red}", ".test .foo{color:red}"); @@ -5951,6 +6064,136 @@ describe("css tests", () => { ); }); + describe("namespace", () => { + minify_test("@namespace url(http://toto.example.org);", '@namespace "http://toto.example.org";'); + minify_test('@namespace "http://toto.example.org";', '@namespace "http://toto.example.org";'); + minify_test('@namespace toto "http://toto.example.org";', '@namespace toto "http://toto.example.org";'); + minify_test("@namespace toto url(http://toto.example.org);", '@namespace toto "http://toto.example.org";'); + + cssTest( + ` + @namespace "http://example.com/foo"; + + x { + color: red; + } + `, + ` + @namespace "http://example.com/foo"; + + x { + color: red; + } + `, + ); + + cssTest( + ` + @namespace toto "http://toto.example.org"; + + toto|x { + color: red; + } + + [toto|att=val] { + color: blue + } + `, + ` + @namespace toto "http://toto.example.org"; + + toto|x { + color: red; + } + + [toto|att="val"] { + color: #00f; + } + `, + ); + + cssTest( + ` + @namespace "http://example.com/foo"; + + |x { + color: red; + } + + [|att=val] { + color: blue + } + `, + ` + @namespace "http://example.com/foo"; + + |x { + color: red; + } + + [att="val"] { + color: #00f; + } + `, + ); + + cssTest( + ` + @namespace "http://example.com/foo"; + + *|x { + color: red; + } + + [*|att=val] { + color: blue + } + `, + ` + @namespace "http://example.com/foo"; + + *|x { + color: red; + } + + [*|att="val"] { + color: #00f; + } + `, + ); + + error_test('.foo { color: red } @namespace "http://example.com/foo";', "ParserError::UnexpectedNamespaceRule"); + + // `bun pm diff` parses and prints CSS with no minify pass, then compares the two prints. + test("bun pm diff shows a dropped namespace prefix as a change", async () => { + const xl = '@namespace xl "urn:example:xlink";\n'; + using dir = tempDir("css-namespace-pm-diff", { + "a/dropped.css": `${xl}use[xl|href="#i"]{color:green}\n`, + "b/dropped.css": `${xl}use[href="#i"] { color: green }\n`, + "a/kept.css": `${xl}use[xl|href="#i"]{color:green}\n`, + "b/kept.css": `${xl}use[xl|href="#i"] { color: green }\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "diff", "./a", "./b", "--json"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const files = JSON.parse(stdout).files.map((file: { path: string; formattingOnly: boolean }) => ({ + path: file.path, + formattingOnly: file.formattingOnly, + })); + expect(files).toEqual([ + { path: "dropped.css", formattingOnly: false }, + { path: "kept.css", formattingOnly: true }, + ]); + expect(exitCode).toBe(0); + }); + }); + describe("media", () => { minify_test( "@media (min-width: 240px) { .foo { color: chartreuse }}", From 1e3da36bad0490da68b86ffb23b4d5017e5b9190 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:03:00 +0000 Subject: [PATCH 2/4] css: make the namespace of the boxed attribute selector node required Both constructors of the boxed node pass a namespace. Store it as a plain `NamespaceConstraint` and rename the node to `AttrSelectorWithNamespace`. A boxed node with no namespace no longer type-checks, and the node is 8 bytes smaller. --- src/css/selectors/parser.rs | 46 +++++++++++++++---------------------- 1 file changed, 18 insertions(+), 28 deletions(-) diff --git a/src/css/selectors/parser.rs b/src/css/selectors/parser.rs index f281b9a86ddd..4ec0cceda6d5 100644 --- a/src/css/selectors/parser.rs +++ b/src/css/selectors/parser.rs @@ -182,26 +182,24 @@ pub mod attrs { } #[derive(Clone)] - pub struct AttrSelectorWithOptionalNamespace { - pub(crate) namespace: Option>>, + pub struct AttrSelectorWithNamespace { + pub(crate) namespace: NamespaceConstraint>, pub(crate) local_name: Impl::LocalName, pub(crate) local_name_lower: Impl::LocalName, pub(crate) operation: ParsedAttrSelectorOperation, pub(crate) never_matches: bool, } - impl AttrSelectorWithOptionalNamespace { + impl AttrSelectorWithNamespace { pub fn to_css(&self, dest: &mut Printer) -> Result<(), PrintErr> { dest.write_char(b'[')?; - if let Some(nsp) = &self.namespace { - match nsp { - NamespaceConstraint::Specific(v) => { - IdentFns::to_css(&v.prefix, dest)?; - dest.write_char(b'|')?; - } - NamespaceConstraint::Any => { - dest.write_str("*|")?; - } + match &self.namespace { + NamespaceConstraint::Specific(v) => { + IdentFns::to_css(&v.prefix, dest)?; + dest.write_char(b'|')?; + } + NamespaceConstraint::Any => { + dest.write_str("*|")?; } } IdentFns::to_css(&self.local_name, dest)?; @@ -231,13 +229,7 @@ pub mod attrs { } pub fn eql(&self, rhs: &Self) -> bool { - match (&self.namespace, &rhs.namespace) { - (None, None) => true, - (Some(a), Some(b)) => a.eql(b), - _ => return false, - } - .then_some(()) - .is_some() + self.namespace.eql(&rhs.namespace) && self.local_name.eql(&rhs.local_name) && self.local_name_lower.eql(&rhs.local_name_lower) && self.operation.eql(&rhs.operation) @@ -245,7 +237,7 @@ pub mod attrs { } pub(crate) fn deep_clone(&self) -> Self { Self { - namespace: self.namespace.as_ref().map(|n| n.deep_clone()), + namespace: self.namespace.deep_clone(), local_name: self.local_name, local_name_lower: self.local_name_lower, operation: self.operation.deep_clone(), @@ -253,9 +245,7 @@ pub mod attrs { } } pub(crate) fn hash(&self, hasher: &mut Wyhash) { - if let Some(ns) = &self.namespace { - ns.hash(hasher); - } + self.namespace.hash(hasher); self.local_name.hash(hasher); self.local_name_lower.hash(hasher); self.operation.hash(hasher); @@ -1990,7 +1980,7 @@ pub enum GenericComponent { never_matches: bool, }, /// An `ns|` or `*|` attribute selector, in a Box to keep size_of::() small. - AttributeOther(Box>), + AttributeOther(Box>), /// Pseudo-classes Negation(Box<[GenericSelector]>), @@ -3433,8 +3423,8 @@ pub(crate) fn parse_attribute_selector( // [foo] let local_name_lower: *const [u8] = arena_lowercase(input.arena(), local_name); if let Some(ns) = namespace { - let x = attrs::AttrSelectorWithOptionalNamespace:: { - namespace: Some(ns), + let x = attrs::AttrSelectorWithNamespace:: { + namespace: ns, local_name: Ident { v: local_name }, local_name_lower: Ident { v: local_name_lower, @@ -3529,8 +3519,8 @@ pub(crate) fn parse_attribute_selector( // lightningcss boxes uppercase names (`|| !local_name_is_ascii_lowercase`) for its matcher. if let Some(namespace) = namespace { Ok(GenericComponent::AttributeOther(Box::new( - attrs::AttrSelectorWithOptionalNamespace:: { - namespace: Some(namespace), + attrs::AttrSelectorWithNamespace:: { + namespace, local_name: Ident { v: std::ptr::from_ref::<[u8]>(local_name), }, From 790e5aab4eb2ad736d7740f55e1ce6700a9f7591 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:14:50 +0000 Subject: [PATCH 3/4] test(css): assert the error for a late @namespace, run the pm diff case concurrently The ported `error_test` line was a skipped test with no body, so it checked nothing. Assert the parse error itself. The `bun pm diff` case spawns a process and shares no state, so it is `test.concurrent`. --- test/js/bun/css/css.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index dae02f5f6659..e2f9d96ea4e4 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -6162,10 +6162,14 @@ describe("css tests", () => { `, ); - error_test('.foo { color: red } @namespace "http://example.com/foo";', "ParserError::UnexpectedNamespaceRule"); + test('ERROR: .foo { color: red } @namespace "http://example.com/foo";', () => { + expect(() => minify_test_with_options('.foo { color: red } @namespace "http://example.com/foo";', "")).toThrow( + "@namespace rules must come before any other rules except @charset, @import, and @layer", + ); + }); // `bun pm diff` parses and prints CSS with no minify pass, then compares the two prints. - test("bun pm diff shows a dropped namespace prefix as a change", async () => { + test.concurrent("bun pm diff shows a dropped namespace prefix as a change", async () => { const xl = '@namespace xl "urn:example:xlink";\n'; using dir = tempDir("css-namespace-pm-diff", { "a/dropped.css": `${xl}use[xl|href="#i"]{color:green}\n`, From e4117032198b60464e5d1de2b12494c0278484e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:35:14 +0000 Subject: [PATCH 4/4] ci: retrigger