Repository navigation
Per-instance config identity defeats the resolved-style cache (2307 entries where 417 suffice) #433
Description
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-css3.0.7 (currentlatest), and verified still present onmain- 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'suseStatecan stay or go — withmappingToConfigmemoised it returns the shared array either way.- A caller passing a fresh mapping literal per render is unaffected (the
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:
- 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
--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.
Activity
github-actions commentedon Aug 19, 2026
🤖 Auto-triage
Status: CONFIRMED
Type: bug
Version: v5 (3.0.7 /
main)Findings
Reproduced the root cause in a Jest unit test.
mappingToConfig(mapping)is pure but returns a fresh array on every call, anduseCssElementinvokes it per component instance viauseState(() => mappingToConfig(mapping))(src/native/api.tsx:90) — so N instances sharing the same module-constant mapping produce N distinctconfigsarrays. BecausegenerateStateHashkeys thestylesFamilycache onstate.configsobject identity (src/native/react/rules.ts:300), identical elements each get their own cache entry, defeating the intended sharing.What I tested
src/native/api.tsx,src/components/View.tsx,src/native/react/rules.ts,src/native/react/useNativeCss.ts, andsrc/native/styles/index.tsto verify every code reference in the report.styled()(line 54) correctly derivesconfigsonce at module scope, whileuseCssElement(line 90) derives per-instance — the asymmetry the report calls out.mappingToConfig(m) === mappingToConfig(m)for the same mapping object. It fails on currentmain: two calls return arrays thatserialize to the same stringbut are not identity-equal. This is a direct, mechanical demonstration of the cache-key defeat.Next steps
mappingToConfigon the mapping identity via aWeakMap) is safe and minimal: pure-in-mapping,state.configsis read-only in consumers, and callers passing fresh mapping literals per render simply miss the cache without regression.mappingToConfigidentity, or astylesFamily-size assertion once instrumentation is added) to lock this in.This is an automated triage. See auto-triage.yml.
YevheniiKotyrlo commentedon Aug 19, 2026
Opened #434 with the fix, plus a correction to my own report above.
The reproduction snippet I gave counts entries via
stylesFamily.keys(), andfamily()onmainexposes onlydeleteandclear— nokeys. That came from an instrumented build on my side; following it verbatim throws. The PR adds asize()tofamilyso the invariant is assertable without instrumentation, and ships the reproduction as three test files that run onmainas it stands.Two things I got wrong by understating them:
There is a second, independent cause.
familyandweakFamilycache on the result being truthy, andhashKeyFamilyhands outhashKeyCount++— so the first weak key ever hashed is assigned0and never caches. Its hash changes between lookups. Memoising the config alone takes 50 identical elements from 50 entries to 2, not 1; the residual is that key.It also propagates into
getRuleVariation. That clone is keyed on the config identity too, so the same defect produced aStyleRuleclone per element rather than per mapping.Reproduced on a second platform since filing — a physical Android device (Mi MIX 3), 150 identical radio controls: 1785 entries → 270.
Nothing here changes rendered output; it changes how many times the library computes and retains it.