Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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),
);
});
42 changes: 42 additions & 0 deletions src/__tests__/native/family.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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]));
});
73 changes: 73 additions & 0 deletions src/__tests__/native/state-hash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { generateHash } from "../../native/react/rules";

/**
* `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 before this was injective: a `vertical-align: top` element rendered `object-fit: contain`
* because both rule sets hashed to `18h`.
*/

/** Distinct weak keys. Any object is a valid one. */
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<string, string>();

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);
}
}

// Vacuity guard: the loop above must actually have hashed every pair.
expect(owner.size).toBe((keys.length * (keys.length - 1)) / 2);
});

test("a key set hashes the same however it is ordered", () => {
// The rule set reaching `generateStateHash` is a Set built in render order, so order-independence
// is what lets two elements with the same rules share one entry at all.
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", () => {
// The encoding is exact only if it encodes every member. Skipping one — which the inherited
// `if (!key) continue` did — makes `[a, b]` and `[a, falsy, b]` the same string, and a cache
// key collision hands the second caller the first caller's styles. Unreachable from typed code,
// so this pins the contract rather than a bug: out of domain fails, it does not vanish.
const key: WeakKey = {};

expect(() => generateHash([key, undefined as unknown as WeakKey])).toThrow();
});

test("hashing the same key twice answers the same value", () => {
// `hashKeyFamily` assigns each key a number once. A key whose number is not retained hashes
// differently on its second lookup, which splits its cache entry.
const only: WeakKey = {};

expect(generateHash([only])).toBe(generateHash([only]));
});

test("the encoding is integers, not floats or exponents", () => {
// The ordering runs through a `Float64Array`, so every member is a double by the time it is
// joined. `Number.prototype.toString` renders an integral double without a fraction, and only
// switches to exponential notation past 1e21 — far beyond a key counter. Pinning it here means a
// future change to the array type has to face the question rather than silently reshape the key.
const keys: WeakKey[] = [{}, {}, {}];

expect(generateHash(keys)).toMatch(/^\d+(?:,\d+)*$/u);
});
104 changes: 104 additions & 0 deletions src/__tests__/native/style-cache-bounds.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { View as RNView, type ViewProps } 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";

/**
* Sharing a cache entry across elements is only safe if the entry is (a) released when the last
* element using it unmounts, and (b) not consumed by whichever element reads it first.
*
* Both became load-bearing when the config stopped being per-instance: before that, every element
* held its own entry, so an entry that leaked was one element's worth and an entry that was drained
* was drained only for its owner.
*/

/** A module constant, exactly as every shipped wrapper declares it. */
const tintedMapping = {
className: { target: "style", nativeStyleMapping: { color: "tintColor" } },
} as unknown as StyledConfiguration<typeof RNView>;

const Tinted = copyComponentProperties(
RNView,
(props: StyledProps<ViewProps, typeof tintedMapping>) =>
useCssElement(RNView, props, tintedMapping),
);

test("nothing writes to the config every element now shares", () => {
// While each element minted its own config, a write to one was private. Sharing makes
// "the consumers only read it" load-bearing rather than incidental — and that claim was
// inherited rather than measured.
//
// Checked by VALUE rather than by `Object.freeze`: a write to a frozen object throws only in
// strict mode, and measured here it does not throw at all — so a freeze-based version of this
// test passes whatever the code does, which is worse than not having it.
registerCSS(`.tinted { color: orange; }`);

const shared = mappingToConfig(tintedMapping);
const before = JSON.stringify(shared);

render(<Tinted className="tinted" testID="unmutated" />);

expect(JSON.stringify(shared)).toBe(before);
expect(screen.getByTestId("unmutated").props.tintColor).toBe("#ffa500");
});

test("a shared entry is not consumed by the first element that reads it", () => {
// `nativeStyleMapping` drains the resolved style in place. That is harmless when every element
// owns its entry and load-bearing once they share one.
registerCSS(`.tinted { color: orange; }`);
stylesFamily.clear();

render(
<>
<Tinted className="tinted" testID="a" />
<Tinted className="tinted" testID="b" />
<Tinted className="tinted" testID="c" />
</>,
);

const first = screen.getByTestId("a").props.tintColor;

expect(first).toBe("#ffa500");
expect(screen.getByTestId("b").props.tintColor).toBe(first);
expect(screen.getByTestId("c").props.tintColor).toBe(first);
});

test("the cache does not grow when a screen is entered and left repeatedly", () => {
// The OOM shape, and the one this PR changes. Entries are not released on unmount — that is a
// separate, pre-existing defect — so what matters is whether a second visit ADDS to them.
//
// With the config minted per instance, a remount produces new config identities, therefore new
// state hashes, therefore new entries, while the old ones stay. Measured on `main`: 2 entries
// after the first visit, 4 after the second, and so on without limit. Deriving the config once
// per mapping makes the second visit reuse the first visit's key, so the count is a function of
// what the app renders rather than of how often it has been rendered.
registerCSS(`.churn-a { color: red; } .churn-b { color: blue; }`);
stylesFamily.clear();

const sizes: number[] = [];

for (let cycle = 0; cycle < 25; cycle += 1) {
const tree = render(
<>
<View className="churn-a" />
<View className="churn-b" />
</>,
);
sizes.push(stylesFamily.size());
tree.unmount();
}

// Every cycle reaches the same count. A ratcheting cache fails on the second entry, not the last.
expect(new Set(sizes).size).toBe(1);
expect(sizes[0]).toBe(2);
});
43 changes: 43 additions & 0 deletions src/__tests__/native/style-cache-sharing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { render } from "@testing-library/react-native";
import { View } from "react-native-css/components/View";
import { registerCSS } from "react-native-css/jest";

import { stylesFamily } from "../../native/styles";

/**
* The end-to-end statement of what the cache is for: it holds one entry per distinct set of
* resolved inputs, not one per mounted element. Everything else in the resolution path is an
* implementation detail of reaching that number.
*/

const ELEMENTS = 50;

test("identical elements share one resolved-style cache entry", () => {
registerCSS(`.probe { color: red; width: 10px; }`);
stylesFamily.clear();

render(
<>
{Array.from({ length: ELEMENTS }, (_unused, index) => (
<View key={index} className="probe" />
))}
</>,
);

expect(stylesFamily.size()).toBe(1);
});

test("elements with different class lists keep distinct entries", () => {
// The counter-case: sharing must follow the inputs, not collapse everything onto one entry.
registerCSS(`.red { color: red; } .blue { color: blue; }`);
stylesFamily.clear();

render(
<>
<View className="red" />
<View className="blue" />
</>,
);

expect(stylesFamily.size()).toBe(2);
});
Loading