From a925afe5d7611a48194b46c221efe4ace5b93918 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 10 Sep 2026 14:35:55 +0300 Subject: [PATCH] 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": "~=",