Skip to content

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

Open

Description

@YevheniiKotyrlo

Before submitting a new issue

  • I tested using the latest version of the library, as the bug might be already fixed.
  • I tested using a supported version of react native.
  • I checked for possible duplicate issues, with possible answers.

Bug summary

useCssElement derives its style config per component instance, but the resolved-style cache is keyed on that config's object identity. Every shipped components/*.tsx wrapper passes a module-constant mapping, so N mounted elements produce N distinct config identities carrying identical values, and N identical elements each get their own stylesFamily entry, their own sorted rule array and their own observable, where one would do. styled() derives its config once at module scope and shares one entry.

The cache key (src/native/react/rules.ts) hashes the config by identity, through a weakFamily that numbers each new object:

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

The two entry points in src/native/api.tsx derive it differently:

// line 53, styled(): once, at module scope
const configs = mappingToConfig(mapping);

// line 89, useCssElement(): once per instance
const [config] = useState(() => mappingToConfig(mapping));

and every shipped wrapper takes the second path with a module-constant mapping (src/components/View.tsx):

const mapping = {
  className: "style",
} satisfies StyledConfiguration<typeof RNView>;
// ...
return useCssElement(RNView, props, mapping);

mappingToConfig is pure in mapping, so it returns the same value and a new identity on every call.

On a screen of 150 identical controls, four styled elements each plus the surrounding chrome, in a release build of React Native 0.86 with Fabric and Hermes on an iPhone 12 Pro Max with iOS 26.6, 3.0.7 held 2307 entries, and 417 with mappingToConfig memoised on the mapping; 417 is the floor of a screen with many distinct class lists. A Mi MIX 3 with 150 identical radio controls held 1785, and 270 with #434. Rendered output is the same either way; the cost is the work and the retained objects. One Instruments CPU run per side over a 160-instance scene put the JS thread at 2914 ms against 2794 ms and 2129 ms against 2082 ms, inside my suite's run-to-run spread, so I can claim no CPU win from my own workload.

#434 fixes it, with these tests.

Library version

main at 2421ea7 (0.0.0-nightly.2421ea7), and 3.0.7

Environment info

System:
  OS: Windows 11 10.0.26300
  CPU: "(32) x64 AMD Ryzen 9 9950X3D 16-Core Processor          "
  Memory: 155.26 GB / 255.67 GB
Binaries:
  Node:
    version: 26.12.1
    path: C:\Program Files\nodejs\node.EXE
  Yarn:
    version: 4.9.2
    path: C:\Users\<user>\AppData\Roaming\npm\yarn.CMD
  npm:
    version: 12.2.1
    path: C:\Program Files\nodejs\npm.CMD
  Watchman: Not Found
SDKs:
  Android SDK:
    API Levels:
      - "33"
      - "35"
      - "35"
      - "35"
      - "36"
      - "37"
    Build Tools:
      - 35.0.0
      - 36.0.0
      - 36.1.0
      - 37.0.0
    System Images:
      - android-26 | Google APIs Intel x86_64 Atom
      - android-28 | Google APIs Intel x86_64 Atom
      - android-29 | Google APIs Intel x86_64 Atom
      - android-30 | Google APIs Intel x86_64 Atom
      - android-31 | Google APIs Intel x86_64 Atom
      - android-32 | Google APIs Intel x86_64 Atom
      - android-33 | Google APIs Intel x86_64 Atom
      - android-34 | Google APIs Intel x86_64 Atom
      - android-35 | Google APIs Intel x86_64 Atom
      - android-36.1 | Google Play Intel x86_64 Atom
      - android-36.1 | Pre-Release 16 KB Page Size Google Play Intel x86_64 Atom
      - android-37.0 | Google APIs Intel x86_64 Atom
    Android NDK: Not Found
  Windows SDK: Not Found
IDEs:
  Android Studio: AI-262.9437.185.2621.16467767
  Visual Studio: Not Found
Languages:
  Java:
    version: 17.0.20.1
    path: C:\Program Files\Microsoft\jdk-17.0.20.101-hotspot\bin\javac.EXE
  Ruby: Not Found
npmPackages:
  "@react-native-community/cli": Not Found
  react:
    installed: 19.2.3
    wanted: 19.2.3
  react-native:
    installed: 0.86.3
    wanted: 0.86.3
  react-native-windows: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: Not found
  newArchEnabled: Not found
iOS:
  hermesEnabled: Not found
  newArchEnabled: Not found

Steps to reproduce

stylesFamily exposes no count on main, so the reproducer branch is main at 2421ea7 with family() given a size(), and one test added, src/__tests__/native/style-cache-per-instance.test.tsx.

  1. Clone the reproducer branch and install:
    git clone --branch repro/433-per-instance-config-identity https://github.com/YevheniiKotyrlo/react-native-css
    cd react-native-css
    yarn install
  2. Run yarn jest src/__tests__/native/style-cache-per-instance.test.tsx.

