From ba1b8a0d24e8d0d5f0f2334d2147295d2880411a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 05:06:20 +0300 Subject: [PATCH] fix(native): answer an ancestor chain as a path, not as a set of names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container is registered by name, and every compound of an ancestor chain was resolved in the element's own scope. So `.outer .inner .x` asked whether both names sit somewhere above the element, and it matched the reversed nesting too. A descendant combinator asks for an `.inner` ancestor that itself has an `.outer` ancestor (Selectors 4 §16.1). Each registration now carries the scope it registered in, beside its element and props. The chain is walked innermost first, each compound resolving in the scope of the one inside it. A single compound resolves as before. Guards stay on the element's own scope, the one it can re-read. --- src/__tests__/native/grouping.test.tsx | 171 +++++++++++++++++++++++ src/native/conditions/container-query.ts | 39 +++--- src/native/conditions/guards.ts | 3 +- src/native/react/rules.ts | 17 +-- src/native/react/useNativeCss.ts | 9 +- src/native/reactivity.ts | 15 +- 6 files changed, 219 insertions(+), 35 deletions(-) diff --git a/src/__tests__/native/grouping.test.tsx b/src/__tests__/native/grouping.test.tsx index 1484e4b8..6d9a805d 100644 --- a/src/__tests__/native/grouping.test.tsx +++ b/src/__tests__/native/grouping.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react-native"; +import { compile } from "react-native-css/compiler"; import { View } from "react-native-css/components/View"; import { registerCSS } from "react-native-css/jest"; @@ -108,3 +109,173 @@ test("group selector", () => { expect(child.props.style).toStrictEqual(undefined); }); + +const RED = { color: "#f00" }; +const CHAIN_CSS = `.outer .inner .subject { color: red; }`; + +test("a chain compiles outermost first, each ancestor class registering a container", () => { + const stylesheet = compile(CHAIN_CSS).stylesheet(); + const rule = (name: string) => + stylesheet.s?.find(([className]) => className === name)?.[1][0]; + + expect(rule("subject")?.cq).toStrictEqual([ + { n: "g:outer" }, + { n: "g:inner" }, + ]); + expect(rule("outer")?.c).toStrictEqual(["g:outer"]); + expect(rule("inner")?.c).toStrictEqual(["g:inner"]); +}); + +test("a chain applies when its ancestors nest in the order it names, with anything between", () => { + registerCSS(CHAIN_CSS); + + render( + <> + + + + + + + + + + + + + + + , + ); + + expect(screen.getByTestId("adjacent")).toHaveStyle(RED); + expect(screen.getByTestId("intervening")).toHaveStyle(RED); +}); + +test("a chain does not apply to its ancestors nested the other way round, side by side, or on one element", () => { + registerCSS(CHAIN_CSS); + + render( + <> + + + + + + + + + + + + + , + ); + + expect(screen.getByTestId("reversed").props.style).toStrictEqual(undefined); + expect(screen.getByTestId("siblings").props.style).toStrictEqual(undefined); + expect(screen.getByTestId("one-element").props.style).toStrictEqual( + undefined, + ); +}); + +test("a three-deep chain answers its order the same way", () => { + registerCSS(`.a .b .c .subject { color: red; }`); + + render( + <> + + + + + + + + + + + + + + + , + ); + + expect(screen.getByTestId("ordered")).toHaveStyle(RED); + expect(screen.getByTestId("swapped").props.style).toStrictEqual(undefined); +}); + +test("each compound's interaction reads its own container", () => { + registerCSS(` + .outer .inner:hover .inner-hover { color: red; } + .outer:hover .inner .outer-hover { color: red; } + `); + + render( + + + + + + , + ); + + fireEvent(screen.getByTestId("inner"), "hoverIn"); + expect(screen.getByTestId("inner-hover")).toHaveStyle(RED); + expect(screen.getByTestId("outer-hover").props.style).toStrictEqual( + undefined, + ); + + fireEvent(screen.getByTestId("inner"), "hoverOut"); + fireEvent(screen.getByTestId("outer"), "hoverIn"); + expect(screen.getByTestId("inner-hover").props.style).toStrictEqual( + undefined, + ); + expect(screen.getByTestId("outer-hover")).toHaveStyle(RED); +}); + +test("an outer container's attribute is read from that container, and re-derives when it changes", () => { + registerCSS(`.outer[data-state="on"] .inner .subject { color: red; }`); + + const tree = (state: string) => ( + <> + + + + + + + + + + + + ); + + render(tree("on")); + expect(screen.getByTestId("ordered")).toHaveStyle(RED); + expect(screen.getByTestId("reversed").props.style).toStrictEqual(undefined); + + screen.rerender(tree("off")); + expect(screen.getByTestId("ordered").props.style).toStrictEqual(undefined); +}); + +test("a chain renders the same styles twice over", () => { + registerCSS(CHAIN_CSS); + + const tree = ( + + + + + + ); + + render(tree); + const first = screen.getByTestId("subject").props.style; + + screen.rerender(tree); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(first); + expect(screen.getByTestId("subject")).toHaveStyle(RED); +}); diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index e25df564..0e9693c2 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -14,6 +14,7 @@ import { focusFamily, hoverFamily, type ContainerContextValue, + type ContainerRegistration, type Getter, } from "../reactivity"; import { testAttributes } from "./attributes"; @@ -21,34 +22,40 @@ import type { RenderGuard } from "./guards"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; +// The ancestor compounds arrive outermost first; each resolves in the scope of the one inside it (Selectors 4 §16.1) export function testContainerQueries( queries: ContainerQuery[], inheritedContainers: ContainerContextValue, guards: RenderGuard[], get: Getter, ) { - return queries.every((query) => { - return testContainerQuery(query, inheritedContainers, guards, get); - }); + // Every container the walk reaches registered above this element, so this element's scope observes a change to any of them + for (const query of queries) { + const name = query.n ?? DEFAULT_CONTAINER_NAME; + guards.push(["c", name, inheritedContainers[name]]); + } + + let scope = inheritedContainers; + + for (const query of [...queries].reverse()) { + const container = scope[query.n ?? DEFAULT_CONTAINER_NAME]; + + if (!container || !testContainerQuery(query, container, get)) { + return false; + } + + scope = container.scope; + } + + return true; } export function testContainerQuery( query: ContainerQuery, - inheritedContainers: ContainerContextValue, - guards: RenderGuard[], + container: ContainerRegistration, get: Getter, ): boolean { - const name = query.n ?? DEFAULT_CONTAINER_NAME; - const container = inheritedContainers[name]!; - - guards.push(["c", name, container]); - - if (!container) { - return false; - } - - // Ancestor snapshots have their own context guard. Their attributes must not - // be compared with this descendant's props by the ordinary attribute guards. + // The registration's own guard covers a container's props, so this element's attribute guards must not record them if (query.a && !testAttributes(query.a, container.props, [])) { return false; } diff --git a/src/native/conditions/guards.ts b/src/native/conditions/guards.ts index d3c9dd84..5218eed2 100644 --- a/src/native/conditions/guards.ts +++ b/src/native/conditions/guards.ts @@ -2,6 +2,7 @@ import type { ComponentState } from "../react/useNativeCss"; import type { ContainerContextValue, + ContainerRegistration, VariableContextValue, } from "../reactivity"; @@ -9,7 +10,7 @@ export type RenderGuard = | ["a", string, any] | ["d", string, any] | ["v", string, any] - | ["c", string, WeakKey]; + | ["c", string, ContainerRegistration | undefined]; export function testGuards( state: ComponentState, diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index e0fe8d1a..f811aecc 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -15,6 +15,7 @@ import { VAR_SYMBOL, weakFamily, type ContainerContextValue, + type ContainerRegistration, type VariableContextValue, } from "../reactivity"; import { stylesFamily } from "../styles"; @@ -141,24 +142,24 @@ export function updateRules( } if (rule.c) { + const registration: ContainerRegistration = { + key: state.ruleEffectGetter, + props: currentProps, + scope: inheritedContainers, + }; + // We're going to set a value, so we need to create a new object if (containers === inheritedContainers) { containers = { ...inheritedContainers, // This container becomes the default container - [DEFAULT_CONTAINER_NAME]: { - key: state.ruleEffectGetter, - props: currentProps, - }, + [DEFAULT_CONTAINER_NAME]: registration, }; } // This this component as the named container for (const name of rule.c) { - containers![name] = { - key: state.ruleEffectGetter, - props: currentProps, - }; + containers![name] = registration; } // Enable hover/active/focus/layout handlers diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts index f38c77c2..1cb585b9 100644 --- a/src/native/react/useNativeCss.ts +++ b/src/native/react/useNativeCss.ts @@ -170,13 +170,16 @@ export function useNativeCss( } if (state.containers) { - // Publish a new props snapshot even when this component's own rules did not - // change. Descendants can depend on an ancestor attribute through a group. + // Republished every render, even when this component's own rules did not change: a descendant can ask after its props or the containers above it const containers = Object.fromEntries( Object.entries(state.containers).map(([name, container]) => [ name, container.key === state.ruleEffectGetter - ? { key: container.key, props: originalProps } + ? { + key: container.key, + props: originalProps, + scope: inheritedContainers, + } : (inheritedContainers[name] ?? container), ]), ); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..a695c084 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -235,13 +235,14 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); /** Containers ****************************************************************/ -export type ContainerContextValue = Record< - string, - { - key: WeakKey; - props: Record | null | undefined; - } ->; +export interface ContainerRegistration { + readonly key: WeakKey; + readonly props: Record | null | undefined; + /** The containers above this one, so `.a .b .x` can ask for an `.a` above the `.b` (Selectors 4 §16.1) */ + readonly scope: ContainerContextValue; +} + +export type ContainerContextValue = Record; export const ContainerContext = createContext({}); export const containerLayoutFamily = weakFamily(() => {