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
221 changes: 221 additions & 0 deletions src/__tests__/native/ancestor-chain-order.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<View className="outer">
<View className="inner">
<View testID="subject" className="subject" />
</View>
</View>,
);

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(
<View className="inner">
<View className="outer">
<View testID="subject" className="subject" />
</View>
</View>,
);

expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined);
});

test("it does not apply when neither ancestor contains the other", () => {
registerCSS(CHAIN_CSS);

render(
<>
<View className="inner" />
<View className="outer">
<View testID="subject" className="subject" />
</View>
</>,
);

expect(screen.getByTestId("subject").props.style).toStrictEqual(undefined);
});

test("intervening elements do not break the chain", () => {
registerCSS(CHAIN_CSS);

render(
<View className="outer">
<View>
<View className="inner">
<View>
<View testID="subject" className="subject" />
</View>
</View>
</View>
</View>,
);

expect(screen.getByTestId("subject")).toHaveStyle(RED);
});

test("a three-deep chain answers the same way", () => {
registerCSS(`.a .b .c .subject { color: red; }`);

render(
<>
<View className="a">
<View className="b">
<View className="c">
<View testID="ordered" className="subject" />
</View>
</View>
</View>
<View className="a">
<View className="c">
<View className="b">
<View testID="swapped" className="subject" />
</View>
</View>
</View>
</>,
);

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(
<View className="outer inner">
<View testID="subject" className="subject" />
</View>,
);

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(
<>
<View className="group">
<View testID="under" className="subject" />
</View>
<View testID="outside" className="subject" />
</>,
);

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(
<View className="outer">
<View testID="inner" className="inner">
<View testID="subject" className="subject" />
</View>
</View>,
);

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(
<View testID="outer" className="outer">
<View testID="inner" className="inner">
<View testID="subject" className="subject" />
</View>
</View>,
);

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(
<View className="outer">
<View testID="inner" className="inner">
<View testID="subject" className="subject" />
</View>
</View>,
);

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 = (
<View className="outer">
<View className="inner">
<View testID="subject" className="subject" />
</View>
</View>
);

const { rerender } = render(tree);
const first = screen.getByTestId("subject").props.style;

rerender(tree);

expect(screen.getByTestId("subject").props.style).toStrictEqual(first);
});
});
49 changes: 33 additions & 16 deletions src/native/conditions/container-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
7 changes: 4 additions & 3 deletions src/native/conditions/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down
10 changes: 8 additions & 2 deletions src/native/react/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/native/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,19 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));

/** Containers ****************************************************************/

export type ContainerContextValue = Record<string, WeakKey>;
/**
* 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<string, ContainerRegistration>;
export const ContainerContext = createContext<ContainerContextValue>({});

export const containerLayoutFamily = weakFamily(() => {
Expand Down