diff --git a/src/__tests__/native/container-attributes.test.ts b/src/__tests__/native/container-attributes.test.ts
new file mode 100644
index 00000000..bb45a5e4
--- /dev/null
+++ b/src/__tests__/native/container-attributes.test.ts
@@ -0,0 +1,84 @@
+import { containerAttributesFamily } from "../../native/reactivity";
+import type { Effect } from "../../native/reactivity";
+
+/**
+ * A container publishes its props after every commit of its own component, so the observable's
+ * equality is what stands between an ancestor re-rendering and every descendant that reads it
+ * re-evaluating its rules.
+ *
+ * The values a selector can compare are strings; everything else it can only ask about for
+ * presence. `children`, `style` and every handler are fresh objects on each render and are
+ * indistinguishable to a selector, so a change in their identity must not notify — while a change
+ * in a `dataSet` key, which is also a fresh object every render, must.
+ */
+const container = { label: "container" };
+
+const countingEffect = (): { effect: Effect; runs: () => number } => {
+ let runs = 0;
+ const effect: Effect = { observers: new Set(), run: () => void (runs += 1) };
+ return { effect, runs: () => runs };
+};
+
+test("a republish that changed nothing a selector can see does not notify", () => {
+ const { effect, runs } = countingEffect();
+ const observable = containerAttributesFamily(container);
+ observable.get(effect);
+
+ const dataSet = { open: true };
+ observable.set({ dataSet, style: { flex: 1 }, children: {} });
+ expect(runs()).toBe(1);
+
+ // A re-render: same values, every object freshly allocated.
+ observable.set({ dataSet: { open: true }, style: { flex: 1 }, children: {} });
+ expect(runs()).toBe(1);
+});
+
+test("a change to a dataSet value notifies", () => {
+ const { effect, runs } = countingEffect();
+ const observable = containerAttributesFamily({ label: "value" });
+ observable.get(effect);
+
+ observable.set({ dataSet: { open: true } });
+ expect(runs()).toBe(1);
+
+ observable.set({ dataSet: { open: false } });
+ expect(runs()).toBe(2);
+});
+
+test("adding or removing a dataSet key notifies", () => {
+ const { effect, runs } = countingEffect();
+ const observable = containerAttributesFamily({ label: "keys" });
+ observable.get(effect);
+
+ observable.set({ dataSet: { open: true } });
+ expect(runs()).toBe(1);
+
+ observable.set({ dataSet: { open: true, state: "x" } });
+ expect(runs()).toBe(2);
+
+ observable.set({ dataSet: { open: true } });
+ expect(runs()).toBe(3);
+});
+
+test("a prop appearing or disappearing notifies, because presence is answerable", () => {
+ const { effect, runs } = countingEffect();
+ const observable = containerAttributesFamily({ label: "presence" });
+ observable.get(effect);
+
+ observable.set({ disabled: true });
+ expect(runs()).toBe(1);
+
+ observable.set({});
+ expect(runs()).toBe(2);
+});
+
+test("the first publish from undefined notifies", () => {
+ const { effect, runs } = countingEffect();
+ const observable = containerAttributesFamily({ label: "first" });
+
+ // The pre-publish reading: a descendant that renders before its ancestor's effect has run.
+ expect(observable.get(effect)).toBeUndefined();
+
+ observable.set({ dataSet: { open: true } });
+ expect(runs()).toBe(1);
+});
diff --git a/src/__tests__/native/container-queries.test.tsx b/src/__tests__/native/container-queries.test.tsx
index 3ea60394..e4d069e8 100644
--- a/src/__tests__/native/container-queries.test.tsx
+++ b/src/__tests__/native/container-queries.test.tsx
@@ -113,3 +113,214 @@ test("container query width", () => {
color: "#00f",
});
});
+
+/**
+ * An ancestor attribute selector — what Tailwind writes as `group-data-[state=open]:*` and
+ * `group-disabled:*` — compiles to a container query carrying an attribute condition, and that
+ * condition asks about the CONTAINER's props rather than the element's own. These pin that it is
+ * ANSWERED, in both directions: applied when the container matches, withheld when it does not.
+ *
+ * The selector is spelled the way Tailwind emits an ancestor variant — `:is(:where(.group) *)` —
+ * because a bare descendant combinator compiles to nothing. `.group` needs no `container-type`:
+ * the compiler registers `g:group` from the selector itself, and declaring one would register
+ * the DEFAULT container instead, under a name the query never asks for.
+ */
+const ANCESTOR_ATTRIBUTE_CSS = `
+ .subject:is(:where(.group)[data-state="open"] *) {
+ color: blue;
+ }
+ `;
+
+test("an ancestor attribute condition is withheld when the container does not match", () => {
+ registerCSS(ANCESTOR_ATTRIBUTE_CSS);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
+});
+
+test("an ancestor attribute condition applies when the container matches", () => {
+ registerCSS(ANCESTOR_ATTRIBUTE_CSS);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).toHaveStyle({ color: "#00f" });
+});
+
+test("a container carrying no value at all does not satisfy the condition", () => {
+ registerCSS(ANCESTOR_ATTRIBUTE_CSS);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
+});
+
+test("the condition reads the CONTAINER, not the element that carries the class", () => {
+ registerCSS(ANCESTOR_ATTRIBUTE_CSS);
+
+ // The value sits on the child. An ancestor selector must not answer from it, or every element
+ // would satisfy its own group condition.
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
+});
+
+test("a change on the container re-evaluates the descendant", () => {
+ registerCSS(ANCESTOR_ATTRIBUTE_CSS);
+
+ const closed = (
+
+
+
+ );
+ const open = (
+
+
+
+ );
+
+ const { rerender } = render(closed);
+ expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
+
+ rerender(open);
+ expect(screen.getByTestId(childID)).toHaveStyle({ color: "#00f" });
+
+ // And back — a condition that latches on is a different defect with the same first half.
+ rerender(closed);
+ expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
+});
+
+test("an ancestor presence condition reads the container's own value", () => {
+ registerCSS(`
+ .subject:is(:where(.group)[data-open] *) {
+ color: red;
+ }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).toHaveStyle({ color: "#f00" });
+});
+
+test("an ancestor presence condition is withheld when the container lacks the value", () => {
+ registerCSS(`
+ .subject:is(:where(.group)[data-open] *) {
+ color: red;
+ }
+ `);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#f00" });
+});
+
+/**
+ * A KNOWN LIMIT, pinned rather than left silent.
+ *
+ * CSS matches `:is(:where(.group)[data-state="open"] *)` against ANY ancestor carrying the class
+ * and the value. A container context holds one entry per container name, so the nearest ancestor
+ * of that name is the only one consulted, and an outer match behind a non-matching inner one is
+ * missed.
+ *
+ * That is correct for a real `@container` — CSS Containment names the query container as the
+ * NEAREST eligible ancestor — and it is a divergence for the group form, which is a descendant
+ * combinator wearing a container query. Carrying every same-named ancestor would mean an array in
+ * the container context, and a fresh array identity on each render defeats the `["c", name, …]`
+ * render guard, which compares by identity. So it is a context-shape decision rather than a local
+ * one.
+ *
+ * These pin what the runtime does today, so a change of mind about it is a deliberate edit here.
+ */
+test("only the nearest same-named group is consulted", () => {
+ registerCSS(ANCESTOR_ATTRIBUTE_CSS);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ // CSS would match here, via the outer group.
+ expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
+});
+
+test("the nearest same-named group answers even when an outer one does not", () => {
+ registerCSS(ANCESTOR_ATTRIBUTE_CSS);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).toHaveStyle({ color: "#00f" });
+});
+
+test("an element is not its own group ancestor", () => {
+ registerCSS(ANCESTOR_ATTRIBUTE_CSS);
+
+ // `:is(:where(.group) *)` selects a DESCENDANT, so an element carrying both classes must not
+ // satisfy its own group condition. This agrees with CSS and is the half most easily broken by
+ // answering an ancestor condition from the element's own props.
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
+});
diff --git a/src/native/conditions/attributes.ts b/src/native/conditions/attributes.ts
index 23b72804..3ce482b2 100644
--- a/src/native/conditions/attributes.ts
+++ b/src/native/conditions/attributes.ts
@@ -2,10 +2,20 @@ import type { AttributeQuery } from "react-native-css/compiler";
import type { RenderGuard } from "./guards";
+/**
+ * `guards` is optional because the props are not always the ELEMENT's own.
+ *
+ * A render guard is checked against `currentProps` on the next render, so it can only speak for
+ * the component that owns those props. A container query's attribute condition asks about an
+ * ANCESTOR's props, and recording a guard for it would compare the ancestor's value against the
+ * descendant's own prop of that name — a mismatch on every render for any element that does not
+ * happen to carry the same attribute. That caller subscribes to the container's props observable
+ * instead, which is a signal the guard system has no way to express.
+ */
export function testAttributes(
queries: AttributeQuery[],
props: Record | undefined | null,
- guards: RenderGuard[],
+ guards?: RenderGuard[],
) {
return queries.every((query) => testAttribute(query, props, guards));
}
@@ -13,7 +23,7 @@ export function testAttributes(
function testAttribute(
[type, prop, operator, testValue]: AttributeQuery,
props: Record | undefined | null,
- guards: RenderGuard[],
+ guards?: RenderGuard[],
) {
let value: unknown = undefined;
@@ -26,7 +36,7 @@ function testAttribute(
}
}
- guards.push([type, prop, value]);
+ guards?.push([type, prop, value]);
if (!operator) {
return value !== undefined && value !== null && value !== false;
diff --git a/src/native/conditions/container-query.ts b/src/native/conditions/container-query.ts
index ac546c9a..ee7b1831 100644
--- a/src/native/conditions/container-query.ts
+++ b/src/native/conditions/container-query.ts
@@ -9,6 +9,7 @@ import type {
import {
activeFamily,
+ containerAttributesFamily,
containerHeightFamily,
containerWidthFamily,
focusFamily,
@@ -16,7 +17,7 @@ import {
type ContainerContextValue,
type Getter,
} from "../reactivity";
-// import { testAttributes } from "./attributes";
+import { testAttributes } from "./attributes";
import type { RenderGuard } from "./guards";
export const DEFAULT_CONTAINER_NAME = "c:___default___";
@@ -47,9 +48,16 @@ export function testContainerQuery(
return false;
}
- // if (query.a && !testAttributes(query.a, container.props, guards)) {
- // return false;
- // }
+ // The container's props, not this element's — read through `get` so this element's rule effect
+ // subscribes to them and re-evaluates when the ancestor changes. No render guard is recorded:
+ // a guard is checked against this element's own `currentProps`, which cannot speak for another
+ // component's (see `testAttributes`).
+ if (
+ query.a &&
+ !testAttributes(query.a, get(containerAttributesFamily(container)))
+ ) {
+ return false;
+ }
if (query.m && !testContainerMediaCondition(query.m, container, get)) {
return false;
diff --git a/src/native/react/useNativeCss.ts b/src/native/react/useNativeCss.ts
index 11d3ede8..d2dae024 100644
--- a/src/native/react/useNativeCss.ts
+++ b/src/native/react/useNativeCss.ts
@@ -15,6 +15,7 @@ import type { StyledConfiguration } from "../../runtime.types";
import { testGuards, type RenderGuard } from "../conditions/guards";
import {
cleanupEffect,
+ containerAttributesFamily,
ContainerContext,
type ContainerContextValue,
type Effect,
@@ -115,6 +116,24 @@ export function useNativeCss(
// Both effects share the same observers, so we only need to cleanup one of them
useEffect(() => () => cleanupEffect(state.ruleEffect), [state.ruleEffect]);
+ /**
+ * Publish this component's props for any descendant whose rule asks about them.
+ *
+ * After the commit rather than during the render, because a write here notifies descendants
+ * and a render must not. NO dependency array, deliberately: the props a descendant queries are
+ * not the ones this component's own render guards track — `group-data-[disabled=true]:*` on a
+ * child reads a key this component may render nothing from — so keying the effect on anything
+ * this component knows about would miss exactly the changes the channel exists to deliver.
+ * The observable's own equality is what makes an unchanged republish free.
+ */
+ useEffect(() => {
+ if (state.containers) {
+ containerAttributesFamily(state.ruleEffectGetter).set(
+ originalProps ?? undefined,
+ );
+ }
+ });
+
// Check if our derived state has changed (e.g the className prop)
if (
testGuards(state, originalProps, inheritedVariables, inheritedContainers)
diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts
index 0824edeb..e48c6af4 100644
--- a/src/native/reactivity.ts
+++ b/src/native/reactivity.ts
@@ -235,6 +235,96 @@ export const containerLayoutFamily = weakFamily(() => {
});
});
+/**
+ * Whether two records answer the same for every own key, compared one level deep.
+ */
+function shallowEqual(
+ a: Record,
+ b: Record,
+): boolean {
+ const keys = Object.keys(a);
+ if (keys.length !== Object.keys(b).length) {
+ return false;
+ }
+ return keys.every((key) => Object.is(a[key], b[key]));
+}
+
+/**
+ * A prop as an attribute selector can SEE it.
+ *
+ * Selectors L4 6.1 compares two strings, so a value that cannot be one is only ever answerable
+ * for presence. Projecting every object to a single marker is what keeps the comparison below
+ * from reporting a change for `children`, `style` and every handler — all of them fresh objects
+ * on each render of the container, none of them distinguishable to a selector.
+ */
+function attributeVisible(value: unknown): unknown {
+ return typeof value === "object" && value !== null ? PRESENT : value;
+}
+
+const PRESENT = Symbol.for("react-native-css.attribute-present");
+
+/**
+ * Whether two prop snapshots answer every attribute query identically.
+ *
+ * A container publishes on every render of its own component, so this is what keeps a render that
+ * changed nothing from notifying every descendant that reads it. Two keys are special:
+ *
+ * - `dataSet` is compared one level deeper, because it is written as an object literal at the JSX
+ * site — `dataSet={{ open }}` is a fresh object every render — and comparing it by identity
+ * would defeat the guard for exactly the selectors this channel exists to serve.
+ * - every other object is compared as PRESENCE, per `attributeVisible` above.
+ */
+function attributesEqual(
+ a: Record | undefined,
+ b: Record | undefined,
+): boolean {
+ if (Object.is(a, b)) {
+ return true;
+ }
+ if (!a || !b) {
+ return false;
+ }
+ const keys = Object.keys(a);
+ if (keys.length !== Object.keys(b).length) {
+ return false;
+ }
+ return keys.every((key) => {
+ if (key !== "dataSet") {
+ return Object.is(attributeVisible(a[key]), attributeVisible(b[key]));
+ }
+ const left = a[key];
+ const right = b[key];
+ return isRecord(left) && isRecord(right)
+ ? shallowEqual(left, right)
+ : Object.is(left, right);
+ });
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+/**
+ * The props of the component that registered a container, published for its DESCENDANTS.
+ *
+ * An ancestor attribute selector — `group-disabled:`, `group-data-[state=open]:` — compiles to a
+ * container query whose attribute condition asks about the CONTAINER's props rather than the
+ * element's own. `ContainerContextValue` carries identity alone, so the evaluator has nothing to
+ * read; this is the channel that carries the answer.
+ *
+ * Reading it through the DESCENDANT's getter is also the invalidation signal: the descendant's
+ * rule effect subscribes here, so a prop change on the ancestor re-evaluates the descendant's
+ * rules. `containerLayoutFamily` above is the same shape for the same reason — a fact owned by
+ * the container that a descendant's rules depend on, which no render guard of the descendant's
+ * own could observe.
+ */
+export const containerAttributesFamily = weakFamily(() => {
+ return observable | undefined>(
+ undefined,
+ attributesEqual,
+ );
+});
+
export const containerWidthFamily = weakFamily((key) => {
return observable((read) => {
return read(containerLayoutFamily(key))?.width || 0;