From 55253748bcb7fc6fa7707aeddfbd280dd8bfb5b9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:50:14 +0300 Subject: [PATCH 1/3] fix: match a dataSet value and a dir attribute the way the DOM does react-native-web writes a dataSet entry as a data-* attribute for every value but null and undefined, so [data-x] matches data-x="false" in a browser. The runtime treated false as absent there, as it rightly does for a boolean prop, and compared an array value as no value, where the DOM holds its comma-joined text. HTML compares a dir value ASCII case-insensitively, so [dir="RTL"] matches dir="rtl" unless the selector carries the s flag. The compiler emitted the operand as written, and the direction condition never matched it. It now folds the operand unless s is given. Both folds share one ASCII lowercasing helper, which the runtime's i flag also uses. --- src/__tests__/compiler/selectors.test.tsx | 16 +++++ src/__tests__/native/attributes.test.tsx | 46 ++++++++++++++ src/__tests__/native/direction-media.test.tsx | 14 +++++ src/compiler/selector-builder.ts | 8 ++- src/native/conditions/attributes.ts | 61 ++++++++++++------- src/utilities/ascii-case.ts | 4 ++ 6 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 src/utilities/ascii-case.ts diff --git a/src/__tests__/compiler/selectors.test.tsx b/src/__tests__/compiler/selectors.test.tsx index 1df75cf0..bd77a42e 100644 --- a/src/__tests__/compiler/selectors.test.tsx +++ b/src/__tests__/compiler/selectors.test.tsx @@ -1,3 +1,5 @@ +import { compile } from "react-native-css/compiler"; + import { getClassNameSelectors } from "../../compiler/selector-builder"; test("empty", () => { @@ -150,3 +152,17 @@ test(".group-[.test]:text-white { &:is(:where(.group):is(.test) *) {}", () => { }, ]); }); + +// HTML compares a dir value ASCII case-insensitively unless the selector carries s +test.each([ + ['[dir="RTL"]', "rtl"], + ['[dir="Rtl"]', "rtl"], + ['[dir="rtl"]', "rtl"], + ['[dir="RTL" i]', "rtl"], + ['[dir="RTL" s]', "RTL"], +])(".t%s compiles to the direction %p", (attribute, direction) => { + const rule = compile(`.t${attribute} { color: red }`).stylesheet() + .s?.[0]?.[1][0]; + + expect(rule?.m).toStrictEqual([["=", "dir", direction]]); +}); diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 900dde70..222ae632 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -280,3 +280,49 @@ test.each(["=", "|=", "~=", "^=", "$=", "*="] as const)( expect(screen.getByTestId(testID).props.style.width).toBe(40); }, ); + +// react-native-web writes a dataSet value as a data-* attribute unless it is null or undefined +describe("a dataSet value projects as react-native-web writes it", () => { + test.each([ + [false, true], + [0, true], + ["", true], + [null, false], + [undefined, false], + ])("[data-test] over %p is present: %s", (value, present) => { + registerCSS(`.test { width: 40px; } .test[data-test] { width: 80px; }`); + render( + , + ); + expect(screen.getByTestId(testID).props.style.width).toBe( + present ? 80 : 40, + ); + }); + + test("a false plain prop is an absent boolean attribute", () => { + registerCSS(`.test { width: 40px; } .test[disabled] { width: 80px; }`); + render(); + expect(screen.getByTestId(testID).props.style.width).toBe(40); + }); + + test.each([ + ['[data-test="a,b"]', true], + ['[data-test~="a"]', false], + ])("%s over the array [a, b] matches: %s", (attribute, matches) => { + registerCSS(`.test { width: 40px; } .test${attribute} { width: 80px; }`); + render( + , + ); + expect(screen.getByTestId(testID).props.style.width).toBe( + matches ? 80 : 40, + ); + }); +}); diff --git a/src/__tests__/native/direction-media.test.tsx b/src/__tests__/native/direction-media.test.tsx index 8ed38ee4..014c087a 100644 --- a/src/__tests__/native/direction-media.test.tsx +++ b/src/__tests__/native/direction-media.test.tsx @@ -33,3 +33,17 @@ test.each([false, true])( expect(screen.getByTestId("ltr")).toHaveStyle({ width: isRTL ? 10 : 37 }); }, ); + +test.each([ + ['[dir="RTL"]', true], + ['[dir="RTL" i]', true], + ['[dir="RTL" s]', false], +])(".t%s matches a right-to-left layout: %s", (attribute, matches) => { + Object.defineProperty(I18nManager, "isRTL", { + configurable: true, + value: true, + }); + registerCSS(`.t { width: 10px; } .t${attribute} { width: 37px; }`); + render(); + expect(screen.getByTestId("t")).toHaveStyle({ width: matches ? 37 : 10 }); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 6787c0ca..f66a5c1c 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -1,6 +1,7 @@ import type { AttrOperation, Selector, SelectorList } from "lightningcss"; import { Specificity } from "../utilities"; +import { asciiLowerCase } from "../utilities/ascii-case"; import type { AttributeQuery, AttrSelectorOperator, @@ -249,7 +250,12 @@ function parseComponents( return []; } - getMediaQuery(ref).push([operator, "dir", component.operation.value]); + // HTML compares a dir value ASCII case-insensitively unless the selector carries s + const direction = + component.operation.caseSensitivity === "explicit-case-sensitive" + ? component.operation.value + : asciiLowerCase(component.operation.value); + getMediaQuery(ref).push([operator, "dir", direction]); return parseComponents(rest, options, root, ref, specificity); } else { // specificity[Specificity.ClassName] = diff --git a/src/native/conditions/attributes.ts b/src/native/conditions/attributes.ts index e856e79e..f6b53ec5 100644 --- a/src/native/conditions/attributes.ts +++ b/src/native/conditions/attributes.ts @@ -1,7 +1,10 @@ import type { AttributeQuery } from "react-native-css/compiler"; +import { asciiLowerCase } from "../../utilities/ascii-case"; import type { RenderGuard } from "./guards"; +type AttributeQueryType = AttributeQuery[0]; + export function testAttributes( queries: AttributeQuery[], props: Record | undefined | null, @@ -29,43 +32,55 @@ function testAttribute( guards.push([type, prop, value]); if (!operator) { - return value !== undefined && value !== null && value !== false; + return isPresent(type, value); } if (operator === "!") return !value; - if (value === undefined || value === null || testValue === undefined) - return false; - if ( - typeof value !== "string" && - typeof value !== "number" && - typeof value !== "boolean" - ) - return false; - let actual = String(value); - if (caseSensitivity === "i") { - // CSS attribute flags fold ASCII letters only, not Unicode characters. - actual = actual.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); - testValue = testValue.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); - } + const attribute = attributeText(type, value); + if (attribute === undefined || testValue === undefined) return false; + + const insensitive = caseSensitivity === "i"; + const actual = insensitive ? asciiLowerCase(attribute) : attribute; + const expected = insensitive ? asciiLowerCase(testValue) : testValue; switch (operator) { case "=": - return actual === testValue; + return actual === expected; case "~=": - return ( - testValue !== "" && actual.split(/[\t\n\f\r ]+/).includes(testValue) - ); + return expected !== "" && actual.split(/[\t\n\f\r ]+/).includes(expected); case "|=": - return actual === testValue || actual.startsWith(testValue + "-"); + return actual === expected || actual.startsWith(expected + "-"); case "^=": - return testValue !== "" && actual.startsWith(testValue); + return expected !== "" && actual.startsWith(expected); case "$=": - return testValue !== "" && actual.endsWith(testValue); + return expected !== "" && actual.endsWith(expected); case "*=": - return testValue !== "" && actual.includes(testValue); + return expected !== "" && actual.includes(expected); default: operator satisfies never; return false; } } + +// react-native-web writes a dataSet entry as a data-* attribute for every value but null and undefined, while a false prop is an absent boolean attribute +function isPresent(type: AttributeQueryType, value: unknown): boolean { + return ( + value !== undefined && value !== null && (type === "d" || value !== false) + ); +} + +function attributeText( + type: AttributeQueryType, + value: unknown, +): string | undefined { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return String(value); + } + + return type === "d" && Array.isArray(value) ? value.join(",") : undefined; +} diff --git a/src/utilities/ascii-case.ts b/src/utilities/ascii-case.ts new file mode 100644 index 00000000..748b92c5 --- /dev/null +++ b/src/utilities/ascii-case.ts @@ -0,0 +1,4 @@ +// Selectors §6.3 and HTML fold ASCII letters only: toLowerCase would also fold U+212A KELVIN SIGN onto k +export function asciiLowerCase(text: string): string { + return text.replace(/[A-Z]/g, (letter) => letter.toLowerCase()); +} From e5c3148b27de373a099c77225387480b391fa3e6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:54:26 +0300 Subject: [PATCH 2/3] refactor(compiler): build an attribute query in one place The compound path and the :is() / :where() path each built the same query from an attribute selector, one with its own copy of the operator map. Both now call one builder. --- src/compiler/selector-builder.ts | 102 +++++++++---------------------- 1 file changed, 28 insertions(+), 74 deletions(-) diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index f66a5c1c..02bcc7b1 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -260,58 +260,7 @@ function parseComponents( } else { // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; - const attributeQuery: AttributeQuery = component.name.startsWith( - "data-", - ) - ? // [data-*] are turned into `dataSet` queries - ["d", toRNProperty(component.name.replace("data-", ""))] - : // Everything else is turned into `attribute` queries - [ - "a", - component.name.startsWith("aria-") - ? component.name - : toRNProperty(component.name), - ]; - if (component.operation) { - let operator: AttrSelectorOperator | undefined; - switch (component.operation.operator) { - case "equal": - operator = "="; - break; - case "includes": - operator = "~="; - break; - case "dash-match": - operator = "|="; - break; - case "prefix": - operator = "^="; - break; - case "substring": - operator = "*="; - break; - case "suffix": - operator = "$="; - break; - default: - component.operation.operator satisfies never; - break; - } - if (operator) { - // Append the operator onto the attribute query - attributeQuery.push(operator, component.operation.value); - if ( - component.operation.caseSensitivity === "ascii-case-insensitive" - ) { - attributeQuery.push("i"); - } else if ( - component.operation.caseSensitivity === "explicit-case-sensitive" - ) { - attributeQuery.push("s"); - } - } - } - getAttributeQuery(ref).push(attributeQuery); + getAttributeQuery(ref).push(attributeQueryFor(component)); specificity[Specificity.ClassName] = (specificity[Specificity.ClassName] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); @@ -480,28 +429,7 @@ function parseIsWhereComponents( // specificity[Specificity.ClassName] = // (specificity[Specificity.ClassName] ?? 0) + 1; } - const attributeQuery: AttributeQuery = component.name.startsWith("data-") - ? // [data-*] are turned into `dataSet` queries - ["d", toRNProperty(component.name.replace("data-", ""))] - : // Everything else is turned into `attribute` queries - [ - "a", - component.name.startsWith("aria-") - ? component.name - : toRNProperty(component.name), - ]; - if (component.operation) { - const operator = operatorMap[component.operation.operator]; - // Append the operator onto the attribute query - attributeQuery.push(operator, component.operation.value); - if (component.operation.caseSensitivity === "ascii-case-insensitive") { - attributeQuery.push("i"); - } else if ( - component.operation.caseSensitivity === "explicit-case-sensitive" - ) { - attributeQuery.push("s"); - } - } + const attributeQuery = attributeQueryFor(component); queries ??= [{ specificity: [] }]; for (const query of queries) { if (type === "is") { @@ -614,6 +542,32 @@ type CamelCase = ? `${Lowercase}${Uppercase}${CamelCase}` : Lowercase; +function attributeQueryFor( + component: Extract, +): AttributeQuery { + const query: AttributeQuery = component.name.startsWith("data-") + ? ["d", toRNProperty(component.name.replace("data-", ""))] + : [ + "a", + component.name.startsWith("aria-") + ? component.name + : toRNProperty(component.name), + ]; + + const operation = component.operation; + if (!operation) { + return query; + } + + query.push(operatorMap[operation.operator], operation.value); + if (operation.caseSensitivity === "ascii-case-insensitive") { + query.push("i"); + } else if (operation.caseSensitivity === "explicit-case-sensitive") { + query.push("s"); + } + return query; +} + const operatorMap: Record = { "equal": "=", "includes": "~=", From 291ce56d31122fc22a65343a3555f938f0edddf7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:55:20 +0300 Subject: [PATCH 3/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 React Native carries the class list as className, so an attribute query reading props.class answered false for every element, and [class~="x"], [class*="x"] and [class] never matched. The attribute builder maps class to className. --- src/__tests__/compiler/selectors.test.tsx | 26 ++++++++++++++++++ src/__tests__/native/attributes.test.tsx | 33 +++++++++++++++++++++++ src/compiler/selector-builder.ts | 16 ++++++----- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/__tests__/compiler/selectors.test.tsx b/src/__tests__/compiler/selectors.test.tsx index bd77a42e..8088dac4 100644 --- a/src/__tests__/compiler/selectors.test.tsx +++ b/src/__tests__/compiler/selectors.test.tsx @@ -166,3 +166,29 @@ test.each([ expect(rule?.m).toStrictEqual([["=", "dir", direction]]); }); + +test.each([ + ['[class="a b"]', ["a", "className", "=", "a b"]], + ['[class~="b"]', ["a", "className", "~=", "b"]], + ['[class*="b"]', ["a", "className", "*=", "b"]], + ["[class]", ["a", "className"]], + ['[data-state="open"]', ["d", "state", "=", "open"]], + ["[disabled]", ["a", "disabled"]], +])(".t%s reads the prop it arrives on", (attribute, query) => { + const rule = compile(`.t${attribute} { color: red }`).stylesheet() + .s?.[0]?.[1][0]; + + expect(rule?.aq).toStrictEqual([query]); +}); + +test("an ancestor [class~=…] reads className through the :is() site", () => { + const rule = compile( + `.child:is(:where(.group)[class~="active"] *) { color: red }`, + ) + .stylesheet() + .s?.find(([name]) => name === "child")?.[1][0]; + + expect(rule?.cq).toStrictEqual([ + { n: "g:group", a: [["a", "className", "~=", "active"]] }, + ]); +}); diff --git a/src/__tests__/native/attributes.test.tsx b/src/__tests__/native/attributes.test.tsx index 222ae632..2c4288d9 100644 --- a/src/__tests__/native/attributes.test.tsx +++ b/src/__tests__/native/attributes.test.tsx @@ -326,3 +326,36 @@ describe("a dataSet value projects as react-native-web writes it", () => { ); }); }); + +test.each([ + ["[class='test example']", "example", true], + ["[class='example']", "example", false], + ["[class~='example']", "example", true], + ["[class~='example']", "other", false], + ["[class*='xamp']", "example", true], + ["[class]", "example", true], +])( + ".test%s over the class list test %s matches: %s", + (selector, name, matches) => { + registerCSS(`.test { width: 40px; } .test${selector} { width: 80px; }`); + render(); + expect(screen.getByTestId(testID).props.style.width).toBe( + matches ? 80 : 40, + ); + }, +); + +test.each([ + ["group active", true], + ["group", false], +])("an ancestor class list %p answers [class~=…]: %s", (name, matches) => { + registerCSS( + `.child { width: 40px; } .child:is(:where(.group)[class~="active"] *) { width: 80px; }`, + ); + render( + + + , + ); + expect(screen.getByTestId(testID).props.style.width).toBe(matches ? 80 : 40); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 02bcc7b1..cc8b8684 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -542,17 +542,21 @@ type CamelCase = ? `${Lowercase}${Uppercase}${CamelCase}` : Lowercase; +// React Native names the class list className, and keeps an aria-* prop as written +function attributePropName(name: string): string { + if (name === "class") { + return "className"; + } + + return name.startsWith("aria-") ? name : toRNProperty(name); +} + function attributeQueryFor( component: Extract, ): AttributeQuery { const query: AttributeQuery = component.name.startsWith("data-") ? ["d", toRNProperty(component.name.replace("data-", ""))] - : [ - "a", - component.name.startsWith("aria-") - ? component.name - : toRNProperty(component.name), - ]; + : ["a", attributePropName(component.name)]; const operation = component.operation; if (!operation) {