Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/__tests__/native/container-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
211 changes: 211 additions & 0 deletions src/__tests__/native/container-queries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<View
testID={parentID}
className="group"
{...{ dataSet: { state: "closed" } }}
>
<View testID={childID} className="subject" />
</View>,
);

expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
});

test("an ancestor attribute condition applies when the container matches", () => {
registerCSS(ANCESTOR_ATTRIBUTE_CSS);

render(
<View
testID={parentID}
className="group"
{...{ dataSet: { state: "open" } }}
>
<View testID={childID} className="subject" />
</View>,
);

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(
<View testID={parentID} className="group">
<View testID={childID} className="subject" />
</View>,
);

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(
<View testID={parentID} className="group">
<View
testID={childID}
className="subject"
{...{ dataSet: { state: "open" } }}
/>
</View>,
);

expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
});

test("a change on the container re-evaluates the descendant", () => {
registerCSS(ANCESTOR_ATTRIBUTE_CSS);

const closed = (
<View
testID={parentID}
className="group"
{...{ dataSet: { state: "closed" } }}
>
<View testID={childID} className="subject" />
</View>
);
const open = (
<View
testID={parentID}
className="group"
{...{ dataSet: { state: "open" } }}
>
<View testID={childID} className="subject" />
</View>
);

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(
<View testID={parentID} className="group" {...{ dataSet: { open: true } }}>
<View testID={childID} className="subject" />
</View>,
);

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(
<View testID={parentID} className="group">
<View testID={childID} className="subject" />
</View>,
);

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(
<View testID="outer" className="group" {...{ dataSet: { state: "open" } }}>
<View className="group" {...{ dataSet: { state: "closed" } }}>
<View testID={childID} className="subject" />
</View>
</View>,
);

// 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(
<View
testID="outer"
className="group"
{...{ dataSet: { state: "closed" } }}
>
<View className="group" {...{ dataSet: { state: "open" } }}>
<View testID={childID} className="subject" />
</View>
</View>,
);

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(
<View
testID={childID}
className="group subject"
{...{ dataSet: { state: "open" } }}
/>,
);

expect(screen.getByTestId(childID)).not.toHaveStyle({ color: "#00f" });
});
16 changes: 13 additions & 3 deletions src/native/conditions/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,28 @@ 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<string, unknown> | undefined | null,
guards: RenderGuard[],
guards?: RenderGuard[],
) {
return queries.every((query) => testAttribute(query, props, guards));
}

function testAttribute(
[type, prop, operator, testValue]: AttributeQuery,
props: Record<string, unknown> | undefined | null,
guards: RenderGuard[],
guards?: RenderGuard[],
) {
let value: unknown = undefined;

Expand All @@ -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;
Expand Down
16 changes: 12 additions & 4 deletions src/native/conditions/container-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import type {

import {
activeFamily,
containerAttributesFamily,
containerHeightFamily,
containerWidthFamily,
focusFamily,
hoverFamily,
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___";
Expand Down Expand Up @@ -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;
Expand Down
Loading