From d2f3a1b3611ea596bb2d24bf8e4467f85d8f70e0 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 14:11:35 +0300 Subject: [PATCH 1/2] fix(compiler): answer an ancestor condition that names no class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseComponents` opens a container-query ref at a descendant combinator and every later component in that compound writes its condition onto it, but only the `class` arm attached that ref to the rule. A compound identified by a condition alone — `[data-state="on"] .x`, `:hover .x`, `:disabled .x` — therefore wrote onto an object nothing read, and the rule shipped with no condition: it applied to every element its class named, in every state. Attach the ref when a condition is written onto it, idempotently, so a compound carrying both a condition and a class attaches once. The condition then resolves against the default container, which is what the `:is()` / `:where()` spelling of the same selector already compiled to, and an absent container answers false rather than true. --- .../compiler/ancestor-conditions.test.ts | 153 ++++++++++++++++++ .../native/ancestor-conditions.test.tsx | 111 +++++++++++++ src/compiler/selector-builder.ts | 45 ++++-- 3 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/compiler/ancestor-conditions.test.ts create mode 100644 src/__tests__/native/ancestor-conditions.test.tsx diff --git a/src/__tests__/compiler/ancestor-conditions.test.ts b/src/__tests__/compiler/ancestor-conditions.test.ts new file mode 100644 index 00000000..f2c8fa6e --- /dev/null +++ b/src/__tests__/compiler/ancestor-conditions.test.ts @@ -0,0 +1,153 @@ +import { compile } from "react-native-css/compiler"; + +const RED = { color: "#f00" } as const; +const INHERITED_COLOR = [["__rn-css-color", "#f00"]] as const; + +function rulesFor(css: string, className = "subject") { + return compile(css) + .stylesheet() + .s?.find(([name]) => name === className)?.[1]; +} + +describe("an ancestor identified by a condition alone", () => { + test("an attribute compiles to a container query carrying it", () => { + expect( + rulesFor(`[data-state="on"] .subject { color: red; }`), + ).toStrictEqual([ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["d", "state", "=", "on"]] }], + }, + ]); + }); + + test("an attribute with no operation compiles to a presence query", () => { + expect(rulesFor(`[aria-busy] .subject { color: red; }`)).toStrictEqual([ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["a", "ariaBusy"]] }], + }, + ]); + }); + + test("every pseudo-class the builder answers compiles to a container query", () => { + expect(rulesFor(`:hover .subject { color: red; }`)).toStrictEqual([ + { s: [1, 2], d: [RED], v: INHERITED_COLOR, cq: [{ p: { h: 1 } }] }, + ]); + expect(rulesFor(`:active .subject { color: red; }`)).toStrictEqual([ + { s: [1, 2], d: [RED], v: INHERITED_COLOR, cq: [{ p: { a: 1 } }] }, + ]); + expect(rulesFor(`:focus .subject { color: red; }`)).toStrictEqual([ + { s: [1, 2], d: [RED], v: INHERITED_COLOR, cq: [{ p: { f: 1 } }] }, + ]); + expect(rulesFor(`:disabled .subject { color: red; }`)).toStrictEqual([ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["a", "disabled"]] }], + }, + ]); + expect(rulesFor(`:empty .subject { color: red; }`)).toStrictEqual([ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["a", "children", "!"]] }], + }, + ]); + }); + + test("the descendant combinator compiles to what the :where() spelling of it already did", () => { + const combinator = rulesFor(`[data-state="on"] .subject { color: red; }`); + const isWhere = rulesFor( + `.subject:where([data-state="on"] *) { color: red; }`, + ); + + expect(combinator?.[0]?.cq).toStrictEqual(isWhere?.[0]?.cq); + }); + + test("each conditioned ancestor in a chain contributes its own query", () => { + expect( + rulesFor(`[data-state="on"] [aria-busy] .subject { color: red; }`), + ).toStrictEqual([ + { + s: [1, 3], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["d", "state", "=", "on"]] }, { a: [["a", "ariaBusy"]] }], + }, + ]); + }); +}); + +describe("a named ancestor is unchanged", () => { + test("its conditions still land on the query its class names", () => { + expect( + rulesFor(`.group[data-state="on"] .subject { color: red; }`), + ).toStrictEqual([ + { + s: [1, 3], + d: [RED], + v: INHERITED_COLOR, + cq: [{ a: [["d", "state", "=", "on"]], n: "g:group" }], + }, + ]); + }); + + test("a compound naming several classes still yields ONE query", () => { + expect(rulesFor(`.a.b .subject { color: red; }`)).toStrictEqual([ + { s: [1, 3], d: [RED], v: INHERITED_COLOR, cq: [{ n: "g:b.a" }] }, + ]); + }); + + test("a named and a classless ancestor each keep their own query", () => { + expect( + rulesFor(`.group [data-state="on"] .subject { color: red; }`), + ).toStrictEqual([ + { + s: [1, 3], + d: [RED], + v: INHERITED_COLOR, + cq: [{ n: "g:group" }, { a: [["d", "state", "=", "on"]] }], + }, + ]); + }); +}); + +describe("what is not an ancestor query", () => { + test("a condition on the SUBJECT stays on the rule", () => { + expect(rulesFor(`.subject[data-state="on"] { color: red; }`)).toStrictEqual( + [ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + aq: [["d", "state", "=", "on"]], + }, + ], + ); + expect(rulesFor(`.subject:hover { color: red; }`)).toStrictEqual([ + { s: [1, 2], d: [RED], v: INHERITED_COLOR, p: { h: 1 } }, + ]); + }); + + test("an unconditioned ancestor contributes nothing to compile", () => { + expect(rulesFor(`div .subject { color: red; }`)).toStrictEqual(undefined); + }); +}); + +describe("determinism", () => { + test("compiling the same stylesheet twice yields equal output", () => { + const css = ` + [data-state="on"] .subject { color: red; } + .group:hover [aria-busy] .subject { color: blue; } + `; + + expect(compile(css).stylesheet()).toStrictEqual(compile(css).stylesheet()); + }); +}); diff --git a/src/__tests__/native/ancestor-conditions.test.tsx b/src/__tests__/native/ancestor-conditions.test.tsx new file mode 100644 index 00000000..6f63a200 --- /dev/null +++ b/src/__tests__/native/ancestor-conditions.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +const RED = { color: "#f00" }; + +/** Any rule declaring a container makes its element the default container a nameless query resolves against. */ +const CONTAINER_CSS = `.container { container-type: inline-size; }`; + +test("a rule scoped to a hovered ancestor does not apply under an ancestor that is not hovered", () => { + registerCSS(` + ${CONTAINER_CSS} + :hover .subject { color: red; } + `); + + render( + + + , + ); + + expect(screen.getByTestId("subject")).not.toHaveStyle(RED); + + fireEvent(screen.getByTestId("container"), "hoverIn"); + + expect(screen.getByTestId("subject")).toHaveStyle(RED); +}); + +test("the same rule does not apply where there is no ancestor to answer it", () => { + registerCSS(`:hover .subject { color: red; }`); + + render(); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); +}); + +test("an ancestor condition is withheld from a sibling of the subject's ancestor", () => { + registerCSS(` + ${CONTAINER_CSS} + :active .subject { color: red; } + `); + + render( + <> + + + + + + + , + ); + + fireEvent(screen.getByTestId("pressed"), "pressIn"); + + expect(screen.getByTestId("under-pressed")).toHaveStyle(RED); + expect(screen.getByTestId("under-idle")).not.toHaveStyle(RED); +}); + +test("an attribute-identified ancestor is withheld where no ancestor can answer it", () => { + registerCSS(`[data-state="on"] .subject { color: red; }`); + + render( + + + , + ); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); +}); + +test("a named ancestor still answers from the element its class names", () => { + registerCSS(`.group:hover .subject { color: red; }`); + + render( + + + , + ); + + expect(screen.getByTestId("subject")).not.toHaveStyle(RED); + + fireEvent(screen.getByTestId("group"), "hoverIn"); + + expect(screen.getByTestId("subject")).toHaveStyle(RED); +}); + +test("a hovered ancestor moves every subject beneath it, and only those", () => { + registerCSS(` + ${CONTAINER_CSS} + :hover .subject { color: red; } + `); + + render( + <> + + + + + + + + , + ); + + fireEvent(screen.getByTestId("container"), "hoverIn"); + + expect(screen.getByTestId("direct")).toHaveStyle(RED); + expect(screen.getByTestId("nested")).toHaveStyle(RED); + expect(screen.getByTestId("outside")).not.toHaveStyle(RED); +}); diff --git a/src/compiler/selector-builder.ts b/src/compiler/selector-builder.ts index 88561b78..69139ea4 100644 --- a/src/compiler/selector-builder.ts +++ b/src/compiler/selector-builder.ts @@ -145,30 +145,35 @@ function parseComponents( case "pseudo-class": { switch (component.kind) { case "hover": { + attachContainerQuery(root, ref); getPseudoClassesQuery(ref).h = 1; specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } case "active": { + attachContainerQuery(root, ref); getPseudoClassesQuery(ref).a = 1; specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } case "focus": { + attachContainerQuery(root, ref); getPseudoClassesQuery(ref).f = 1; specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } case "disabled": { + attachContainerQuery(root, ref); getAttributeQuery(ref).push(["a", "disabled"]); specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; return parseComponents(rest, options, root, ref, specificity); } case "empty": { + attachContainerQuery(root, ref); getAttributeQuery(ref).push(["a", "children", "!"]); specificity[Specificity.PseudoClass] = (specificity[Specificity.PseudoClass] ?? 0) + 1; @@ -291,6 +296,7 @@ function parseComponents( attributeQuery.push(operator, component.operation.value); } } + attachContainerQuery(root, ref); getAttributeQuery(ref).push(attributeQuery); specificity[Specificity.ClassName] = (specificity[Specificity.ClassName] ?? 0) + 1; @@ -315,16 +321,7 @@ function parseComponents( component.name, ]); } else { - let containerQueries = containerQueryMap.get(root); - if (!containerQueries) { - containerQueries = []; - root.containerQuery = containerQueries; - containerQueryMap.set(root, containerQueries); - } - if (!ref.n) { - containerQueries.unshift(ref); - } - + attachContainerQuery(root, ref); ref.n = ref.n ? `${ref.n}.${component.name}` : `g:${component.name}`; } @@ -505,6 +502,34 @@ function isContainerQuery( return !("type" in value); } +/** + * An ancestor compound is a container query, whether or not it names a class. + * + * The rule's `cq` list is what the runtime evaluates, and a ref reaches it here. Only the class + * arm used to attach one, so a compound identified by a condition alone — `[data-state="on"] .x`, + * `:hover .x` — wrote that condition onto an object nothing read, and the rule applied to every + * element the class named, in every state. + */ +function attachContainerQuery( + root: PartialSelector, + ref: PartialSelector | ContainerQuery, +): void { + if (!isContainerQuery(ref)) { + return; + } + + let containerQueries = containerQueryMap.get(root); + if (!containerQueries) { + containerQueries = []; + root.containerQuery = containerQueries; + containerQueryMap.set(root, containerQueries); + } + + if (!containerQueries.includes(ref)) { + containerQueries.unshift(ref); + } +} + function getPseudoClassesQuery(key: PartialSelector | ContainerQuery) { let pseudoClassesQuery = pseudoClassesQueryMap.get(key); if (!pseudoClassesQuery) { From b6fcc0fab1c8c430f30d7442797cb54614d95776 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 14:37:44 +0300 Subject: [PATCH 2/2] test(compiler): pin that a subject compound stays the subject's and an unanswered combinator drops --- .../compiler/ancestor-conditions.test.ts | 22 +++++++++++++++++++ .../native/ancestor-conditions.test.tsx | 18 +++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/__tests__/compiler/ancestor-conditions.test.ts b/src/__tests__/compiler/ancestor-conditions.test.ts index f2c8fa6e..22c75c94 100644 --- a/src/__tests__/compiler/ancestor-conditions.test.ts +++ b/src/__tests__/compiler/ancestor-conditions.test.ts @@ -139,6 +139,28 @@ describe("what is not an ancestor query", () => { test("an unconditioned ancestor contributes nothing to compile", () => { expect(rulesFor(`div .subject { color: red; }`)).toStrictEqual(undefined); }); + + test("a compound on ONE element is the subject's, not an ancestor's", () => { + expect(rulesFor(`[data-state="on"].subject { color: red; }`)).toStrictEqual( + [ + { + s: [1, 2], + d: [RED], + v: INHERITED_COLOR, + aq: [["d", "state", "=", "on"]], + }, + ], + ); + }); + + test("a combinator the builder does not answer drops the rule rather than widening it", () => { + expect( + rulesFor(`[data-state="on"] > .subject { color: red; }`), + ).toStrictEqual(undefined); + expect(rulesFor(`:hover > .subject { color: red; }`)).toStrictEqual( + undefined, + ); + }); }); describe("determinism", () => { diff --git a/src/__tests__/native/ancestor-conditions.test.tsx b/src/__tests__/native/ancestor-conditions.test.tsx index 6f63a200..5196d7ae 100644 --- a/src/__tests__/native/ancestor-conditions.test.tsx +++ b/src/__tests__/native/ancestor-conditions.test.tsx @@ -85,6 +85,24 @@ test("a named ancestor still answers from the element its class names", () => { expect(screen.getByTestId("subject")).toHaveStyle(RED); }); +test("the ancestor itself is not a subject, even when it carries the class", () => { + registerCSS(` + ${CONTAINER_CSS} + :hover .subject { color: red; } + `); + + render( + + + , + ); + + fireEvent(screen.getByTestId("container"), "hoverIn"); + + expect(screen.getByTestId("descendant")).toHaveStyle(RED); + expect(screen.getByTestId("container")).not.toHaveStyle(RED); +}); + test("a hovered ancestor moves every subject beneath it, and only those", () => { registerCSS(` ${CONTAINER_CSS}