src/native/reactivity.ts:

diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts
index a32ac64..ad4309c 100644
--- a/src/native/reactivity.ts
+++ b/src/native/reactivity.ts
@@ -173,6 +173,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();
       },

src/__tests__/native/style-cache-per-instance.test.tsx:

// The resolved-style cache holds one entry per distinct class list, so 50 identical elements resolve one style set
import { View as RNView } from "react-native";

import { render } from "@testing-library/react-native";
import { View } from "react-native-css/components/View";
import { registerCSS } from "react-native-css/jest";
import { styled } from "react-native-css/native";

import { stylesFamily } from "../../native/styles";

const StyledView = styled(RNView, { className: { target: "style" } });

test.each([
  ["View", View],
  ["styled(View)", StyledView],
])(
  "50 identical %s elements hold one resolved-style cache entry",
  (_name, Component) => {
    registerCSS(`.probe { color: red; width: 10px; }`);
    stylesFamily.clear();

    render(
      <>
        {Array.from({ length: 50 }, (_unused, index) => (
          <Component key={index} className="probe" />
        ))}
      </>,
    );

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

Expected: 50 identical elements hold one resolved-style cache entry, through the shipped View as through styled(View).

Actual: the shipped View holds 50, one per element, and styled(View) holds one.

The run's output on main at 2421ea7, Windows 11, Node 26.12.1
FAIL src/__tests__/native/style-cache-per-instance.test.tsx (8.787 s)
  × 50 identical View elements hold one resolved-style cache entry (26 ms)
  √ 50 identical styled(View) elements hold one resolved-style cache entry (6 ms)
  ● 50 identical View elements hold one resolved-style cache entry
    expect(received).toBe(expected) // Object.is equality
    Expected: 1
    Received: 50
      28 |     );
      29 |
    > 30 |     expect(stylesFamily.size()).toBe(1);
         |                                 ^
      31 |   },
      32 | );
      33 |
      at toBe (src/__tests__/native/style-cache-per-instance.test.tsx:30:33)
Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        9.86 s
Ran all test suites matching /src\\__tests__\\native\\style-cache-per-instance.test.tsx/i.

Reproducible example repository

https://github.com/YevheniiKotyrlo/react-native-css/tree/repro/433-per-instance-config-identity

Activity

  1. github-actions commented on Aug 19, 2026

    @github-actions

    🤖 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, and useCssElement invokes it per component instance via useState(() => mappingToConfig(mapping)) (src/native/api.tsx:90) — so N instances sharing the same module-constant mapping produce N distinct configs arrays. Because generateStateHash keys the stylesFamily cache on state.configs object identity (src/native/react/rules.ts:300), identical elements each get their own cache entry, defeating the intended sharing.

    What I tested

    • Read src/native/api.tsx, src/components/View.tsx, src/native/react/rules.ts, src/native/react/useNativeCss.ts, and src/native/styles/index.ts to verify every code reference in the report.
    • Confirmed styled() (line 54) correctly derives configs once at module scope, while useCssElement (line 90) derives per-instance — the asymmetry the report calls out.
    • Wrote a minimal Jest test asserting mappingToConfig(m) === mappingToConfig(m) for the same mapping object. It fails on current main: two calls return arrays that serialize to the same string but are not identity-equal. This is a direct, mechanical demonstration of the cache-key defeat.

    Next steps

    • The proposed fix (memoise mappingToConfig on the mapping identity via a WeakMap) is safe and minimal: pure-in-mapping, state.configs is read-only in consumers, and callers passing fresh mapping literals per render simply miss the cache without regression.
    • Reporter offered to open a PR; the analysis is thorough and the fix is a good first cut. Worth accepting.
    • Worth adding a regression test (either mappingToConfig identity, or a stylesFamily-size assertion once instrumentation is added) to lock this in.

    This is an automated triage. See auto-triage.yml.

  2. added
    auto-triagedIssue has been automatically triaged by the auto-triage workflow
    bugSomething isn't working
    confirmedBug reproduced and confirmed by triage
    on Aug 19, 2026
  3. YevheniiKotyrlo commented on Aug 19, 2026

    @YevheniiKotyrlo
    ContributorAuthor

    Opened #434 with the fix, plus a correction to my own report above.

    The reproduction snippet I gave counts entries via stylesFamily.keys(), and family() on main exposes only delete and clear — no keys. That came from an instrumented build on my side; following it verbatim throws. The PR adds a size() to family so the invariant is assertable without instrumentation, and ships the reproduction as three test files that run on main as it stands.

    Two things I got wrong by understating them:

    There is a second, independent cause. family and weakFamily cache on the result being truthy, and hashKeyFamily hands out hashKeyCount++ — so the first weak key ever hashed is assigned 0 and 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 a StyleRule clone 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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