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
171 changes: 171 additions & 0 deletions src/__tests__/native/grouping.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
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";

Expand Down Expand Up @@ -108,3 +109,173 @@ test("group selector", () => {

expect(child.props.style).toStrictEqual(undefined);
});

const RED = { color: "#f00" };
const CHAIN_CSS = `.outer .inner .subject { color: red; }`;

test("a chain compiles outermost first, each ancestor class registering a container", () => {
const stylesheet = compile(CHAIN_CSS).stylesheet();
const rule = (name: string) =>
stylesheet.s?.find(([className]) => className === name)?.[1][0];

expect(rule("subject")?.cq).toStrictEqual([
{ n: "g:outer" },
{ n: "g:inner" },
]);
expect(rule("outer")?.c).toStrictEqual(["g:outer"]);
expect(rule("inner")?.c).toStrictEqual(["g:inner"]);
});

test("a chain applies when its ancestors nest in the order it names, with anything between", () => {
registerCSS(CHAIN_CSS);

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

expect(screen.getByTestId("adjacent")).toHaveStyle(RED);
expect(screen.getByTestId("intervening")).toHaveStyle(RED);
});

test("a chain does not apply to its ancestors nested the other way round, side by side, or on one element", () => {
registerCSS(CHAIN_CSS);

render(
<>
<View className="inner">
<View className="outer">
<View testID="reversed" className="subject" />
</View>
</View>
<View className="inner" />
<View className="outer">
<View testID="siblings" className="subject" />
</View>
<View className="outer inner">
<View testID="one-element" className="subject" />
</View>
</>,
);

expect(screen.getByTestId("reversed").props.style).toStrictEqual(undefined);
expect(screen.getByTestId("siblings").props.style).toStrictEqual(undefined);
expect(screen.getByTestId("one-element").props.style).toStrictEqual(
undefined,
);
});

test("a three-deep chain answers its order 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("each compound's interaction reads its own container", () => {
registerCSS(`
.outer .inner:hover .inner-hover { color: red; }
.outer:hover .inner .outer-hover { color: red; }
`);

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

fireEvent(screen.getByTestId("inner"), "hoverIn");
expect(screen.getByTestId("inner-hover")).toHaveStyle(RED);
expect(screen.getByTestId("outer-hover").props.style).toStrictEqual(
undefined,
);

fireEvent(screen.getByTestId("inner"), "hoverOut");
fireEvent(screen.getByTestId("outer"), "hoverIn");
expect(screen.getByTestId("inner-hover").props.style).toStrictEqual(
undefined,
);
expect(screen.getByTestId("outer-hover")).toHaveStyle(RED);
});

test("an outer container's attribute is read from that container, and re-derives when it changes", () => {
registerCSS(`.outer[data-state="on"] .inner .subject { color: red; }`);

const tree = (state: string) => (
<>
<View className="outer" {...{ dataSet: { state } }}>
<View className="inner">
<View testID="ordered" className="subject" />
</View>
</View>
<View className="inner">
<View className="outer" {...{ dataSet: { state } }}>
<View testID="reversed" className="subject" />
</View>
</View>
</>
);

render(tree("on"));
expect(screen.getByTestId("ordered")).toHaveStyle(RED);
expect(screen.getByTestId("reversed").props.style).toStrictEqual(undefined);

screen.rerender(tree("off"));
expect(screen.getByTestId("ordered").props.style).toStrictEqual(undefined);
});

test("a chain renders the same styles twice over", () => {
registerCSS(CHAIN_CSS);

const tree = (
<View className="outer">
<View className="inner">
<View testID="subject" className="subject" />
</View>
</View>
);

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

screen.rerender(tree);

expect(screen.getByTestId("subject").props.style).toStrictEqual(first);
expect(screen.getByTestId("subject")).toHaveStyle(RED);
});
39 changes: 23 additions & 16 deletions src/native/conditions/container-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,41 +14,48 @@ import {
focusFamily,
hoverFamily,
type ContainerContextValue,
type ContainerRegistration,
type Getter,
} from "../reactivity";
import { testAttributes } from "./attributes";
import type { RenderGuard } from "./guards";

export const DEFAULT_CONTAINER_NAME = "c:___default___";

