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
93 changes: 93 additions & 0 deletions src/__tests__/native/batch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Dimensions } from "react-native";

import { act, render, screen } from "@testing-library/react-native";
import { View } from "react-native-css/components/View";
import { registerCSS } from "react-native-css/jest";

import { batch, observable, type Effect } from "../../native/reactivity";

jest.mock("react-native", () => {
const RN = jest.requireActual("react-native");
RN.Platform.OS = "ios";
return RN as unknown;
});

const rotate = (width: number) => {
act(() => {
const metrics = { width, height: 800, scale: 2, fontScale: 1 };
Dimensions.set({ window: metrics, screen: metrics });
});
};

const effectRecording = (record: () => void): Effect => ({
observers: new Set(),
run: record,
});

test.each([
["before", ".a { width: 10vw; color: var(--c); }"],
["after", ".a { color: var(--c); width: 10vw; }"],
])(
"a rotation reaches an element reading a viewport unit %s a shared width-dependent variable",
(_, rule) => {
registerCSS(`
:root { --c: blue; }
@media (min-width: 500px) { :root { --c: red; } }
${rule}
.b { color: var(--c); }
`);
rotate(300);
render(
<>
<View testID="a" className="a" />
<View testID="b" className="b" />
</>,
);

for (const [width, color] of [
[700, "red"],
[300, "blue"],
[700, "red"],
] as const) {
rotate(width);
expect(screen.getByTestId("a")).toHaveStyle({ width: width / 10, color });
expect(screen.getByTestId("b")).toHaveStyle({ color });
}
},
);

test("an effect notified again while the batch drains runs again", () => {
const source = observable(1);
const derived = observable((read) => read(source) * 10);
const seen: string[] = [];
const subscriber = effectRecording(() => {
seen.push(`${source.get()},${derived.get()}`);
});

source.get(subscriber);
derived.get(subscriber);
batch(() => {
source.set(2);
});

expect(seen.at(-1)).toBe("2,20");
});

test("a batch defers every notification until its update returns, and runs each effect once", () => {
const width = observable(1);
const height = observable(1);
let runs = 0;
const subscriber = effectRecording(() => {
runs += 1;
});

width.get(subscriber);
height.get(subscriber);
batch(() => {
width.set(2);
height.set(2);
expect(runs).toBe(0);
});

expect(runs).toBe(1);
});
90 changes: 90 additions & 0 deletions src/__tests__/native/config-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ScrollView as RNScrollView, View as RNView } from "react-native";

import { generateStateHash } from "../../native/react/rules";
import {
mappingToConfig,
type ComponentState,
type Config,
} from "../../native/react/useNativeCss";
import {
VAR_SYMBOL,
type Effect,
type VariableContextValue,
} from "../../native/reactivity";
import type { StyledConfiguration } from "../../runtime.types";

const ruleFor = (): WeakKey => ({});

const stateFor = (configs: Config[]): ComponentState => {
const ruleEffect: Effect = { observers: new Set(), run: () => undefined };
const inheritedVariables: VariableContextValue = { [VAR_SYMBOL]: true };

return {
configs,
inheritedContainers: {},
inheritedVariables,
ruleEffect,
ruleEffectGetter: (observable) => observable.get(ruleEffect),
styleEffect: { observers: new Set(), run: () => undefined },
};
};

const viewMapping = {
className: "style",
} satisfies StyledConfiguration<typeof RNView>;

const scrollViewMapping = {
className: "style",
contentContainerClassName: "contentContainerStyle",
} satisfies StyledConfiguration<typeof RNScrollView>;

test("two consumers of one mapping share a cache key", () => {
const rules = [ruleFor()];

const first = generateStateHash(
stateFor(mappingToConfig(viewMapping)),
rules,
);
const second = generateStateHash(
stateFor(mappingToConfig(viewMapping)),
rules,
);

expect(first).toBe(second);
});

test("mappings that differ keep different cache keys", () => {
const rules = [ruleFor()];

const view = generateStateHash(stateFor(mappingToConfig(viewMapping)), rules);
const scrollView = generateStateHash(
stateFor(mappingToConfig(scrollViewMapping)),
rules,
);

expect(view).not.toBe(scrollView);
});

test("a mapping that is not an object is refused by name", () => {
expect(() => mappingToConfig("style" as never)).toThrow(
/mapping must be an object/u,
);
expect(() => mappingToConfig(undefined as never)).toThrow(
/mapping must be an object/u,
);
});

test("a mapping mutated after its first use is not re-derived", () => {
const mapping: Record<string, string> = { className: "style" };
const first = mappingToConfig(mapping);

mapping.className = "contentContainerStyle";

expect(mappingToConfig(mapping)).toBe(first);
});

test("a mapping built per call still produces an equal config", () => {
expect(mappingToConfig({ className: "style" })).toEqual(
mappingToConfig(viewMapping),
);
});
32 changes: 32 additions & 0 deletions src/__tests__/native/reactivity-dependencies.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,35 @@ test("an explicit computed argument replaces its previous dependency", () => {
cleanupEffect(subscriber);
expect(second.observers.size).toBe(0);
});

test("an observed computed value runs once per change, not once per read", () => {
const source = observable(1);
let computed = 0;
const value = observable<number>((get) => {
computed += 1;
return get(source) * 2;
});
const subscriber: Effect = { observers: new Set(), run: jest.fn() };

for (let read = 0; read < 20; read += 1) {
expect(value.get(subscriber)).toBe(2);
}
expect(computed).toBe(1);

source.set(5);
expect(value.get(subscriber)).toBe(10);
expect(computed).toBe(2);
});

test("a family deletes a key by value only while the key still maps to it", () => {
const cached = family((key: string) => ({ key }));
const original = cached("shared");
cached.delete("shared");
const replacement = cached("shared");

expect(cached.deleteIf("shared", original)).toBe(false);
expect(cached("shared")).toBe(replacement);

expect(cached.deleteIf("shared", replacement)).toBe(true);
expect(cached.size()).toBe(0);
});
50 changes: 50 additions & 0 deletions src/__tests__/native/state-hash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { generateHash } from "../../native/react/rules";

const keysOf = (count: number): WeakKey[] =>
Array.from({ length: count }, () => ({}));

test("distinct key sets never share a hash", () => {
const keys = keysOf(60);
const owner = new Map<string, string>();

for (const [leftIndex, left] of keys.entries()) {
for (const [offset, right] of keys.slice(leftIndex + 1).entries()) {
const signature = `${String(leftIndex)}+${String(leftIndex + 1 + offset)}`;
const hash = generateHash([left, right]);
const prior = owner.get(hash);

expect(prior ?? signature).toBe(signature);
owner.set(hash, signature);
}
}

expect(owner.size).toBe((keys.length * (keys.length - 1)) / 2);
});

test("a key set hashes the same however it is ordered", () => {
const first: WeakKey = {};
const second: WeakKey = {};
const third: WeakKey = {};

expect(generateHash([first, second, third])).toBe(
generateHash([third, first, second]),
);
});

test("a key outside WeakKey is refused rather than erased", () => {
const key: WeakKey = {};

expect(() => generateHash([key, undefined as unknown as WeakKey])).toThrow();
});

test("hashing the same key twice answers the same value", () => {
const only: WeakKey = {};

expect(generateHash([only])).toBe(generateHash([only]));
});

test("the encoding is integers, not floats or exponents", () => {
const keys: WeakKey[] = [{}, {}, {}];

expect(generateHash(keys)).toMatch(/^\d+(?:,\d+)*$/u);
});
Loading