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),
);
});
50 changes: 50 additions & 0 deletions src/__tests__/native/state-hash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { generateHash } from "../../native/react/rules";

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

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 encoding is integers, not floats or exponents", () => {
const keys: WeakKey[] = [{}, {}, {}];

expect(generateHash(keys)).toMatch(/^\d+(?:,\d+)*$/u);
});
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",
]);
});
46 changes: 11 additions & 35 deletions src/native/react/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,48 +297,24 @@ const hashKeyFamily = weakFamily(() => hashKeyCount++);

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

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

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

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

return generateHash(keys);
return generateHash([state.configs, ...iterableKeys]);
}

/**
* 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.
*/
const MOD = 9007199254740871; // Largest prime within safe integer range 2^53
const PRIME = 31; // A smaller prime for mixing
// An exact encoding rather than a digest, since a collision would hand one element another element's styles
export function generateHash(keys: WeakKey[]): string {
let hash = 0;
let product = 1; // Used for mixing to enhance uniqueness
// A typed array sorts numerically without a comparator, and Float64 holds the unbounded key counter where Int32 wraps
const numbers = new Float64Array(keys.length);
let index = 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
numbers[index] = hashKeyFamily(key);
index += 1;
}

// Combine hash and product to form the final hash
hash = (hash + product) % MOD;
// Sorted, so a key set encodes the same in any order
numbers.sort();

// Return the hash as a string
return hash.toString(36);
return numbers.join(",");
}
20 changes: 16 additions & 4 deletions src/native/react/useNativeCss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { testGuards, type RenderGuard } from "../conditions/guards";
import {
cleanupEffect,
ContainerContext,
weakFamily,
type ContainerContextValue,
type Effect,
type Getter,
Expand Down Expand Up @@ -190,10 +191,10 @@ export function useNativeCss(
return createElement(type, props);
}

/**
* Convert the styled() mapping to a config array
*/
export function mappingToConfig(mapping: StyledConfiguration<any>) {
// Derived once per mapping, since the resolved-style cache keys on the config array's identity
const configForMapping = weakFamily(function (
mapping: StyledConfiguration<any>,
): Config[] {
return Object.entries(mapping).flatMap(([key, value]): Config => {
if (value === true) {
return {
Expand Down Expand Up @@ -237,6 +238,17 @@ export function mappingToConfig(mapping: StyledConfiguration<any>) {

throw new Error(`styled(): Invalid mapping for ${key}: ${value}`);
});
});

// Refused by name, since a primitive would otherwise fail inside the WeakMap with a message naming neither styled() nor the argument
export function mappingToConfig(mapping: StyledConfiguration<any>): Config[] {
if (typeof mapping !== "object" || mapping === null) {
throw new Error(
`styled(): mapping must be an object, received ${mapping === null ? "null" : typeof mapping}`,
);
}

return configForMapping(mapping);
}

// Apply native Image fitting before normal, inline, and important props merge.
Expand Down
3 changes: 3 additions & 0 deletions src/native/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ export function family<Key, Result = Key, Args extends any = void>(
delete(key: Key) {
return map.delete(key);
},
size() {
return map.size;
},
clear() {
return map.clear();
},
Expand Down