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/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..b2dcc38a --- /dev/null +++ b/src/__tests__/native/style-cache.test.tsx @@ -0,0 +1,94 @@ +import { Button as RNButton, type ButtonProps } from "react-native"; + +import { render, screen } from "@testing-library/react-native"; +import { copyComponentProperties } from "react-native-css/components/copyComponentProperties"; +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 { 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; + +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(