// The ancestor compounds arrive outermost first; each resolves in the scope of the one inside it (Selectors 4 §16.1)
export function testContainerQueries(
queries: ContainerQuery[],
inheritedContainers: ContainerContextValue,
guards: RenderGuard[],
get: Getter,
) {
return queries.every((query) => {
return testContainerQuery(query, inheritedContainers, guards, get);
});
// Every container the walk reaches registered above this element, so this element's scope observes a change to any of them
for (const query of queries) {
const name = query.n ?? DEFAULT_CONTAINER_NAME;
guards.push(["c", name, inheritedContainers[name]]);
}

let scope = inheritedContainers;

for (const query of [...queries].reverse()) {
const container = scope[query.n ?? DEFAULT_CONTAINER_NAME];

if (!container || !testContainerQuery(query, container, get)) {
return false;
}

scope = container.scope;
}

return true;
}

export function testContainerQuery(
query: ContainerQuery,
inheritedContainers: ContainerContextValue,
guards: RenderGuard[],
container: ContainerRegistration,
get: Getter,
): boolean {
const name = query.n ?? DEFAULT_CONTAINER_NAME;
const container = inheritedContainers[name]!;

guards.push(["c", name, container]);

if (!container) {
return false;
}

// Ancestor snapshots have their own context guard. Their attributes must not
// be compared with this descendant's props by the ordinary attribute guards.
// The registration's own guard covers a container's props, so this element's attribute guards must not record them
if (query.a && !testAttributes(query.a, container.props, [])) {
return false;
}
Expand Down
3 changes: 2 additions & 1 deletion src/native/conditions/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
import type { ComponentState } from "../react/useNativeCss";
import type {
ContainerContextValue,
ContainerRegistration,
VariableContextValue,
} from "../reactivity";

export type RenderGuard =
| ["a", string, any]
| ["d", string, any]
| ["v", string, any]
| ["c", string, WeakKey];
| ["c", string, ContainerRegistration | undefined];

export function testGuards(
state: ComponentState,
Expand Down
17 changes: 9 additions & 8 deletions src/native/react/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
VAR_SYMBOL,
weakFamily,
type ContainerContextValue,
type ContainerRegistration,
type VariableContextValue,
} from "../reactivity";
import { stylesFamily } from "../styles";
Expand Down Expand Up @@ -141,24 +142,24 @@ export function updateRules(
}

if (rule.c) {
const registration: ContainerRegistration = {
key: state.ruleEffectGetter,
props: currentProps,
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]: {
key: state.ruleEffectGetter,
props: currentProps,
},
[DEFAULT_CONTAINER_NAME]: registration,
};
}

// This this component as the named container
for (const name of rule.c) {
containers![name] = {
key: state.ruleEffectGetter,
props: currentProps,
};
containers![name] = registration;
}

// Enable hover/active/focus/layout handlers
Expand Down
9 changes: 6 additions & 3 deletions src/native/react/useNativeCss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,16 @@ export function useNativeCss(
}

if (state.containers) {
// Publish a new props snapshot even when this component's own rules did not
// change. Descendants can depend on an ancestor attribute through a group.
// Republished every render, even when this component's own rules did not change: a descendant can ask after its props or the containers above it
const containers = Object.fromEntries(
Object.entries(state.containers).map(([name, container]) => [
name,
container.key === state.ruleEffectGetter
? { key: container.key, props: originalProps }
? {
key: container.key,
props: originalProps,
scope: inheritedContainers,
}
: (inheritedContainers[name] ?? container),
]),
);
Expand Down
15 changes: 8 additions & 7 deletions src/native/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,14 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));

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

export type ContainerContextValue = Record<
string,
{
key: WeakKey;
props: Record<string, unknown> | null | undefined;
}
>;
export interface ContainerRegistration {
readonly key: WeakKey;
readonly props: Record<string, unknown> | null | undefined;
/** The containers above this one, so `.a .b .x` can ask for an `.a` above the `.b` (Selectors 4 §16.1) */
readonly scope: ContainerContextValue;
}

export type ContainerContextValue = Record<string, ContainerRegistration>;
export const ContainerContext = createContext<ContainerContextValue>({});

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