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;