diff --git a/src/__tests__/native/batch.test.tsx b/src/__tests__/native/batch.test.tsx
new file mode 100644
index 00000000..2cb6c876
--- /dev/null
+++ b/src/__tests__/native/batch.test.tsx
@@ -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(
+ <>
+
+
+ >,
+ );
+
+ 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);
+});
diff --git a/src/__tests__/native/config-identity.test.ts b/src/__tests__/native/config-identity.test.ts
new file mode 100644
index 00000000..5edbaf66
--- /dev/null
+++ b/src/__tests__/native/config-identity.test.ts
@@ -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;
+
+const scrollViewMapping = {
+ className: "style",
+ contentContainerClassName: "contentContainerStyle",
+} satisfies StyledConfiguration;
+
+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 = { 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),
+ );
+});
diff --git a/src/__tests__/native/reactivity-dependencies.test.tsx b/src/__tests__/native/reactivity-dependencies.test.tsx
index 52d4cd3b..f008cbd2 100644
--- a/src/__tests__/native/reactivity-dependencies.test.tsx
+++ b/src/__tests__/native/reactivity-dependencies.test.tsx
@@ -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((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);
+});
diff --git a/src/__tests__/native/state-hash.test.ts b/src/__tests__/native/state-hash.test.ts
new file mode 100644
index 00000000..959449f1
--- /dev/null
+++ b/src/__tests__/native/state-hash.test.ts
@@ -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();
+
+ 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);
+});
diff --git a/src/__tests__/native/style-cache.test.tsx b/src/__tests__/native/style-cache.test.tsx
new file mode 100644
index 00000000..d4f8919c
--- /dev/null
+++ b/src/__tests__/native/style-cache.test.tsx
@@ -0,0 +1,390 @@
+import { Activity, StrictMode, type ReactNode } from "react";
+import { Button as RNButton, type ButtonProps } from "react-native";
+
+import { act, render, screen } from "@testing-library/react-native";
+import { VariableContextProvider } from "react-native-css";
+import { copyComponentProperties } from "react-native-css/components/copyComponentProperties";
+import { Text } from "react-native-css/components/Text";
+import { View } from "react-native-css/components/View";
+import { registerCSS } from "react-native-css/jest";
+import { useCssElement } from "react-native-css/native";
+import type {
+ StyledConfiguration,
+ StyledProps,
+} from "react-native-css/runtime.types";
+
+import { mappingToConfig } from "../../native/react/useNativeCss";
+import { colorScheme, dimensions, type Effect } from "../../native/reactivity";
+import { stylesFamily } from "../../native/styles";
+
+const buttonMapping: StyledConfiguration = {
+ className: { target: false, nativeStyleMapping: { color: "color" } },
+};
+
+const Button = copyComponentProperties(
+ RNButton,
+ (props: StyledProps) =>
+ useCssElement(RNButton, props, buttonMapping),
+);
+
+const titleColor = (title: string): unknown =>
+ screen.getByText(title).props.style?.[1]?.color;
+
+const withFreshVariables = (className: string, name: string) => (
+
+
+
+);
+
+test("identical elements share one resolved-style cache entry", () => {
+ registerCSS(`.probe { color: red; width: 10px; }`);
+ stylesFamily.clear();
+
+ render(
+ <>
+ {Array.from({ length: 50 }, (_unused, index) => (
+
+ ))}
+ >,
+ );
+
+ expect(stylesFamily.size()).toBe(1);
+});
+
+test("elements with different class lists keep distinct entries", () => {
+ registerCSS(`.red { color: red; } .blue { color: blue; }`);
+ stylesFamily.clear();
+
+ render(
+ <>
+
+
+ >,
+ );
+
+ expect(stylesFamily.size()).toBe(2);
+});
+
+test("nothing writes to the config elements share", () => {
+ // Compared by value, since a write to a frozen config does not throw here
+ registerCSS(`.tinted { color: orange; }`);
+ const shared = mappingToConfig(buttonMapping);
+ const before = JSON.stringify(shared);
+
+ render(