From 55253748bcb7fc6fa7707aeddfbd280dd8bfb5b9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 03:50:14 +0300 Subject: [PATCH] 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()); +}