diff --git a/src/__tests__/native/config-identity.test.ts b/src/__tests__/native/config-identity.test.ts new file mode 100644 index 00000000..703aae80 --- /dev/null +++ b/src/__tests__/native/config-identity.test.ts @@ -0,0 +1,121 @@ +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"; + +/** + * The resolved-style cache is keyed by `generateStateHash`, which hashes `state.configs` by object + * identity. Sharing therefore depends on one mapping producing one config array, rather than an + * equal array per consumer. + * + * The rules half of that key already holds: `StyleCollection.styles(className)` is keyed by the + * class string, so every element carrying the same class list references one rule set. The config + * half is the one input that is minted per consumer. + */ + +/** A stand-in for the rule set a shared class list resolves to — any object is a valid weak key. */ +const ruleFor = (): WeakKey => ({}); + +/** The smallest `ComponentState` `generateStateHash` reads: it only touches `configs`. */ +const stateFor = (configs: Config[]): ComponentState => { + const observers = new Set(); + const ruleEffect: Effect = { observers, run: () => undefined }; + const inheritedVariables: VariableContextValue = { [VAR_SYMBOL]: true }; + + return { + configs, + inheritedContainers: {}, + inheritedVariables, + ruleEffect, + ruleEffectGetter: (observable) => observable.get(ruleEffect), + styleEffect: { observers, 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 state hash always carries the config, so it is never the empty string", () => { + // The empty string used to double as a no-keys sentinel in `generateStateHash`. With the key a + // join rather than a digest, an empty key list renders as the empty string too — so the sentinel + // and a real state would have shared one cache entry. The config is an unconditional key, which + // is what makes the sentinel unnecessary rather than merely unlikely. + const state = stateFor(mappingToConfig(viewMapping)); + + expect(generateStateHash(state, [])).not.toBe(""); + expect(generateStateHash(state, [ruleFor()])).not.toBe(""); +}); + +test("a mapping that is not an object is refused by name", () => { + // The derivation is cached on the mapping OBJECT. A primitive cannot be a weak-map key, so + // without this guard the failure surfaces as a `WeakMap` error naming nothing the caller wrote. + 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", () => { + // Documented rather than defended: `useCssElement` already froze the derivation per instance, so + // a mutation only ever reached NEWLY mounted elements — the same mapping meaning two things at + // once. Deriving once per mapping settles it on one. + 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", () => { + // Every wrapper the library ships passes a module constant, but a caller may build the mapping + // inline. That path cannot share on identity and has to keep working unchanged. + expect(mappingToConfig({ className: "style" })).toEqual( + mappingToConfig(viewMapping), + ); +}); diff --git a/src/__tests__/native/family.test.ts b/src/__tests__/native/family.test.ts new file mode 100644 index 00000000..18681be2 --- /dev/null +++ b/src/__tests__/native/family.test.ts @@ -0,0 +1,134 @@ +import { generateHash } from "../../native/react/rules"; +import { family, weakFamily } from "../../native/reactivity"; + +/** + * `family` and `weakFamily` promise one factory call per key, and cached on the result being + * truthy. A factory that legitimately returns `0`, `""` or `false` was therefore re-run on every + * lookup, and its key never settled on a value. + * + * `hashKeyFamily` in `native/react/rules.ts` is such a factory: it hands out `hashKeyCount++`, so + * the first weak key ever hashed is assigned `0` and is the one key that never caches. Its hash + * changes between lookups, which splits every cache keyed on that hash. + */ + +test("weakFamily calls its factory once per key when the result is falsy", () => { + let calls = 0; + const numbers = weakFamily(() => calls++); + const key = {}; + + expect(numbers(key)).toBe(0); + expect(numbers(key)).toBe(0); + expect(calls).toBe(1); +}); + +test("family calls its factory once per key when the result is falsy", () => { + let calls = 0; + const numbers = family(() => calls++); + + expect(numbers("key")).toBe(0); + expect(numbers("key")).toBe(0); + expect(calls).toBe(1); +}); + +test("a weak key hashes to the same value on every lookup", () => { + // The key assigned `0` is the one the falsy-cache miss exposes, and it is whichever key this + // module hashes FIRST. Asserting the value rather than only the agreement is what keeps that + // true: a test added above this one that hashes would take the `0` and leave this passing + // against a key that was never at risk. + const key = {}; + + expect(generateHash([key])).toBe("0"); + expect(generateHash([key])).toBe(generateHash([key])); +}); + +test("a bounded family never exceeds its cap", () => { + // Unbounded is the pre-existing shape and it is reachable from the public API: `vars()` returns a + // fresh object per call, so an inline `style={vars({...})}` hands the resolved-style cache a new + // weak key — and therefore a new entry — on every render of a component that never unmounts. + const bounded = family( + (key) => ({ key }), + 4, + ); + + for (let index = 0; index < 40; index += 1) { + bounded(`key-${String(index)}`); + } + + expect(bounded.size()).toBe(4); +}); + +test("a bounded family evicts the least recently READ, not the oldest", () => { + // Which entry goes matters more than that one goes. The workload that fills this cache is a + // churn of single-use keys arriving beside a small set that is read every render — so evicting by + // insertion order would discard exactly the entries worth keeping and leave the garbage. + const built: string[] = []; + const bounded = family((key) => { + built.push(key); + return { key }; + }, 3); + + bounded("keep"); + bounded("evict-me"); + bounded("also-keep"); + bounded("keep"); // a read, which must renew it + bounded("also-keep"); + bounded("fresh"); // pushes past the cap + + // Re-reading tells us which survived: a survivor is served from the map, an evicted key is built + // a second time. That is a stronger check than a membership helper — it proves the entry is gone + // rather than merely unreported. + bounded("keep"); + bounded("also-keep"); + bounded("fresh"); + bounded("evict-me"); + + expect(bounded.size()).toBe(3); + expect(built).toStrictEqual([ + "keep", + "evict-me", + "also-keep", + "fresh", + "evict-me", + ]); +}); + +test("an unbounded family is unchanged", () => { + // Every other `family` in the library is keyed by something the stylesheet bounds — a class name, + // a variable name — so a cap there would be a cost with nothing to buy. Omitting it must keep the + // exact prior behaviour rather than applying a default. + const unbounded = family((key) => ({ + key, + })); + + for (let index = 0; index < 40; index += 1) { + unbounded(`key-${String(index)}`); + } + + expect(unbounded.size()).toBe(40); +}); + +test("deleteIf removes a key only while it still maps to that value", () => { + // A cached value that releases itself knows the key it was created under, and that key may since + // have been remapped — by eviction and a rebuild, or by a consumer that superseded its own entry. + // Deleting by key alone then destroys whatever took the key, which is live and someone else's. + const built: string[] = []; + const cache = family((key) => { + built.push(key); + return { built: built.length }; + }); + + const original = cache("shared"); + cache.delete("shared"); + const replacement = cache("shared"); + + expect(replacement).not.toBe(original); + + // The original releasing itself must not touch the replacement. + expect(cache.deleteIf("shared", original)).toBe(false); + expect(cache.size()).toBe(1); + expect(cache("shared")).toBe(replacement); + + // The holder of the current value can still release it. + expect(cache.deleteIf("shared", replacement)).toBe(true); + expect(cache.size()).toBe(0); +}); diff --git a/src/__tests__/native/native-style-mapping-idempotence.test.tsx b/src/__tests__/native/native-style-mapping-idempotence.test.tsx new file mode 100644 index 00000000..bce343f1 --- /dev/null +++ b/src/__tests__/native/native-style-mapping-idempotence.test.tsx @@ -0,0 +1,98 @@ +import { Button as RNButton, type ButtonProps } from "react-native"; + +import { render } from "@testing-library/react-native"; +import { copyComponentProperties } from "react-native-css/components/copyComponentProperties"; +import { registerCSS, testID } from "react-native-css/jest"; +import { useCssElement } from "react-native-css/native"; +import type { + StyledConfiguration, + StyledProps, +} from "react-native-css/runtime.types"; + +/** + * `nativeStyleMapping` drains keys OUT of the resolved style object and writes them onto real props + * — `delete source[key]`, then `props[path] = value`. It mutates. + * + * That was harmless while every read of the styles observable recomputed: each render got a fresh + * object and the mutation died with it. Now the observable memoises, so the object it mutates is the + * CACHED one, shared by every consumer of that entry and surviving every render. + * + * It is idempotent — the second pass finds the key already drained and continues — but nothing + * enforced that, and "it happens to be a no-op" is not a property anyone can rely on while editing + * the drain. This pins it: the same component re-rendered many times, and a second component + * joining the same cache entry, must both see the mapped prop and never a re-drained style. + */ + +const mapping: StyledConfiguration = { + className: { + target: false, + nativeStyleMapping: { color: "color" }, + }, +}; + +const Button = copyComponentProperties( + RNButton, + (props: StyledProps) => + useCssElement(RNButton, props, mapping), +); + +test("re-rendering does not re-drain the cached style object", () => { + registerCSS(`.drain-once { color: orange; }`); + + const tree = render( +