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..3fb4785c --- /dev/null +++ b/src/__tests__/native/state-hash.test.ts @@ -0,0 +1,98 @@ +import { generateHash, generateStateHash } from "../../native/react/rules"; +import type { ComponentState } from "../../native/react/useNativeCss"; + +/** + * `generateHash` keys the resolved-style cache. Two different sets of rules landing on one key does + * not cost a cache miss — `family` returns the entry it already holds and ignores the rules the + * second caller brought, so that element renders the first element's styles. + * + * Measured with the previous, weak hash: a `vertical-align: top` element rendered + * `object-fit: contain` because both rule sets hashed to `18h`. + */ + +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 hash is a safe integer", () => { + // Built from 32 + 21 bits, so it must stay exactly representable as a number. A float or a value + // past 2^53 would round, and rounding merges distinct hashes. + const keys: WeakKey[] = [{}, {}, {}]; + + expect(Number.isSafeInteger(generateHash(keys))).toBe(true); + expect(Number.isSafeInteger(generateHash(keysOf(200)))).toBe(true); +}); + +test("many distinct key sets of realistic shapes never share a hash", () => { + // A config plus every pair from 600 keys (the shape the previous hash collided on most), plus + // large sets the size of a heavily styled element. + const config: WeakKey = {}; + const keys = keysOf(600); + const seen = new Set(); + let sets = 0; + + for (const [index, left] of keys.entries()) { + for (const right of keys.slice(index + 1)) { + seen.add(generateHash([config, left, right])); + sets++; + } + } + for (let start = 0; start + 46 <= keys.length; start++) { + seen.add(generateHash([config, ...keys.slice(start, start + 46)])); + sets++; + } + + expect(seen.size).toBe(sets); +}); + +test("a state hash always carries the config", () => { + // The config is an unconditional member of the key set, so a state with no rules still differs + // from the empty set, and two configs with the same rules differ from each other. + const configs = { configs: [] } as unknown as ComponentState; + const other = { configs: [] } as unknown as ComponentState; + const rule: WeakKey = {}; + + expect(generateStateHash(configs, [])).not.toBe(generateHash([])); + expect(generateStateHash(configs, [rule])).not.toBe( + generateStateHash(other, [rule]), + ); +}); diff --git a/src/__tests__/native/style-cache-collisions.test.tsx b/src/__tests__/native/style-cache-collisions.test.tsx new file mode 100644 index 00000000..11d2b4e7 --- /dev/null +++ b/src/__tests__/native/style-cache-collisions.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS } from "react-native-css/jest"; + +// Every element matches a distinct pair of rules, so a cache key shared by two of them would render +// one element with the other's styles. All stay mounted, so every entry is live at once. +test("every distinct class combination renders its own styles", () => { + const sizes = Array.from({ length: 40 }, (_unused, index) => index + 1); + registerCSS( + sizes + .map((i) => `.cw-${i} { width: ${i}px; } .ch-${i} { height: ${i}px; }`) + .join("\n"), + ); + const cells = sizes.flatMap((width) => + sizes.map((height) => ({ id: `cell-${width}-${height}`, width, height })), + ); + + render( + <> + {cells.map(({ id, width, height }) => ( + + ))} + , + ); + + expect( + cells.map(({ id }) => screen.getByTestId(id).props.style as unknown), + ).toStrictEqual(cells.map(({ width, height }) => ({ width, height }))); +}); 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(