From a0ed0717ce63c7bd0d5244a8f08ce44f7bd630b6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 7 Sep 2026 16:08:37 +0300 Subject: [PATCH 1/5] feat(native): publish a container's props for its descendants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ancestor attribute selector — `group-data-[state=open]:*`, `group-disabled:*` — compiles to a container query whose attribute condition asks about the CONTAINER's props. `ContainerContextValue` is `Record`, so the evaluator has identity and nothing else to read, and a prop change on the ancestor has no signal to invalidate a descendant with. `containerAttributesFamily` is that channel, and it is the shape `containerLayoutFamily` already uses one export above: a weakFamily observable keyed on the same identity, written by the container's own component and read through the DESCENDANT's getter — which is what subscribes the descendant. The effect carries no dependency array, deliberately. The props a descendant queries are not the ones this component's own render guards track: a child writing `group-data-[disabled=true]:*` reads a key the container may render nothing from, so keying the effect on anything the container knows about would miss exactly the changes the channel exists to deliver. The observable's equality is what makes an unchanged republish free. That equality is narrowed to what a selector can see. Selectors L4 6.1 compares two strings, so a value that cannot be one is answerable only for presence: `children`, `style` and every handler are fresh objects on each render and indistinguishable to a selector, so they compare as presence. `dataSet` compares one level deeper, because it too is a fresh literal each render and comparing it by identity would defeat the guard for precisely the selectors this serves. --- src/native/react/useNativeCss.ts | 19 +++++++ src/native/reactivity.ts | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) 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; From 65194ef6dcf23803d88bde1a27f16efc19dcd560 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 7 Sep 2026 16:08:55 +0300 Subject: [PATCH 2/5] fix(native): let an attribute test run without a render guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. The caller that reads an ancestor's props subscribes to the container's props observable instead, which is a signal the guard system has no way to express. Every existing caller still passes a ledger and is unaffected. --- src/native/conditions/attributes.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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; From 48d715649be6949aa4b1fcca11377276eddd8698 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 7 Sep 2026 16:09:03 +0300 Subject: [PATCH 3/5] fix(native): evaluate a container query's attribute condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check was commented out because `container.props` never existed — the container context carries identity alone. With the props published on their own channel it can run, so `group-data-[state=open]:*` and `group-disabled:*` answer from the container rather than falling through as satisfied. Falling through is the worse of the two wrong answers: an unchecked condition applies the rule to EVERY descendant of the container rather than to none. The read goes through `get`, so this element's rule effect subscribes to the container's props and re-evaluates when the ancestor changes. No render guard is recorded, for the reason the previous commit describes. --- src/native/conditions/container-query.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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; From 5823bc97d1fa4dc33c8e3044869a0e728e6117f1 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 7 Sep 2026 16:09:22 +0300 Subject: [PATCH 4/5] test(native): pin ancestor attribute conditions in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven cases over the group form: applied under a container that carries the value, withheld under one that carries a different value, withheld under one that carries none, withheld when the value sits on the element instead of the container, re-evaluated when the container changes and when it changes back, and the presence form both ways. Three more pin what the runtime does at the edges, so a change of mind about any of them is a deliberate edit here. Only the NEAREST same-named group is consulted, which is correct for a real `@container` — CSS Containment names the query container as the nearest eligible ancestor — and a divergence for the group form, which is a descendant combinator wearing a container query: CSS matches via ANY ancestor. Carrying every same-named ancestor means an array in the container context, and a fresh array identity per render defeats the `["c", name, container]` render guard, so it is a context-shape decision rather than a local one. An element is also not its own group ancestor, which agrees with CSS and is the half most easily broken by answering an ancestor condition from the element's own props. The negatives assert `not.toHaveStyle({ … })` rather than `toHaveStyle(undefined)`. The latter passes whatever the element's style is, so the first drafts of these cases were green with the defect reintroduced. --- .../native/container-queries.test.tsx | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) 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" }); +}); From 0dab1f059ab1daa52993c479a6ac338947f90cc5 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 7 Sep 2026 16:09:28 +0300 Subject: [PATCH 5/5] test(native): pin what a container republish does and does not notify A container publishes 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. Five cases: a republish that changed nothing a selector can see does not notify; a `dataSet` value change, a key added or removed, and a prop appearing or disappearing all do; and the pre-publish reading is `undefined`, which is what makes a descendant's first frame answer false rather than true. --- .../native/container-attributes.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/__tests__/native/container-attributes.test.ts 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); +});