Skip to content
Open
121 changes: 121 additions & 0 deletions src/__tests__/native/config-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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<Effect>();
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<typeof RNView>;

const scrollViewMapping = {
className: "style",
contentContainerClassName: "contentContainerStyle",
} satisfies StyledConfiguration<typeof RNScrollView>;

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<string, string> = { 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),
);
});
134 changes: 134 additions & 0 deletions src/__tests__/native/family.test.ts
Original file line number Diff line number Diff line change
@@ -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<object, number>(() => 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<string, number>(() => 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<string, { readonly key: string }>(
(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<string, { readonly key: string }>((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<string, { readonly key: string }>((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<string, { readonly built: number }>((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);
});
98 changes: 98 additions & 0 deletions src/__tests__/native/native-style-mapping-idempotence.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RNButton> = {
className: {
target: false,
nativeStyleMapping: { color: "color" },
},
};

const Button = copyComponentProperties(
RNButton,
(props: StyledProps<ButtonProps, typeof mapping>) =>
useCssElement(RNButton, props, mapping),
);

test("re-rendering does not re-drain the cached style object", () => {
registerCSS(`.drain-once { color: orange; }`);

const tree = render(
<Button
testID={testID}
className="drain-once"
title="Go"
onPress={() => undefined}
/>,
);

const readColor = (): unknown => tree.getByText("Go").props.style?.[1]?.color;

const first = readColor();
expect(first).toBe("#ffa500");

for (let pass = 0; pass < 10; pass += 1) {
tree.rerender(
<Button
testID={testID}
className="drain-once"
title="Go"
onPress={() => undefined}
/>,
);
expect(readColor()).toBe(first);
}

tree.unmount();
});

test("a second component joining the same entry sees the same mapped value", () => {
// The sharper half: the entry is shared, so a mutation left behind by the first consumer would
// reach the second as a MISSING value rather than a wrong one.
registerCSS(`.drain-shared { color: blue; }`);

const first = render(
<Button
testID={testID}
className="drain-shared"
title="One"
onPress={() => undefined}
/>,
);
const second = render(
<Button
testID={testID}
className="drain-shared"
title="Two"
onPress={() => undefined}
/>,
);

expect(first.getByText("One").props.style?.[1]?.color).toBe("#00f");
expect(second.getByText("Two").props.style?.[1]?.color).toBe("#00f");

first.unmount();
second.unmount();
});
Loading