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(() => {