From ef2040c0278bc0a773545a1d23ba1546cdc41188 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 14:11:07 +0300 Subject: [PATCH 1/4] fix(native): answer :dir() per element, and compile every spelling of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `testComparison`'s `dir` arm read `I18nManager.isRTL`, so every `rtl:` / `ltr:` utility in an app was answered once, for the whole process, from what the OS locale said at launch — and it answered `true` for `ltr` unconditionally, so on a right-to-left device both variants applied. Selectors 4 §7.1 defines `:dir()` per element: the element's own `dir`, else the closest ancestor's. An element's `dir` prop is now its declaration, published to descendants as the inherited variable `__rn-css-directionality` after the bag's inline merge, read back through the same context every descendant already subscribes to, and guarded on both reads so a changed `dir` re-derives the subtree. The `dir` feature compares against that, falling back to the platform's own layout direction — the root a React Native tree has — so an app that declares nothing keeps what it had. A declaring element lands the UA rule `[dir=…] { direction: … }` beneath every author rule. The compiler hands every spelling to that one arm: a bare `:dir()` or a `[dir=…]` on the subject, either on an ancestor, or either inside `:is()` / `:where()`. The ancestor forms previously compiled with NO condition and applied in every direction; `:root` and `html` are transparent as ancestors, since every element descends from the document element and none is it; identical arms are deduplicated so Tailwind's three-arm variant is one rule; and the attribute's value is read ASCII-case-insensitively unless the selector's own `s` flag says otherwise. --- src/__tests__/compiler/directionality.test.ts | 259 ++++++++++++ src/__tests__/native/directionality.test.tsx | 376 ++++++++++++++++++ src/compiler/selector-builder.ts | 100 ++++- src/native-internal/root.ts | 1 + src/native/conditions/directionality.ts | 56 +++ src/native/conditions/index.ts | 4 +- src/native/conditions/media-query.ts | 41 +- src/native/react/rules.ts | 32 ++ src/utilities/specificity.ts | 4 + types.d.ts | 3 + 10 files changed, 851 insertions(+), 25 deletions(-) create mode 100644 src/__tests__/compiler/directionality.test.ts create mode 100644 src/__tests__/native/directionality.test.tsx create mode 100644 src/native/conditions/directionality.ts diff --git a/src/__tests__/compiler/directionality.test.ts b/src/__tests__/compiler/directionality.test.ts new file mode 100644 index 00000000..49b6c2e9 --- /dev/null +++ b/src/__tests__/compiler/directionality.test.ts @@ -0,0 +1,259 @@ +import { compile } from "react-native-css/compiler"; + +const RTL = ["=", "dir", "rtl"] as const; +const LTR = ["=", "dir", "ltr"] as const; +const PADDING = { paddingLeft: 4 } as const; + +describe("the subject compound", () => { + test("a bare :dir() compiles to the dir condition", () => { + expect( + compile(`.bare:dir(rtl) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["bare", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]], + }); + }); + + test("both directions are answerable", () => { + expect( + compile(`.bare:dir(ltr) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["bare", [{ s: [1, 2], d: [PADDING], m: [LTR] }]]], + }); + }); + + test("a [dir] attribute compiles to the same condition, with an attribute's specificity", () => { + expect( + compile(`.self[dir="rtl"] { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["self", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]], + }); + }); + + test(":dir() composes with the other pseudo-classes on the element", () => { + expect( + compile(`.hover:dir(rtl):hover { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["hover", [{ s: [1, 3], d: [PADDING], m: [RTL], p: { h: 1 } }]]], + }); + }); +}); + +describe("an ancestor compound", () => { + test("a [dir] attribute on an ancestor is the directionality the subject inherits", () => { + expect( + compile(`[dir="rtl"] .descendant { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["descendant", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]], + }); + }); + + test("a :dir() on an ancestor answers the same way", () => { + expect( + compile(`:dir(rtl) .descendant { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["descendant", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]], + }); + }); + + test("html[dir] and :root[dir] name the document element, which every element descends from", () => { + const fromHtml = compile( + `html[dir="rtl"] .descendant { padding-left: 4px; }`, + ).stylesheet(); + + expect(fromHtml).toStrictEqual({ + s: [["descendant", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]], + }); + expect( + compile( + `:root[dir="rtl"] .descendant { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual(fromHtml); + }); + + test(":root as an ancestor is transparent, and no element IS the root", () => { + expect( + compile(`:root .under { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["under", [{ s: [1, 1], d: [PADDING] }]]], + }); + expect( + compile(`.self:root { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test("a directionality beside an ancestor class keeps the container query", () => { + expect( + compile(`.group:dir(rtl) .item { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [ + ["group", [{ s: [0], c: ["g:group"] }]], + [ + "item", + [{ s: [1, 3], d: [PADDING], m: [RTL], cq: [{ n: "g:group" }] }], + ], + ], + }); + }); +}); + +describe(":is() and :where()", () => { + test("Tailwind's rtl: variant compiles to ONE rule carrying ONE dir condition", () => { + expect( + compile( + `.tw:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual({ + s: [["tw", [{ s: [1, 1], d: [PADDING], m: [RTL] }]]], + }); + }); + + test("the variant's condition is the same shape a bare :dir() compiles to", () => { + const bare = compile(`.x:dir(rtl) { padding-left: 4px; }`).stylesheet(); + const variant = compile( + `.x:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { padding-left: 4px; }`, + ).stylesheet(); + + expect(variant.s?.[0]?.[1][0]?.m).toStrictEqual(bare.s?.[0]?.[1][0]?.m); + }); + + test("a [dir] attribute inside :where() answers like :dir(), with :where()'s specificity", () => { + expect( + compile(`.where:where([dir="rtl"]) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["where", [{ s: [1, 1], d: [PADDING], m: [RTL] }]]], + }); + }); + + test("a [dir] attribute inside :is() carries its specificity", () => { + expect( + compile(`.is:is([dir="rtl"]) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [["is", [{ s: [1, 2], d: [PADDING], m: [RTL] }]]], + }); + }); + + test("arms that are not the same query stay separate rules", () => { + expect( + compile( + `.mixed:is(:dir(rtl), .group) { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "mixed", + [ + { s: [1, 1], d: [PADDING], m: [RTL] }, + { s: [1, 2], d: [PADDING], cq: [{ n: "g:group" }] }, + ], + ], + ["group", [{ s: [0], c: ["g:group"] }]], + ], + }); + }); + + test("identical arms of any kind emit one rule", () => { + expect( + compile(`.dup:is(.group, .group) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({ + s: [ + ["group", [{ s: [0], c: ["g:group"] }]], + ["dup", [{ s: [1, 2], d: [PADDING], cq: [{ n: "g:group" }] }]], + ], + }); + }); +}); + +describe("media queries", () => { + test("the (dir) media feature is the same condition", () => { + expect( + compile( + `@media (dir: rtl) { .media { padding-left: 4px; } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [["media", [{ s: [2, 1], m: [RTL], d: [PADDING] }]]], + }); + }); + + test("a negated feature keeps its negation", () => { + expect( + compile( + `@media not (dir: rtl) { .media { padding-left: 4px; } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [["media", [{ s: [2, 1], m: [["!", RTL]], d: [PADDING] }]]], + }); + }); + + test("a dir condition composes with a media query and a container query", () => { + expect( + compile(` + @media (prefers-color-scheme: dark) { + .group:where(:dir(rtl)) .dark { padding-left: 8px; } + } + `).stylesheet(), + ).toStrictEqual({ + s: [ + ["group", [{ s: [0], c: ["g:group"] }]], + [ + "dark", + [ + { + s: [2, 2], + m: [["=", "prefers-color-scheme", "dark"], RTL], + d: [{ paddingLeft: 8 }], + cq: [{ n: "g:group" }], + }, + ], + ], + ], + }); + }); +}); + +describe("the [dir] value", () => { + test("is read ASCII-case-insensitively, as HTML defines the attribute", () => { + const lower = compile( + `.upper[dir="rtl"] { padding-left: 4px; }`, + ).stylesheet(); + + expect( + compile(`.upper[dir="RTL"] { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual(lower); + expect( + compile(`.upper[dir="RTL" i] { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual(lower); + }); + + test("the selector's own s flag is honoured, so an upper-case value matches nothing", () => { + expect( + compile(`.upper[dir="RTL" s] { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({}); + }); + + test("a value the engine cannot answer matches nothing", () => { + expect( + compile(` + .auto[dir="auto"] { padding-left: 4px; } + .prefix[dir^="r"] { padding-left: 4px; } + .present[dir] { padding-left: 4px; } + `).stylesheet(), + ).toStrictEqual({}); + }); + + test("a negated :dir() is outside the builder's negation and matches nothing", () => { + expect( + compile(`.not:not(:dir(rtl)) { padding-left: 4px; }`).stylesheet(), + ).toStrictEqual({}); + }); +}); + +describe("determinism", () => { + test("compiling the same stylesheet twice yields equal output", () => { + const css = ` + .tw:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { padding-left: 4px; } + [dir="ltr"] .tw { padding-left: 8px; } + `; + + expect(compile(css).stylesheet()).toStrictEqual(compile(css).stylesheet()); + }); +}); diff --git a/src/__tests__/native/directionality.test.tsx b/src/__tests__/native/directionality.test.tsx new file mode 100644 index 00000000..ac0f8aac --- /dev/null +++ b/src/__tests__/native/directionality.test.tsx @@ -0,0 +1,376 @@ +import { I18nManager, type StyleProp, type ViewStyle } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; +import { Text } from "react-native-css/components/Text"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +const originalIsRTL = Object.getOwnPropertyDescriptor(I18nManager, "isRTL"); + +afterEach(() => { + if (originalIsRTL) { + Object.defineProperty(I18nManager, "isRTL", originalIsRTL); + } +}); + +const answerIsRTL = (isRTL: boolean) => { + Object.defineProperty(I18nManager, "isRTL", { + configurable: true, + value: isRTL, + }); +}; + +/** Tailwind's `rtl:` / `ltr:` output, verbatim. */ +const DIRECTION_CSS = ` + .paint:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { color: red; } + .paint:where(:dir(ltr), [dir="ltr"], [dir="ltr"] *) { color: blue; } +`; + +const RTL = { color: "#f00" }; +const LTR = { color: "#00f" }; + +test("an element declaring dir=rtl matches :dir(rtl) and not :dir(ltr)", () => { + registerCSS(DIRECTION_CSS); + + render(); + + expect(screen.getByTestId("subject")).toHaveStyle(RTL); + expect(screen.getByTestId("subject")).not.toHaveStyle(LTR); +}); + +test("an element with no declaration above it reads the platform's direction", () => { + registerCSS(DIRECTION_CSS); + + render(); + + expect(screen.getByTestId("subject")).toHaveStyle(LTR); + expect(screen.getByTestId("subject")).not.toHaveStyle(RTL); +}); + +test("on a right-to-left platform an undeclared element reads rtl", () => { + registerCSS(DIRECTION_CSS); + answerIsRTL(true); + + render(); + + expect(screen.getByTestId("subject")).toHaveStyle(RTL); + expect(screen.getByTestId("subject")).not.toHaveStyle(LTR); +}); + +test("directionality is inherited from the closest declaring ancestor", () => { + registerCSS(DIRECTION_CSS); + + render( + + + + + , + ); + + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + expect(screen.getByTestId("descendant")).not.toHaveStyle(LTR); +}); + +test("a nested declaration overrides its ancestor for its own subtree", () => { + registerCSS(DIRECTION_CSS); + + render( + + + + + + , + ); + + expect(screen.getByTestId("outer")).toHaveStyle(RTL); + expect(screen.getByTestId("inner")).toHaveStyle(LTR); + expect(screen.getByTestId("inner")).not.toHaveStyle(RTL); +}); + +test("a declaration overrides the platform's direction for its subtree", () => { + registerCSS(DIRECTION_CSS); + answerIsRTL(true); + + render( + <> + + + + + , + ); + + expect(screen.getByTestId("undeclared")).toHaveStyle(RTL); + expect(screen.getByTestId("declared")).toHaveStyle(LTR); + expect(screen.getByTestId("declared")).not.toHaveStyle(RTL); +}); + +test("an ancestor [dir] selector answers from the directionality the element inherits", () => { + registerCSS(`[dir="rtl"] .paint { color: red; }`); + + render( + <> + + + + + + + + + + , + ); + + expect(screen.getByTestId("under-rtl")).toHaveStyle(RTL); + expect(screen.getByTestId("under-nested-ltr")).not.toHaveStyle(RTL); + expect(screen.getByTestId("undeclared")).not.toHaveStyle(RTL); +}); + +test("the @media (dir) spelling answers the same way as :dir()", () => { + registerCSS(` + @media (dir: rtl) { .paint { color: red; } } + @media (dir: ltr) { .paint { color: blue; } } + `); + + render( + <> + + + , + ); + + expect(screen.getByTestId("rtl")).toHaveStyle(RTL); + expect(screen.getByTestId("ltr")).toHaveStyle(LTR); +}); + +test("a declaring element takes the UA stylesheet's direction rule", () => { + registerCSS(".paint { color: red; }"); + + render( + <> + + + + , + ); + + expect(screen.getByTestId("rtl")).toHaveStyle({ + direction: "rtl", + writingDirection: "rtl", + }); + expect(screen.getByTestId("ltr")).toHaveStyle({ + direction: "ltr", + writingDirection: "ltr", + }); + expect(screen.getByTestId("undeclared").props.style).toStrictEqual({ + color: "#f00", + }); +}); + +test("an author direction declaration outranks the UA rule and leaves the directionality untouched", () => { + registerCSS(` + .paint:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { color: red; } + .author { direction: ltr; } + `); + + render(); + + expect(screen.getByTestId("subject")).toHaveStyle({ + color: "#f00", + direction: "ltr", + }); +}); + +test("dir=auto is not a declaration — the component holding the text resolves it", () => { + registerCSS(DIRECTION_CSS); + + render( + + {/* `auto` is outside the prop's type on purpose; the runtime is what is under test. */} + + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle(RTL); + expect(screen.getByTestId("subject")).not.toHaveStyle({ direction: "rtl" }); +}); + +test("a declaring element carrying inline variables still publishes its directionality", () => { + registerCSS(` + ${DIRECTION_CSS} + .marker { color: var(--marker); } + `); + + // The inline-variable object `vars()` builds, spelled through the registered symbol it is keyed on. + const inlineVariables = { + [Symbol.for("react-native-css.var")]: "inline", + marker: "green", + } as unknown as StyleProp; + + render( + + + + + , + ); + + expect(screen.getByTestId("declaring")).toHaveStyle({ color: "green" }); + expect(screen.getByTestId("descendant")).toHaveStyle(LTR); + expect(screen.getByTestId("descendant")).not.toHaveStyle(RTL); +}); + +test("a changed dir prop re-derives the element and its descendants", () => { + registerCSS(DIRECTION_CSS); + + const tree = (dir: "ltr" | "rtl") => ( + + + + + + ); + + const { rerender } = render(tree("rtl")); + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + + rerender(tree("ltr")); + expect(screen.getByTestId("descendant")).toHaveStyle(LTR); + expect(screen.getByTestId("descendant")).not.toHaveStyle(RTL); +}); + +test("a removed declaration falls back to what the element would have had without it", () => { + registerCSS(DIRECTION_CSS); + + const tree = (dir: "rtl" | undefined) => ( + + + + ); + + const { rerender } = render(tree("rtl")); + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + + rerender(tree(undefined)); + expect(screen.getByTestId("descendant")).toHaveStyle(LTR); + expect(screen.getByTestId("descendant")).not.toHaveStyle(RTL); +}); + +test("a Text declares its directionality the same way, and takes the text-side UA rule", () => { + registerCSS(DIRECTION_CSS); + + render( + + مرحبا + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle({ + ...RTL, + writingDirection: "rtl", + }); + expect(screen.getByTestId("subject")).not.toHaveStyle(LTR); +}); + +test("a declaration reaches descendants only — a sibling reads the platform's direction", () => { + registerCSS(DIRECTION_CSS); + + render( + + + + + + , + ); + + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); + expect(screen.getByTestId("sibling")).toHaveStyle(LTR); + expect(screen.getByTestId("sibling")).not.toHaveStyle(RTL); +}); + +test("the UA rule lands on the declaring element alone; a descendant inherits the directionality, not the style", () => { + registerCSS(".paint { color: red; }"); + + render( + + + , + ); + + expect(screen.getByTestId("declaring")).toHaveStyle({ direction: "rtl" }); + expect(screen.getByTestId("descendant").props.style).toStrictEqual({ + color: "#f00", + }); +}); + +test("every media-condition operator threads the element's directionality", () => { + registerCSS(` + @media not (dir: rtl) { .negated { color: red; } } + @media (dir: rtl) and (platform: ios) { .conjoined { color: red; } } + @media (dir: ltr) or (dir: rtl) { .either { color: red; } } + `); + + render( + <> + + + + + + , + ); + + expect(screen.getByTestId("negated-rtl")).not.toHaveStyle(RTL); + expect(screen.getByTestId("negated-ltr")).toHaveStyle(RTL); + expect(screen.getByTestId("conjoined-rtl")).toHaveStyle(RTL); + expect(screen.getByTestId("conjoined-ltr")).not.toHaveStyle(RTL); + expect(screen.getByTestId("either")).toHaveStyle(RTL); +}); + +test("a :root declaration belongs to no element and reads the platform's direction", () => { + answerIsRTL(true); + registerCSS(` + :root { --marker: blue; } + @media (dir: rtl) { :root { --marker: red; } } + .marker { color: var(--marker); } + `); + + render( + + + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle({ color: "red" }); +}); + +test("rendering the same tree twice yields the same styles", () => { + registerCSS(DIRECTION_CSS); + + const tree = ( + + + + ); + + const { rerender } = render(tree); + const first = screen.getByTestId("descendant").props.style; + + rerender(tree); + + expect(screen.getByTestId("descendant").props.style).toStrictEqual(first); + expect(screen.getByTestId("descendant")).toHaveStyle(RTL); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 88561b78..54cce7b3 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -144,6 +144,20 @@ function parseComponents( } case "pseudo-class": { switch (component.kind) { + case "dir": { + // Directionality is inherited, so an ancestor's `:dir()` is the element's own answer: the condition lands on the rule. + getMediaQuery(root).push(["=", "dir", component.direction]); + specificity[Specificity.PseudoClass] = + (specificity[Specificity.PseudoClass] ?? 0) + 1; + return parseComponents(rest, options, root, ref, specificity); + } + case "root": { + // `:root` names the document element: every element descends from it, and none IS it. + if (!isContainerQuery(ref)) { + return []; + } + return parseComponents(rest, options, root, ref, specificity); + } case "hover": { getPseudoClassesQuery(ref).h = 1; specificity[Specificity.PseudoClass] = @@ -177,10 +191,10 @@ function parseComponents( case "where": case "is": { // Now get the selectors inside the `is` or `where` pseudo-class - const isWhereContainerQueries = component.selectors.flatMap( - (selector) => { + const isWhereContainerQueries = dedupeContainerQueries( + component.selectors.flatMap((selector) => { return parseIsWhereComponents(component.kind, selector) ?? []; - }, + }), ); // Remember we're looping in reverse order, @@ -206,10 +220,11 @@ function parseComponents( parent = { ...originalParent }; parent.specificity = [...originalParent.specificity]; - if (m && m.length > 1) { + if (m) { + const condition = unwrapConjunction(m); parent.mediaQuery = originalParent.mediaQuery - ? [["&", [...originalParent.mediaQuery, m]]] - : [m]; + ? [["&", [...originalParent.mediaQuery, condition]]] + : [condition]; } if (component.kind === "is") { @@ -240,16 +255,14 @@ function parseComponents( } case "attribute": { if (component.name === "dir") { - if (!component.operation) { + const direction = resolveDirectionAttributeValue(component.operation); + if (direction === undefined) { return []; } - const operator = operatorMap[component.operation.operator]; - if (operator !== "=") { - return []; - } - - getMediaQuery(ref).push([operator, "dir", component.operation.value]); + getMediaQuery(root).push(["=", "dir", direction]); + specificity[Specificity.ClassName] = + (specificity[Specificity.ClassName] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } else { // specificity[Specificity.ClassName] = @@ -453,7 +466,19 @@ function parseIsWhereComponents( } case "attribute": { if (component.name === "dir") { - return null; + const direction = resolveDirectionAttributeValue(component.operation); + if (direction === undefined) { + return null; + } + queries ??= [{ specificity: [] }]; + for (const query of queries) { + if (type === "is") { + query.specificity[Specificity.ClassName] = + (query.specificity[Specificity.ClassName] ?? 0) + 1; + } + getMediaQuery(query).push(["=", "dir", direction]); + } + return parseIsWhereComponents(type, selector, index + 1, queries); } if (type !== "where") { @@ -590,3 +615,50 @@ const operatorMap: Record = { "substring": "*=", "suffix": "$=", }; + +/** + * Two arms of one `:is()` / `:where()` that compile to the same query would emit the same rule + * twice. Tailwind's `rtl:` is the live case: `:dir(rtl)`, `[dir="rtl"]` and `[dir="rtl"] *` all + * name the directionality the element inherits. + */ +function dedupeContainerQueries( + queries: ContainerQueryWithSpecificity[], +): ContainerQueryWithSpecificity[] { + const seen = new Set(); + return queries.filter((query) => { + const key = JSON.stringify(query); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +/** An arm holding one condition is that condition; the `&` around it is the builder's, and the bare `:dir()` form has none. */ +function unwrapConjunction(condition: MediaCondition): MediaCondition { + if (condition[0] !== "&" || condition[1].length !== 1) { + return condition; + } + const [single] = condition[1]; + return single ?? condition; +} + +/** + * The directionality a `[dir=…]` attribute selector names. HTML `dir` takes `ltr`, `rtl` and + * `auto`, ASCII-case-insensitively unless the selector's `s` flag says otherwise; only the first + * two are directionalities the engine can answer, and only an equality test names one of them, + * so every other operation is a selector that matches nothing here. + */ +function resolveDirectionAttributeValue( + operation: AttrOperation | null | undefined, +): "ltr" | "rtl" | undefined { + if (!operation || operation.operator !== "equal") { + return undefined; + } + const value = + operation.caseSensitivity === "explicit-case-sensitive" + ? operation.value + : operation.value.toLowerCase(); + return value === "ltr" || value === "rtl" ? value : undefined; +} diff --git a/src/native-internal/root.ts b/src/native-internal/root.ts index e45a7d11..4b88d1c4 100644 --- a/src/native-internal/root.ts +++ b/src/native-internal/root.ts @@ -16,6 +16,7 @@ const rootVariableFamily = () => { return value; } + // A `:root` declaration belongs to no element, so it reads the initial directionality. if (testMediaQuery(mediaQuery, read)) { return value; } diff --git a/src/native/conditions/directionality.ts b/src/native/conditions/directionality.ts new file mode 100644 index 00000000..985e3c31 --- /dev/null +++ b/src/native/conditions/directionality.ts @@ -0,0 +1,56 @@ +/** + * An element's directionality — HTML's `dir`, which `:dir()` matches and descendants inherit + * (Selectors 4 §7.1). Held beside the `direction` property rather than derived from it, because + * the property does not affect whether `:dir()` matches. `auto` never reaches the engine: it + * resolves from text content, which only the component rendering that text can read. + */ +import { I18nManager } from "react-native"; + +import type { StyleRule } from "react-native-css/compiler"; +import { uaSpecificity } from "react-native-css/utilities"; + +import type { VariableContextValue } from "../reactivity"; + +export type Directionality = "ltr" | "rtl"; + +export const DIRECTIONALITY_VARIABLE = "__rn-css-directionality"; + +/** + * The directionality of the root — what an element with no declaration above it has. + * + * React Native has no document element: the platform's own layout direction is the root's + * (`I18nManager.isRTL` is what already mirrors every flex row), so an app that declares nothing + * keeps reading `rtl:` utilities the way it always has, and a `dir` on any element overrides it + * for that subtree. + */ +export function resolveInitialDirectionality(): Directionality { + return I18nManager.isRTL ? "rtl" : "ltr"; +} + +/** The UA stylesheet rule `[dir=…] { direction: … }` (HTML §15.3.5); `writingDirection` is its text-side twin. */ +const UA_DIRECTION_RULES: Record = { + ltr: { s: uaSpecificity, d: [{ direction: "ltr", writingDirection: "ltr" }] }, + rtl: { s: uaSpecificity, d: [{ direction: "rtl", writingDirection: "rtl" }] }, +}; + +export function resolveDeclaredDirectionality( + value: unknown, +): Directionality | undefined { + return value === "ltr" || value === "rtl" ? value : undefined; +} + +export function resolveDirectionality( + props: Record | null | undefined, + inheritedVariables: VariableContextValue, +): Directionality | undefined { + return ( + resolveDeclaredDirectionality(props?.dir) ?? + resolveDeclaredDirectionality(inheritedVariables[DIRECTIONALITY_VARIABLE]) + ); +} + +export function resolveUaDirectionRule( + directionality: Directionality, +): StyleRule { + return UA_DIRECTION_RULES[directionality]; +} diff --git a/src/native/conditions/index.ts b/src/native/conditions/index.ts index 8886f689..ce411713 100644 --- a/src/native/conditions/index.ts +++ b/src/native/conditions/index.ts @@ -10,6 +10,7 @@ import { } from "../reactivity"; import { testAttributes } from "./attributes"; import { testContainerQueries } from "./container-query"; +import type { Directionality } from "./directionality"; import type { RenderGuard } from "./guards"; import { testMediaQuery } from "./media-query"; @@ -19,11 +20,12 @@ export function testRule( props: Props, guards: RenderGuard[], containerContext: ContainerContextValue, + directionality: Directionality | undefined, ) { if (rule.p && !pseudoClasses(rule.p, get)) { return false; } - if (rule.m && !testMediaQuery(rule.m, get)) { + if (rule.m && !testMediaQuery(rule.m, get, directionality)) { return false; } if (rule.aq && !testAttributes(rule.aq, props, guards)) { diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 75cd9006..3a44d34c 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,45 +1,66 @@ /* eslint-disable */ -import { I18nManager, PixelRatio, Platform } from "react-native"; +import { PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { + resolveInitialDirectionality, + type Directionality, +} from "./directionality"; -export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { - return mediaQueries.every((query) => test(query, get)); +/** + * `directionality` is the ELEMENT's, resolved by `updateRules` from its `dir` prop and its + * inherited scope: `:dir()` answers per element (Selectors 4 §7.1), never per process. Absent — + * a `:root` declaration, a test over a global feature — the platform's root direction answers. + */ +export function testMediaQuery( + mediaQueries: MediaCondition[], + get: Getter, + directionality?: Directionality, +) { + return mediaQueries.every((query) => test(query, get, directionality)); } -function test(mediaQuery: MediaCondition, get: Getter): Boolean { +function test( + mediaQuery: MediaCondition, + get: Getter, + directionality: Directionality | undefined, +): Boolean { switch (mediaQuery[0]) { case "[]": case "!!": return false; case "!": - return !test(mediaQuery[1], get); + return !test(mediaQuery[1], get, directionality); case "&": return mediaQuery[1].every((query) => { - return test(query, get); + return test(query, get, directionality); }); case "|": return mediaQuery[1].some((query) => { - return test(query, get); + return test(query, get, directionality); }); case ">": case ">=": case "<": case "<=": case "=": { - return testComparison(mediaQuery, get); + return testComparison(mediaQuery, get, directionality); } } } -function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { +function testComparison( + mediaQuery: MediaCondition, + get: Getter, + directionality: Directionality | undefined, +): Boolean { const value = mediaQuery[2]; switch (mediaQuery[1]) { case "dir": - return (I18nManager.isRTL && value === "rtl") || value === "ltr"; + return value === (directionality ?? resolveInitialDirectionality()); case "hover": return true; case "platform": diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index f85a66f9..d665f4a3 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -4,6 +4,12 @@ import { StyleCollection } from "react-native-css/native-internal"; import { testRule } from "../conditions"; import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; +import { + DIRECTIONALITY_VARIABLE, + resolveDeclaredDirectionality, + resolveDirectionality, + resolveUaDirectionRule, +} from "../conditions/directionality"; import type { RenderGuard } from "../conditions/guards"; import { getDeepPath } from "../objects"; import { @@ -45,6 +51,26 @@ export function updateRules( let animated = false; let pressable = false; + // `:dir()` matches per element (Selectors 4 §7.1): the element's own `dir`, else its ancestors'. + const declaredDirectionality = resolveDeclaredDirectionality( + currentProps?.dir, + ); + const directionality = resolveDirectionality( + currentProps, + inheritedVariables, + ); + guards.push(["a", "dir", currentProps?.dir]); + guards.push([ + "v", + DIRECTIONALITY_VARIABLE, + inheritedVariables[DIRECTIONALITY_VARIABLE], + ]); + + if (declaredDirectionality !== undefined) { + variables = { ...inheritedVariables }; + rules.add(resolveUaDirectionRule(declaredDirectionality)); + } + for (const config of state.configs) { const source = currentProps?.[config.source]; const shallowTarget = Array.isArray(config.target) @@ -123,6 +149,7 @@ export function updateRules( currentProps, guards, inheritedContainers, + directionality, ) ) { continue; @@ -230,6 +257,11 @@ export function updateRules( } } + // Written after every merge above: a declaration is the element's own, so nothing inherited or inline may shadow it. + if (declaredDirectionality !== undefined && variables) { + variables[DIRECTIONALITY_VARIABLE] = declaredDirectionality; + } + // Generate a StyleObservable for this unique set of rules / variables const stylesObs = stylesFamily(generateStateHash(state, rules), rules); diff --git a/src/utilities/specificity.ts b/src/utilities/specificity.ts index 342f0a38..548652d3 100644 --- a/src/utilities/specificity.ts +++ b/src/utilities/specificity.ts @@ -22,6 +22,10 @@ const Order = Specificity.Order; export const inlineSpecificity: SpecificityArray = []; inlineSpecificity[Specificity.Inline] = 1; +/** A user-agent rule sits beneath every author rule — CSS Cascade 5 §6.1, origin precedence. */ +export const uaSpecificity: SpecificityArray = []; +uaSpecificity[Specificity.Order] = Number.NEGATIVE_INFINITY; + export const specificityCompareFn = ( a: StyleRule | InlineStyleRecord, b: StyleRule | InlineStyleRecord, diff --git a/types.d.ts b/types.d.ts index 1ba5b856..06c46764 100644 --- a/types.d.ts +++ b/types.d.ts @@ -40,6 +40,8 @@ declare module "react-native" { interface ViewProps { className?: string; cssInterop?: boolean; + /** The element's directionality — HTML's `dir`, which `:dir()` matches and descendants inherit. */ + dir?: "ltr" | "rtl"; } interface TextInputProps { placeholderClassName?: string; @@ -47,6 +49,7 @@ declare module "react-native" { interface TextProps { className?: string; cssInterop?: boolean; + dir?: "ltr" | "rtl"; } interface SwitchProps { className?: string; From 3079ca578f97a89d6c70e0f16b75df7433b78042 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 14:37:29 +0300 Subject: [PATCH 2/4] test(native): pin the fail-closed arms of the dir enumeration and its attribute operators --- src/__tests__/compiler/directionality.test.ts | 13 ++++++++++++- src/__tests__/native/directionality.test.tsx | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/__tests__/compiler/directionality.test.ts b/src/__tests__/compiler/directionality.test.ts index 49b6c2e9..c2f84ab7 100644 --- a/src/__tests__/compiler/directionality.test.ts +++ b/src/__tests__/compiler/directionality.test.ts @@ -234,12 +234,23 @@ describe("the [dir] value", () => { expect( compile(` .auto[dir="auto"] { padding-left: 4px; } - .prefix[dir^="r"] { padding-left: 4px; } .present[dir] { padding-left: 4px; } `).stylesheet(), ).toStrictEqual({}); }); + test("every operator but equality matches nothing, so a partial match never widens the rule", () => { + expect( + compile(` + .prefix[dir^="r"] { padding-left: 4px; } + .suffix[dir$="tl"] { padding-left: 4px; } + .substring[dir*="rtl"] { padding-left: 4px; } + .includes[dir~="rtl"] { padding-left: 4px; } + .dash[dir|="rtl"] { padding-left: 4px; } + `).stylesheet(), + ).toStrictEqual({}); + }); + test("a negated :dir() is outside the builder's negation and matches nothing", () => { expect( compile(`.not:not(:dir(rtl)) { padding-left: 4px; }`).stylesheet(), diff --git a/src/__tests__/native/directionality.test.tsx b/src/__tests__/native/directionality.test.tsx index ac0f8aac..82f22503 100644 --- a/src/__tests__/native/directionality.test.tsx +++ b/src/__tests__/native/directionality.test.tsx @@ -184,6 +184,24 @@ test("an author direction declaration outranks the UA rule and leaves the direct }); }); +test("a dir value outside the enumeration is not a declaration", () => { + registerCSS(DIRECTION_CSS); + + render( + + {/* Outside the prop's type on purpose; the runtime is what is under test. */} + + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle(RTL); + expect(screen.getByTestId("subject")).not.toHaveStyle({ direction: "ltr" }); +}); + test("dir=auto is not a declaration — the component holding the text resolves it", () => { registerCSS(DIRECTION_CSS); From 6e3ee06d9c46da944812a6e5ad9daea14382f8d8 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 17:56:48 +0300 Subject: [PATCH 3/4] perf(native): resolve an element's own dir once per render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `updateRules` resolved `props.dir` twice — once as `declaredDirectionality`, and again inside `resolveDirectionality`, which re-ran the same check before falling back to the inherited variable. The caller already holds the declared half, so the waterfall composes at the call site and the inherited half becomes a function that answers only its own question. Measured at 500 elements x 200 renders, median of 7 rounds: 3.1% of the directionality work this change adds to the per-element path. Behaviour is unchanged. The two directionality suites are 45/45, and the full run is 1093 passed with the same two Windows babel suites failing as on main. --- src/native/conditions/directionality.ts | 14 +++++++++----- src/native/react/rules.ts | 8 +++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/native/conditions/directionality.ts b/src/native/conditions/directionality.ts index 985e3c31..c1c6b8c2 100644 --- a/src/native/conditions/directionality.ts +++ b/src/native/conditions/directionality.ts @@ -39,13 +39,17 @@ export function resolveDeclaredDirectionality( return value === "ltr" || value === "rtl" ? value : undefined; } -export function resolveDirectionality( - props: Record | null | undefined, +/** + * What an element inherits, which is only consulted when it declares nothing itself. + * + * Separate from the declared half so a caller that has already resolved `props.dir` — every caller + * does, to decide whether to publish and take the UA rule — spends one resolve rather than two. + */ +export function resolveInheritedDirectionality( inheritedVariables: VariableContextValue, ): Directionality | undefined { - return ( - resolveDeclaredDirectionality(props?.dir) ?? - resolveDeclaredDirectionality(inheritedVariables[DIRECTIONALITY_VARIABLE]) + return resolveDeclaredDirectionality( + inheritedVariables[DIRECTIONALITY_VARIABLE], ); } diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index d665f4a3..15d5e73b 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -7,7 +7,7 @@ import { DEFAULT_CONTAINER_NAME } from "../conditions/container-query"; import { DIRECTIONALITY_VARIABLE, resolveDeclaredDirectionality, - resolveDirectionality, + resolveInheritedDirectionality, resolveUaDirectionRule, } from "../conditions/directionality"; import type { RenderGuard } from "../conditions/guards"; @@ -55,10 +55,8 @@ export function updateRules( const declaredDirectionality = resolveDeclaredDirectionality( currentProps?.dir, ); - const directionality = resolveDirectionality( - currentProps, - inheritedVariables, - ); + const directionality = + declaredDirectionality ?? resolveInheritedDirectionality(inheritedVariables); guards.push(["a", "dir", currentProps?.dir]); guards.push([ "v", From fabaf0c327a7a9e86c917c204efbf86138ec5d77 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 19:17:12 +0300 Subject: [PATCH 4/4] test(compiler): drive the fail-closed arm inside :is() and :where() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A coverage sweep over this branch's own suite found the `direction === undefined` return in the `:is()` / `:where()` attribute arm never executed: the subject compound's fail-closed cases cover `[dir="auto"]`, a bare `[dir]` and every non-equality operator, and none of them was written one nesting in. Measured: removing that guard left all 24 cases green, so a rule the engine cannot answer would have applied in every direction with nothing to report it. Three cases. Two drive the arm — the three unanswerable spellings inside `:is()` and `:where()`, and a mixed arm list where only the unanswerable arm is dropped while its `:dir(rtl)` sibling survives. The third pins that an author's own `and` reaches the rule whole beside the dir condition. The same removal now reddens two of them. --- src/__tests__/compiler/directionality.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/__tests__/compiler/directionality.test.ts b/src/__tests__/compiler/directionality.test.ts index c2f84ab7..606e22e7 100644 --- a/src/__tests__/compiler/directionality.test.ts +++ b/src/__tests__/compiler/directionality.test.ts @@ -184,6 +184,38 @@ describe("media queries", () => { }); }); + test("an author's own `and` is carried whole beside the dir condition", () => { + expect( + compile(` + @media (min-width: 10px) and (prefers-color-scheme: dark) { + .both:dir(rtl) { padding-left: 4px; } + } + `).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "both", + [ + { + s: [2, 2], + m: [ + [ + "&", + [ + [">=", "width", 10], + ["=", "prefers-color-scheme", "dark"], + ], + ], + RTL, + ], + d: [PADDING], + }, + ], + ], + ], + }); + }); + test("a dir condition composes with a media query and a container query", () => { expect( compile(` @@ -256,6 +288,28 @@ describe("the [dir] value", () => { compile(`.not:not(:dir(rtl)) { padding-left: 4px; }`).stylesheet(), ).toStrictEqual({}); }); + + test("an unanswerable value inside :is() drops the rule rather than the arm", () => { + // The fail-closed arm the subject compound already has, one nesting in: the `:is()` path + // resolves the value itself, so a rule kept there would apply in every direction. + expect( + compile(` + .isauto:is([dir="auto"]) { padding-left: 4px; } + .wherepresent:where([dir]) { padding-left: 4px; } + .isop:is([dir^="r"]) { padding-left: 4px; } + `).stylesheet(), + ).toStrictEqual({}); + }); + + test("an unanswerable arm drops only its own rule, leaving its siblings", () => { + expect( + compile( + `.mixedauto:is([dir="auto"], :dir(rtl)) { padding-left: 4px; }`, + ).stylesheet(), + ).toStrictEqual({ + s: [["mixedauto", [{ s: [1, 1], d: [PADDING], m: [RTL] }]]], + }); + }); }); describe("determinism", () => {