Skip to content

Per-instance config identity defeats the resolved-style cache (2307 entries where 417 suffice) #433

Description

@YevheniiKotyrlo

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.

  1. 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);
  2. 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" />
        ))}
      </>
    );
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    auto-triagedIssue has been automatically triaged by the auto-triage workflowbugSomething isn't workingconfirmedBug reproduced and confirmed by triage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions