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
90 changes: 90 additions & 0 deletions src/__tests__/native/config-identity.test.ts
Original file line number Diff line number Diff line change
@@ -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<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 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<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", () => {
expect(mappingToConfig({ className: "style" })).toEqual(
mappingToConfig(viewMapping),
);
});
98 changes: 98 additions & 0 deletions src/__tests__/native/state-hash.test.ts
Original file line number Diff line number Diff line change
@@ -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<number, 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);
}
}

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<number>();
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]),
);
});
29 changes: 29 additions & 0 deletions src/__tests__/native/style-cache-collisions.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<View key={id} testID={id} className={`cw-${width} ch-${height}`} />
))}
</>,
);

expect(
cells.map(({ id }) => screen.getByTestId(id).props.style as unknown),
).toStrictEqual(cells.map(({ width, height }) => ({ width, height })));
});
94 changes: 94 additions & 0 deletions src/__tests__/native/style-cache.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof RNButton> = {
className: { target: false, nativeStyleMapping: { color: "color" } },
};

const Button = copyComponentProperties(
RNButton,
(props: StyledProps<ButtonProps, typeof buttonMapping>) =>
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) => (
<View key={index} className="probe" />
))}
</>,
);

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

test("elements with different class lists keep distinct entries", () => {
registerCSS(`.red { color: red; } .blue { color: blue; }`);
stylesFamily.clear();

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

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(<Button className="tinted" title="Go" onPress={() => undefined} />);

expect(JSON.stringify(shared)).toBe(before);
expect(titleColor("Go")).toBe("#ffa500");
});

test("a shared entry is not consumed by the first element that reads it", () => {
registerCSS(`.tinted { color: orange; }`);
stylesFamily.clear();
const titles = ["a", "b", "c"];

render(
<>
{titles.map((title) => (
<Button
key={title}
className="tinted"
title={title}
onPress={() => undefined}
/>
))}
</>,
);

expect(stylesFamily.size()).toBe(1);
expect(titles.map(titleColor)).toStrictEqual([
"#ffa500",
"#ffa500",
"#ffa500",
]);
});
64 changes: 25 additions & 39 deletions src/native/react/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,55 +290,41 @@ function pushInlineRule(
}

/**
* Get a unique number for a weak key.
* Get a unique number for a weak key. Starts at 1, since `mix32(0)` is 0.
*/
let hashKeyCount = 0;
let hashKeyCount = 1;
const hashKeyFamily = weakFamily(() => hashKeyCount++);

export function generateStateHash(
state: ComponentState,
iterableKeys?: Iterable<WeakKey>,
variables?: WeakKey,
inlineVars?: Set<WeakKey>,
): string {
if (!iterableKeys) {
return "";
}

const keys = [state.configs, ...iterableKeys];

if (variables) {
keys.push(variables);
}

if (inlineVars) {
keys.push(...inlineVars);
}
iterableKeys: Iterable<WeakKey>,
): number {
return generateHash([state.configs, ...iterableKeys]);
}

return generateHash(keys);
// Murmur3's 32-bit finalizer, so neighbouring ids give unrelated values
function mix32(x: number) {
x ^= x >>> 16;
x = Math.imul(x, 0x85ebca6b);
x ^= x >>> 13;
x = Math.imul(x, 0xc2b2ae35);
x ^= x >>> 16;
return x;
}

/**
* Quickly generate a unique hash for a set of numbers.
* This is not a cryptographic hash, but it is fast and has a low chance of collision.
* Hash a set of weak keys into a 53-bit integer. Each id is mixed into two 32-bit values summed
* across the set, so the hash is order-independent without a sort. A collision would hand one
* element another's styles, but at 53 bits the odds are about n² / 2^54 for n live entries.
*/
const MOD = 9007199254740871; // Largest prime within safe integer range 2^53
const PRIME = 31; // A smaller prime for mixing
export function generateHash(keys: WeakKey[]): string {
let hash = 0;
let product = 1; // Used for mixing to enhance uniqueness

export function generateHash(keys: WeakKey[]): number {
let a = 0;
let b = 0;
for (const key of keys) {
if (!key) continue; // Skip if key is undefined

const num = hashKeyFamily(key);
hash = (hash ^ num) % MOD; // XOR and modular arithmetic
product = (product * (num + PRIME)) % MOD; // Mix with multiplication
const h = mix32(hashKeyFamily(key));
a = (a + h) | 0;
b = (b + mix32(h ^ 0x9e3779b9)) | 0;
}

// Combine hash and product to form the final hash
hash = (hash + product) % MOD;

// Return the hash as a string
return hash.toString(36);
// 32 bits of `a` and the top 21 of `b`: 53 bits, exactly representable as a number.
return (a >>> 0) * 0x200000 + (b >>> 11);
}
Loading
Loading