From 5ce907b7afa0d848da1b494ec41feabfa7fcb6d1 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 10 Sep 2026 14:33:46 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(compiler):=20read=20[class=3D=E2=80=A6]?= =?UTF-8?q?=20from=20the=20prop=20the=20class=20list=20arrives=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS 2.1 §5.8.1's own example is `span[class=example]`, and it matches nothing here: an attribute query built from the name `class` reads `props.class`, which no React Native element has, so every element answers false for every operator. The compiler already knows the mapping — a second class name in the same compound builds `["a", "className", "*=", name]` — so this routes both attribute-query build sites through one `attributePropName` rather than adding a third spelling of it. --- src/__tests__/native/attributes.test.tsx | 39 ++++++++++++++++++++++++ src/compiler/selector-builder.ts | 29 +++++++++++++----- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 78e1814c..95b4a782 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -133,3 +133,42 @@ describe("dataSet attribute selector", () => { }); }); }); + +describe("[class=…] reads the prop the class list actually arrives on", () => { + // CSS 2.1 §5.8.1's own example is `span[class=example]`. On React Native the + // class list is `className`, so an unmapped query reads `props.class` — which no + // element has — and answers false for every element. The compiler's own compound + // path already maps it, building `["a", "className", "*=", name]` for a second + // class name in the same selector. + const matchedWidth = ( + selector: string, + className: string, + ): number | undefined => { + registerCSS(`.test${selector} { width: 10px; }`); + render(); + const style = screen.getByTestId(testID).props.style as + | { width?: number } + | undefined; + return style?.width; + }; + + test("[class~=val] finds one word of the class list", () => { + expect(matchedWidth(`[class~='example']`, "example")).toBe(10); + expect(matchedWidth(`[class~='example']`, "other")).toBeUndefined(); + }); + + test("[class*=val] finds a substring of the class list", () => { + expect(matchedWidth(`[class*='xamp']`, "example")).toBe(10); + expect(matchedWidth(`[class*='xamp']`, "other")).toBeUndefined(); + }); + + test("[class] is present whenever the element carries a class", () => { + expect(matchedWidth(`[class]`, "example")).toBe(10); + }); + + test("the name maps at the :is() build site too", () => { + // `:is()` builds its queries on a separate path, so the mapping has to reach + // it as well. + expect(matchedWidth(`:is([class~='example'])`, "example")).toBe(10); + }); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 88561b78..41c2abbc 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -254,13 +254,12 @@ function parseComponents( } else { // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; - const attributeQuery: AttributeQuery = component.name.startsWith( - "data-", - ) + const name = attributePropName(component.name); + const attributeQuery: AttributeQuery = name.startsWith("data-") ? // [data-*] are turned into `dataSet` queries - ["d", toRNProperty(component.name.replace("data-", ""))] + ["d", toRNProperty(name.replace("data-", ""))] : // Everything else is turned into `attribute` queries - ["a", toRNProperty(component.name)]; + ["a", toRNProperty(name)]; if (component.operation) { let operator: AttrSelectorOperator | undefined; switch (component.operation.operator) { @@ -460,11 +459,12 @@ function parseIsWhereComponents( // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; } - const attributeQuery: AttributeQuery = component.name.startsWith("data-") + const name = attributePropName(component.name); + const attributeQuery: AttributeQuery = name.startsWith("data-") ? // [data-*] are turned into `dataSet` queries - ["d", toRNProperty(component.name.replace("data-", ""))] + ["d", toRNProperty(name.replace("data-", ""))] : // Everything else is turned into `attribute` queries - ["a", toRNProperty(component.name)]; + ["a", toRNProperty(name)]; if (component.operation) { const operator = operatorMap[component.operation.operator]; // Append the operator onto the attribute query @@ -582,6 +582,19 @@ type CamelCase = ? `${Lowercase}${Uppercase}${CamelCase}` : Lowercase; +/** + * The prop an attribute name is read from. + * + * `class` is the one attribute whose React Native spelling differs: the class + * list arrives as `className`, so an unmapped `[class=…]` query reads `props.class` + * — a prop no element has — and answers false for every element. The compound + * form already knows this, building `["a", "className", "*=", name]` for a second + * class name in the same selector. + */ +function attributePropName(name: string): string { + return name === "class" ? "className" : name; +} + const operatorMap: Record = { "equal": "=", "includes": "~=", From c99d320e137158a4bcf8ef2b911c9509374fe497 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 10 Sep 2026 15:46:38 +0300 Subject: [PATCH 2/3] test(compiler): cover the `=` operator the fix is named for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[class=…]` is the headline case and the four cases covered `~=`, `*=`, presence and the `:is()` build site — so the mutation proof never exercised the operator in the title. The new case pins both directions of §6.1's exact match: the value it compares is the WHOLE class list, so `[class='test example']` matches `className="test example"` and `[class='example']` does not — the same answer a browser gives for `span[class=example]`. --- src/__tests__/native/attributes.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 95b4a782..3a222be0 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -152,6 +152,14 @@ describe("[class=…] reads the prop the class list actually arrives on", () => return style?.width; }; + test("[class=val] compares against the WHOLE class list", () => { + // §6.1's `=` is an exact match on the attribute's value, and the value here + // is the entire class list — so the spec's own `span[class=example]` matches + // `class="example"` and not `class="test example"`, exactly as in a browser. + expect(matchedWidth(`[class='test example']`, "example")).toBe(10); + expect(matchedWidth(`[class='example']`, "example")).toBeUndefined(); + }); + test("[class~=val] finds one word of the class list", () => { expect(matchedWidth(`[class~='example']`, "example")).toBe(10); expect(matchedWidth(`[class~='example']`, "other")).toBeUndefined(); From ffe2a9a4667bca35038a8f300f247a290cb3e89f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 23 Sep 2026 02:01:52 +0300 Subject: [PATCH 3/3] =?UTF-8?q?test(compiler):=20assert=20the=20attribute?= =?UTF-8?q?=20query=20[class=3D=E2=80=A6]=20emits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit before this one is named `test(compiler)` and put its cases in src/__tests__/native/attributes.test.tsx, so the compiler plane had no coverage of a fix(compiler) change. A runtime test can only observe the RESULT of evaluating the query, which makes a wrong prop name and a wrong evaluation the same red. Seven cases over the emitted stylesheet: the four operators `class` reaches, plus a data-* and a plain attribute that must keep their own channels. Reverting selector-builder.ts reddens exactly the four class cases and leaves the other two green, so the set discriminates rather than merely coupling to the change. --- .../compiler/attribute-selectors.test.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/__tests__/compiler/attribute-selectors.test.tsx diff --git a/src/__tests__/compiler/attribute-selectors.test.tsx b/src/__tests__/compiler/attribute-selectors.test.tsx new file mode 100644 index 00000000..00d153cc --- /dev/null +++ b/src/__tests__/compiler/attribute-selectors.test.tsx @@ -0,0 +1,71 @@ +import { compile } from "react-native-css/compiler"; + +/** + * The attribute query the compiler emits for one class's first rule. + * + * The runtime suite can only observe the RESULT of evaluating this, so a wrong + * prop name and a wrong evaluation are the same red there. This reads the + * emitted query itself, which is the half the runtime cannot see. + */ +function attributeQueryFor(css: string, className: string): unknown { + const rules = compile(css).stylesheet().s; + const entry = rules?.find(([name]) => name === className); + if (!entry) { + throw new Error( + `the compiler emitted no rule for .${className} — the stylesheet carries ${JSON.stringify(rules?.map(([name]) => name))}`, + ); + } + const [, declarations] = entry; + return declarations.map((declaration) => declaration.aq); +} + +/** + * `class` is the CSS attribute; `className` is the prop it arrives on in React + * Native. Emitting `class` names a prop no element has, so every `[class…]` + * selector matches nothing. + */ +const CLASS_ATTRIBUTE_CASES = [ + [ + "exact match", + `.exact[class="a b"] { color: red }`, + "exact", + ["a", "className", "=", "a b"], + ], + [ + "whitespace-list match", + `.list[class~="b"] { color: red }`, + "list", + ["a", "className", "~=", "b"], + ], + [ + "substring match", + `.part[class*="b"] { color: red }`, + "part", + ["a", "className", "*=", "b"], + ], + ["presence", `.present[class] { color: red }`, "present", ["a", "className"]], +] as const; + +describe("[class…] reads the prop the class list arrives on", () => { + test("the census is not empty, so the cases below are not vacuous", () => { + expect(CLASS_ATTRIBUTE_CASES.length).toBeGreaterThan(0); + }); + + test.each(CLASS_ATTRIBUTE_CASES)("%s", (_label, css, className, expected) => { + expect(attributeQueryFor(css, className)).toStrictEqual([[expected]]); + }); +}); + +describe("every other attribute keeps its own channel", () => { + test("a data-* attribute compiles to a dataSet query, not an attribute one", () => { + expect( + attributeQueryFor(`.d[data-state="open"] { color: red }`, "d"), + ).toStrictEqual([[["d", "state", "=", "open"]]]); + }); + + test("a plain attribute is untouched by the class mapping", () => { + expect(attributeQueryFor(`.p[disabled] { color: red }`, "p")).toStrictEqual( + [[["a", "disabled"]]], + ); + }); +});