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(