Summary
useCssElement derives its style config per component instance, but the resolved-style cache is keyed on that config's object identity. Because every shipped components/*.tsx wrapper passes a module-constant mapping, N mounted elements produce N distinct config identities carrying identical values — so N identical elements each get their own stylesFamily entry, their own sorted rule array and their own observable, where one would do.
Measured on a real screen: 2307 cache entries where 417 suffice (5.5×).
styled() does not have this problem — it derives the config once at module scope. Both call sites are in the same file, ten lines apart.
Environment
react-native-css 3.0.7 (current latest), and verified still present on main
- React Native 0.86, Fabric + Hermes, Expo SDK 57
- iPhone 12 Pro Max, iOS 26.6, release build
Steps to reproduce
stylesFamily is not reachable from any public export (./native re-exports api only; ./native-internal publishes root / style-collection / variables), so observing it needs a one-line instrumentation in the package's own source.
-
In src/native/styles/index.ts, after the family is created:
export const stylesFamily = family((hash, rules) => { /* unchanged */ });
// `family()` exposes `delete` / `keys` / `clear` and no `size`, so count via keys().
setInterval(() => {
console.log("stylesFamily entries=" + [...stylesFamily.keys()].length);
}, 1500);
-
In any RN app built against that source, render many elements sharing one class list:
import { View } from "react-native"; // rewritten to components/View.tsx by the Metro resolver
const Probe = () => (
<>
{Array.from({ length: 150 }, (_unused, index) => (
<View key={index} className="size-5 rounded-full border bg-red-500" />
))}
</>
);
-
Read the count once the tree has mounted.
Isolating comparison — swap the rewritten View for styled(RNView, { className: { target: "style" } }), which derives its config once at module scope, and re-read the count on the identical tree. Nothing else changes: same class list, same rules, same element type.
Expected result
The count reflects the number of distinct class lists on screen. 150 elements sharing one class list contribute one entry, because the cache exists to make resolution O(distinct class sets) rather than O(instances).
Actual result
The count scales with the number of instances.
On a screen of 150 identical controls (four styled elements each, plus surrounding chrome):
| build |
stylesFamily entries |
3.0.7 as published |
2307 |
with mappingToConfig memoised on the mapping identity |
417 |
1890 redundant entries, each a separately sorted rule array and a retained observable over the same calculateProps, held for as long as the tree is mounted.
(417 rather than 1 is the honest floor for a real screen — it carries many distinct class lists, and e.g. ScrollView legitimately contributes two configs. What collapses is precisely the per-instance duplication.)
Root cause
The cache key (src/native/react/rules.ts:300) hashes the config by object identity, via a weakFamily that assigns a fresh number to each new object:
const keys = [state.configs, ...iterableKeys];
return generateHash(keys);
The rules half of that key shares correctly — StyleCollection.styles(className) is keyed by the class string.
The config half does not. In src/native/api.tsx, the two entry points disagree:
// line 54 — styled(): once, at module scope. Shares.
const configs = mappingToConfig(mapping);
// line 90 — useCssElement(): once per INSTANCE. Does not share.
const [config] = useState(() => mappingToConfig(mapping));
and every shipped wrapper takes the second path with a module-constant mapping (src/components/View.tsx:11,18):
const mapping = { className: { target: "style", resetsTextAncestor: true } };
// ...
return useCssElement(RNView, props, mapping);
mappingToConfig is pure in mapping, so it returns the same value every time and a different identity every time. The cache is defeated by an identity that always carries the same value.
Proposed fix
Memoise the derived config on the mapping identity — every call site the library itself ships already passes a stable module constant:
const MAPPING_CONFIG_CACHE = new WeakMap<object, Config[]>();
export function mappingToConfig(mapping) {
const cached = MAPPING_CONFIG_CACHE.get(mapping);
if (cached !== undefined) return cached;
const configs = /* existing body */;
MAPPING_CONFIG_CACHE.set(mapping, configs);
return configs;
}
Notes:
useCssElement's useState can stay or go — with mappingToConfig memoised it returns the shared array either way.
- A caller passing a fresh mapping literal per render is unaffected (the
WeakMap simply never hits), so this cannot make any call site worse.
state.configs is read-only in its consumers (iterated for source / target), so sharing one array across components is safe.
Impact, stated honestly
Two measurements, and they say different things:
- Entry count: unambiguous and large — 2307 → 417 on the screen above. The redundant work and the retained objects are real.
- CPU: small in our workload — one Instruments
--cpu run per side over a 160-instance stress scene showed the JS thread at 2914ms → 2794ms and 2129ms → 2082ms, which is inside our suite's run-to-run spread. So we can't claim a CPU win from our own numbers.
That bounds what removing it buys us, and says nothing about a workload with more distinct class sets, more configs per element, or a slower device. Reporting the defect and both numbers rather than the flattering one — you're better placed to judge what it's worth on the workloads the library targets.
Happy to open a PR if the approach looks right.
Summary
useCssElementderives its style config per component instance, but the resolved-style cache is keyed on that config's object identity. Because every shippedcomponents/*.tsxwrapper passes a module-constantmapping, N mounted elements produce N distinct config identities carrying identical values — so N identical elements each get their ownstylesFamilyentry, their own sorted rule array and their own observable, where one would do.Measured on a real screen: 2307 cache entries where 417 suffice (5.5×).
styled()does not have this problem — it derives the config once at module scope. Both call sites are in the same file, ten lines apart.Environment
react-native-css3.0.7 (currentlatest), and verified still present onmainSteps to reproduce
stylesFamilyis not reachable from any public export (./nativere-exportsapionly;./native-internalpublishesroot/style-collection/variables), so observing it needs a one-line instrumentation in the package's own source.In
src/native/styles/index.ts, after the family is created:In any RN app built against that source, render many elements sharing one class list:
Read the count once the tree has mounted.
Isolating comparison — swap the rewritten
Viewforstyled(RNView, { className: { target: "style" } }), which derives its config once at module scope, and re-read the count on the identical tree. Nothing else changes: same class list, same rules, same element type.Expected result
The count reflects the number of distinct class lists on screen. 150 elements sharing one class list contribute one entry, because the cache exists to make resolution O(distinct class sets) rather than O(instances).
Actual result
The count scales with the number of instances.
On a screen of 150 identical controls (four styled elements each, plus surrounding chrome):
stylesFamilyentries3.0.7as publishedmappingToConfigmemoised on the mapping identity1890 redundant entries, each a separately sorted rule array and a retained observable over the same
calculateProps, held for as long as the tree is mounted.(417 rather than 1 is the honest floor for a real screen — it carries many distinct class lists, and e.g.
ScrollViewlegitimately contributes two configs. What collapses is precisely the per-instance duplication.)Root cause
The cache key (
src/native/react/rules.ts:300) hashes the config by object identity, via aweakFamilythat assigns a fresh number to each new object:The rules half of that key shares correctly —
StyleCollection.styles(className)is keyed by the class string.The config half does not. In
src/native/api.tsx, the two entry points disagree:and every shipped wrapper takes the second path with a module-constant mapping (
src/components/View.tsx:11,18):mappingToConfigis pure inmapping, so it returns the same value every time and a different identity every time. The cache is defeated by an identity that always carries the same value.Proposed fix
Memoise the derived config on the mapping identity — every call site the library itself ships already passes a stable module constant:
Notes:
useCssElement'suseStatecan stay or go — withmappingToConfigmemoised it returns the shared array either way.WeakMapsimply never hits), so this cannot make any call site worse.state.configsis read-only in its consumers (iterated forsource/target), so sharing one array across components is safe.Impact, stated honestly
Two measurements, and they say different things:
--cpurun per side over a 160-instance stress scene showed the JS thread at 2914ms → 2794ms and 2129ms → 2082ms, which is inside our suite's run-to-run spread. So we can't claim a CPU win from our own numbers.That bounds what removing it buys us, and says nothing about a workload with more distinct class sets, more configs per element, or a slower device. Reporting the defect and both numbers rather than the flattering one — you're better placed to judge what it's worth on the workloads the library targets.
Happy to open a PR if the approach looks right.