From 88f88cd96ed77955c977977621827a1e848d9955 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 22 Sep 2026 15:26:52 +0300 Subject: [PATCH] fix(native): answer an ancestor chain as a path, not as a set of names `ContainerContextValue` mapped a container name to the nearest element that registered it, and `testContainerQueries` resolved every compound of a selector in the element's own scope. So `.outer .inner .x` asked whether both names were somewhere above rather than whether the `.inner` ancestor itself had an `.outer` ancestor, and the rule matched under reversed nesting. A registration now carries the scope it was made in, and the walk goes innermost first, resolving each compound in the scope of the one inside it. A single-compound selector resolves exactly as before. Guards stay keyed on the element's own scope: it is the only one this element can re-read, and every name the walk reaches is inherited by it, so a change to any of them is observable there. Recording a guard against the scope a link was resolved in instead never agrees on re-check and re-renders the tree until React refuses. --- .../native/ancestor-chain-order.test.tsx | 221 ++++++++++++++++++ src/native/conditions/container-query.ts | 49 ++-- src/native/conditions/guards.ts | 7 +- src/native/react/rules.ts | 10 +- src/native/reactivity.ts | 14 +- 5 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 src/__tests__/native/ancestor-chain-order.test.tsx diff --git a/src/__tests__/native/ancestor-chain-order.test.tsx b/src/__tests__/native/ancestor-chain-order.test.tsx new file mode 100644 index 00000000..80af884c --- /dev/null +++ b/src/__tests__/native/ancestor-chain-order.test.tsx @@ -0,0 +1,221 @@ +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"; + +const RED = { color: "#f00" }; + +const CHAIN_CSS = `.outer .inner .subject { color: red; }`; + +describe("the compiled shape the runtime walks", () => { + test("ancestor compounds are emitted OUTERMOST first", () => { + const rules = compile(CHAIN_CSS) + .stylesheet() + .s?.find(([name]) => name === "subject")?.[1]; + + expect(rules?.[0]?.cq).toStrictEqual([{ n: "g:outer" }, { n: "g:inner" }]); + }); + + test("each ancestor class registers itself as a container", () => { + const stylesheet = compile(CHAIN_CSS).stylesheet(); + + expect( + stylesheet.s?.find(([name]) => name === "outer")?.[1]?.[0]?.c, + ).toStrictEqual(["g:outer"]); + expect( + stylesheet.s?.find(([name]) => name === "inner")?.[1]?.[0]?.c, + ).toStrictEqual(["g:inner"]); + }); +}); + +describe("a chain answers the nesting, not the set", () => { + test("it applies when the ancestors nest in the order the selector names", () => { + registerCSS(CHAIN_CSS); + + render( + + + + + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle(RED); + }); + + test("it does NOT apply when the same two ancestors nest the other way round", () => { + registerCSS(CHAIN_CSS); + + render( + + + + + , + ); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); + }); + + test("it does not apply when neither ancestor contains the other", () => { + registerCSS(CHAIN_CSS); + + render( + <> + + + + + , + ); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); + }); + + test("intervening elements do not break the chain", () => { + registerCSS(CHAIN_CSS); + + render( + + + + + + + + + , + ); + + expect(screen.getByTestId("subject")).toHaveStyle(RED); + }); + + test("a three-deep chain answers 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("one ancestor carrying both class names satisfies neither position twice", () => { + registerCSS(CHAIN_CSS); + + render( + + + , + ); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); + }); +}); + +describe("what the chain walk must not move", () => { + test("a single ancestor is unchanged", () => { + registerCSS(`.group .subject { color: red; }`); + + render( + <> + + + + + , + ); + + expect(screen.getByTestId("under")).toHaveStyle(RED); + expect(screen.getByTestId("outside").props.style).toStrictEqual(undefined); + }); + + test("an interaction condition still reads the container it is written on", () => { + registerCSS(`.outer .inner:hover .subject { color: red; }`); + + render( + + + + + , + ); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); + + fireEvent(screen.getByTestId("inner"), "hoverIn"); + + expect(screen.getByTestId("subject")).toHaveStyle(RED); + }); + + test("an interaction condition on the OUTER compound reads the outer container", () => { + registerCSS(`.outer:hover .inner .subject { color: red; }`); + + render( + + + + + , + ); + + fireEvent(screen.getByTestId("inner"), "hoverIn"); + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); + + fireEvent(screen.getByTestId("outer"), "hoverIn"); + expect(screen.getByTestId("subject")).toHaveStyle(RED); + }); + + test("a chain re-derives when an ancestor's condition changes", () => { + registerCSS(`.outer .inner:hover .subject { color: red; }`); + + render( + + + + + , + ); + + fireEvent(screen.getByTestId("inner"), "hoverIn"); + expect(screen.getByTestId("subject")).toHaveStyle(RED); + + fireEvent(screen.getByTestId("inner"), "hoverOut"); + expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined); + }); + + test("rendering the same tree twice yields the same styles", () => { + registerCSS(CHAIN_CSS); + + const tree = ( + + + + + + ); + + const { rerender } = render(tree); + const first = screen.getByTestId("subject").props.style; + + rerender(tree); + + expect(screen.getByTestId("subject").props.style).toStrictEqual(first); + }); +}); diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts index ac546c9a..9b93dea4 100644 --- a/src/native/conditions/container-query.ts +++ b/src/native/conditions/container-query.ts @@ -21,41 +21,58 @@ import type { RenderGuard } from "./guards"; export const DEFAULT_CONTAINER_NAME = "c:___default___"; +/** + * The ancestor compounds of one selector, outermost first, walked INNERMOST first. + * + * Each compound is resolved in the scope of the one inside it, so `.a .b .x` asks for a `.b` + * ancestor that itself has an `.a` ancestor — Selectors 4 §16.1. Resolving every compound in the + * element's own scope asks only "are both names somewhere above", which is also true when the + * nesting is reversed. + */ export function testContainerQueries( queries: ContainerQuery[], inheritedContainers: ContainerContextValue, guards: RenderGuard[], get: Getter, ) { - return queries.every((query) => { - return testContainerQuery(query, inheritedContainers, guards, get); - }); + // Every name the walk can reach is also inherited by this element, so the element's own scope is + // where a change to any of them is observable — and it is the only scope this element can re-read. + // Keyed on the element rather than on the registration, which is minted per update. + for (const query of queries) { + const name = query.n ?? DEFAULT_CONTAINER_NAME; + guards.push(["c", name, inheritedContainers[name]?.key]); + } + + let scope = inheritedContainers; + + for (let index = queries.length - 1; index >= 0; index--) { + const query = queries[index]!; + const registration = scope[query.n ?? DEFAULT_CONTAINER_NAME]; + + if (!registration || !testContainerQuery(query, registration.key, get)) { + return false; + } + + scope = registration.scope; + } + + return true; } export function testContainerQuery( query: ContainerQuery, - inheritedContainers: ContainerContextValue, - guards: RenderGuard[], + containerKey: WeakKey, get: Getter, ): boolean { - const name = query.n ?? DEFAULT_CONTAINER_NAME; - const container = inheritedContainers[name]!; - - guards.push(["c", name, container]); - - if (!container) { - return false; - } - // if (query.a && !testAttributes(query.a, container.props, guards)) { // return false; // } - if (query.m && !testContainerMediaCondition(query.m, container, get)) { + if (query.m && !testContainerMediaCondition(query.m, containerKey, get)) { return false; } - if (query.p && !testContainerPseudoCondition(query.p, container, get)) { + if (query.p && !testContainerPseudoCondition(query.p, containerKey, get)) { return false; } diff --git a/src/native/conditions/guards.ts b/src/native/conditions/guards.ts index d3c9dd84..2379da11 100644 --- a/src/native/conditions/guards.ts +++ b/src/native/conditions/guards.ts @@ -9,7 +9,8 @@ export type RenderGuard = | ["a", string, any] | ["d", string, any] | ["v", string, any] - | ["c", string, WeakKey]; + // `undefined` is the absence of a container, which a later render can supply. + | ["c", string, WeakKey | undefined]; export function testGuards( state: ComponentState, @@ -34,8 +35,8 @@ export function testGuards( result = inheritedVariables[guard[1]] !== guard[2]; break; case "c": - // Containers - result = inheritedContainers[guard[1]] !== guard[2]; + // The element, never the registration, which is minted per update. + result = inheritedContainers[guard[1]]?.key !== guard[2]; break; } diff --git a/src/native/react/rules.ts b/src/native/react/rules.ts index f85a66f9..a8059fb7 100644 --- a/src/native/react/rules.ts +++ b/src/native/react/rules.ts @@ -139,18 +139,24 @@ export function updateRules( } if (rule.c) { + // The scope this element registers IN — what a chained ancestor query walks out through. + const registration = { + key: state.ruleEffectGetter, + 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]: state.ruleEffectGetter, + [DEFAULT_CONTAINER_NAME]: registration, }; } // This this component as the named container for (const name of rule.c) { - containers![name] = state.ruleEffectGetter; + containers![name] = registration; } // Enable hover/active/focus/layout handlers diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 0824edeb..3c91e094 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -223,7 +223,19 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); /** Containers ****************************************************************/ -export type ContainerContextValue = Record; +/** + * A registered container, and the scope it was registered IN. + * + * The scope is what makes an ancestor CHAIN answerable. `.a .b .x` asks for a `.b` ancestor that + * itself has an `.a` ancestor (Selectors 4 §16.1); a name-to-element map answers only "both names + * are somewhere above", which is also true when the nesting is reversed. + */ +export interface ContainerRegistration { + readonly key: WeakKey; + readonly scope: ContainerContextValue; +} + +export type ContainerContextValue = Record; export const ContainerContext = createContext({}); export const containerLayoutFamily = weakFamily(() => {