From a925afe5d7611a48194b46c221efe4ace5b93918 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 10 Sep 2026 14:35:55 +0300 Subject: [PATCH 1/2] fix(compiler): drop a namespace-qualified attribute selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selectors §6 — `[ns|att]` represents only attributes in `ns`. React Native has no namespaced props, so nothing can match, and `component.namespace` was discarded: every one of these compiled to the same query as the unqualified form and matched the unqualified set instead. An UNDECLARED prefix reaches the same path. lightningcss passes it through rather than rejecting it, and Selectors L3 §6.3.3 makes such a selector invalid, so a rule the author's own stylesheet says cannot apply was applying. `[att]`, `[|att]` and `[*|att]` are untouched — measured, the first two report no namespace and the third reports `any`, and with no namespaced props those three denote the same set. --- src/__tests__/native/attributes.test.tsx | 40 ++++++++++++++++++++++++ src/compiler/selector-builder.ts | 31 ++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 78e1814c..d806bf7a 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -133,3 +133,43 @@ describe("dataSet attribute selector", () => { }); }); }); + +describe("a namespace-qualified attribute selector represents nothing", () => { + const matchedWidth = (selector: string): number | undefined => { + registerCSS(`.test${selector} { width: 10px; }`); + render( + , + ); + const style = screen.getByTestId(testID).props.style as + | { width?: number } + | undefined; + return style?.width; + }; + + // Measured against lightningcss: only `[ns|att]` reports a `specific` namespace. + // `[att]` and `[|att]` report none and `[*|att]` reports `any`, and with no + // namespaced props in the tree those three denote the same set. + test.each([ + ["no namespace", `[data-x='a']`], + ["explicitly no namespace", `[|data-x='a']`], + ["any namespace", `[*|data-x='a']`], + ])("%s matches the prop", (_label, selector) => { + expect(matchedWidth(selector)).toBe(10); + }); + + test("a declared prefix matches nothing — no prop is in a namespace", () => { + registerCSS( + `@namespace ns url(http://example.com/ns); .test[ns|data-x='a'] { width: 10px; }`, + ); + render( + , + ); + expect(screen.getByTestId(testID).props.style).toBeUndefined(); + }); + + test("an undeclared prefix matches nothing — the selector is invalid", () => { + // Selectors L3 §6.3.3. lightningcss passes the prefix through rather than + // rejecting it, so dropping the selector is this compiler's job. + expect(matchedWidth(`[undeclared|data-x='a']`)).toBeUndefined(); + }); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 88561b78..f86ac199 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -251,6 +251,17 @@ function parseComponents( getMediaQuery(ref).push([operator, "dir", component.operation.value]); return parseComponents(rest, options, root, ref, specificity); + } else if (isNamespacedAttribute(component)) { + // Selectors §6 — `[ns|att]` represents only attributes in `ns`. A React + // Native prop is in no namespace, so nothing can match and the selector is + // dropped. An UNDECLARED prefix reaches here too: lightningcss passes it + // through rather than rejecting it, and Selectors L3 §6.3.3 makes such a + // selector invalid, which is the same outcome. + // + // `[att]`, `[|att]` and `[*|att]` are all unaffected — the first two name + // no namespace and the third names any, and with no namespaced props in + // the tree those three denote the same set. + return []; } else { // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; @@ -456,6 +467,12 @@ function parseIsWhereComponents( return null; } + if (isNamespacedAttribute(component)) { + // See the compound path: no prop carries a namespace, so this argument + // represents nothing and the selector it belongs to cannot match. + return null; + } + if (type !== "where") { // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; @@ -582,6 +599,20 @@ type CamelCase = ? `${Lowercase}${Uppercase}${CamelCase}` : Lowercase; +/** + * Whether an attribute selector names a specific namespace. + * + * Measured against lightningcss: `[att]` and `[|att]` both report `null`, `[*|att]` + * reports `{ type: "any" }`, and only `[ns|att]` reports `{ type: "specific" }` — + * for a DECLARED prefix and an undeclared one alike, the second being a selector + * Selectors L3 §6.3.3 makes invalid. + */ +function isNamespacedAttribute( + component: Extract, +): boolean { + return component.namespace?.type === "specific"; +} + const operatorMap: Record = { "equal": "=", "includes": "~=", From 5cc532474bd1c849efeca2cd016b74abf6e9530b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 23 Sep 2026 02:02:52 +0300 Subject: [PATCH 2/2] test(compiler): assert a namespace-qualified selector emits no rule The runtime cases assert that no style reaches the element, which a DROPPED rule and an emitted-but-unmatchable one both produce. Only the compiler can say which happened, and the difference is whether the stylesheet carries a rule to every device that can never match. Seven cases: the three spellings naming no namespace still emit their rule, the two qualified spellings emit nothing, and a sibling rule beside a dropped one survives. Reverting selector-builder.ts reddens exactly the three drop cases. --- .../compiler/attribute-namespaces.test.tsx | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/__tests__/compiler/attribute-namespaces.test.tsx diff --git a/src/__tests__/compiler/attribute-namespaces.test.tsx b/src/__tests__/compiler/attribute-namespaces.test.tsx new file mode 100644 index 00000000..aec0f032 --- /dev/null +++ b/src/__tests__/compiler/attribute-namespaces.test.tsx @@ -0,0 +1,55 @@ +import { compile } from "react-native-css/compiler"; + +/** + * Which classes the compiler emitted a rule for. + * + * The runtime suite asserts that no style reaches the element, which a DROPPED + * rule and an emitted-but-unmatchable one both produce. This reads whether the + * rule exists at all — the half only the compiler can answer, and the half that + * decides whether the stylesheet carries dead weight to every device. + */ +function emittedClasses(css: string): readonly string[] { + return (compile(css).stylesheet().s ?? []).map(([name]) => name); +} + +/** Measured against lightningcss: only `[ns|att]` reports a `specific` namespace. */ +const REPRESENTS_THE_SAME_SET = [ + ["no namespace", `[data-x='a']`], + ["explicitly no namespace", `[|data-x='a']`], + ["any namespace", `[*|data-x='a']`], +] as const; + +const NAMESPACE_QUALIFIED = [ + [ + "a declared prefix", + `@namespace ns url(http://example.com/ns); .test[ns|data-x='a'] { width: 10px }`, + ], + ["an undeclared prefix", `.test[undeclared|data-x='a'] { width: 10px }`], +] as const; + +describe("a selector naming no namespace still emits its rule", () => { + test("the census is not empty, so the cases below are not vacuous", () => { + expect(REPRESENTS_THE_SAME_SET.length).toBeGreaterThan(0); + expect(NAMESPACE_QUALIFIED.length).toBeGreaterThan(0); + }); + + test.each(REPRESENTS_THE_SAME_SET)("%s", (_label, selector) => { + expect(emittedClasses(`.test${selector} { width: 10px }`)).toStrictEqual([ + "test", + ]); + }); +}); + +describe("a namespace-qualified attribute selector emits no rule at all", () => { + test.each(NAMESPACE_QUALIFIED)("%s", (_label, css) => { + expect(emittedClasses(css)).toStrictEqual([]); + }); + + test("only the qualified selector is dropped — a sibling rule survives", () => { + expect( + emittedClasses( + `.kept[data-x='a'] { width: 10px } .dropped[undeclared|data-x='a'] { width: 10px }`, + ), + ).toStrictEqual(["kept"]); + }); +});