Skip to content

fix, perf: key style cache by unique hash - #568

Open
sammoore wants to merge 3 commits into
nativewind:mainfrom
sammoore:fix/mapping-config-identity
Open

sammoore wants to merge 3 commits into
nativewind:mainfrom
sammoore:fix/mapping-config-identity

Conversation

@sammoore

@sammoore sammoore commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Supersedes #434, carrying its commit (by @YevheniiKotyrlo) and replacing its exact string key with a numeric hash.

Problem

The resolved-style cache is keyed by generateStateHash, built from the identities of an element's config and matched rules, so elements share an entry exactly when those are the same objects. Two defects on main break that:

  1. The config is created per instance. useCssElement derives a config from a module-level mapping on every mount, so identical elements never share a key: 50 identical elements hold 50 cache entries, each with its own sorted rule array and observable.
  2. The hash collides. It folds the key ids with XOR and a product and prints the result in base 36. Small sequential ids collide structurally, about 1 in 3 for a config plus one rule. family returns whatever entry it holds for a key, so on a collision an element renders another element's styles (seen in practice: a vertical-align: top element rendered object-fit: contain).

Fixing 1 makes 2 worse: once identical elements share one config, they all land on the same key, so one collision mis-styles every one of them instead of a single element.

Solution

  • The config is derived once per mapping through weakFamily. A mapping that is not an object is refused by name, rather than failing inside the WeakMap with a message that names neither styled() nor the argument.
  • The key becomes a 53-bit integer: each id goes through Murmur3's 32-bit finalizer into two values, each summed across the set. Summing is order-independent, so there is no sort, and no string is built per lookup.
    • fix(native): one resolved-style cache key per distinct input set #434 originally used an exact key (ids sorted and joined into a string). This hash is not exact, but at 53 well-mixed bits a collision is negligible: about n² / 2^54 for n live entries (≈ 6e-11 at 1,000). Over ~5M test sets it had 0 collisions, against ~26k for the current hash.
    • Cost on Hermes is on par with the exact join for 1–8 rules (3.31 µs vs 3.21 µs per lookup) and about 2× faster at 45 rules (44.6 µs vs 85.7 µs), since a joined string grows with rule count and id size.
  • family gains size(), so the one-entry-per-input-set invariant is assertable.

Tests

  • config-identity.test.ts: one mapping gives one config, differing mappings keep different keys, a mapping that is not an object is refused by name, and a mutated or per-call mapping behaves correctly.
  • state-hash.test.ts: distinct key sets never share a hash, order does not matter, a key outside WeakKey is refused, the hash is a safe integer, ~180k realistic key sets (a config plus every pair from 600 keys, and 46-key sets) never collide, and the config is always part of the key.
  • style-cache.test.tsx: identical elements share one entry, differing elements keep distinct entries, and elements sharing an entry and its config each see the value nativeStyleMapping took.
  • style-cache-collisions.test.tsx: a 40×40 grid of distinct class combinations, all mounted at once, each rendering its own styles.

Checked by breaking rules.ts on purpose: swapping the current hash back in fails state-hash and the grid test; hashing only the first rule fails only the grid test; truncating the hash to 32 bits fails state-hash.

Verification

On macOS with Node 24: yarn lint, yarn typecheck and yarn test (86 suites, 1356 tests) pass.

Merge order

#436 builds on #434's commit, which this PR carries, and merges cleanly on top, so this lands first.

YevheniiKotyrlo and others added 3 commits November 6, 2026 18:02
generateStateHash keys the resolved-style cache on weak-key identity, so it
shares only when equal inputs arrive as one object, and two defects broke
that on main.

The config was minted per instance: useCssElement derived one from a module
constant on every mount, so N identical elements each held their own cache
entry, sorted rule array and observable. It is now derived once per mapping
through weakFamily, and a mapping that is not an object is refused by name
rather than failing inside the WeakMap.

The key was lossy: folding the key numbers into one and printing it base-36
is a digest, and family returns the entry it holds whatever rules the second
caller brought, so a collision rendered one element's styles on another. The
numbers are now sorted and joined, which is exact and order-independent, in
a Float64Array so the sort is native and the unbounded key counter does not
wrap. family gains size() so the invariant is assertable.
Replace the sorted-and-joined string key with a fixed-size numeric hash:

- Each weak-key id goes through Murmur3's 32-bit finalizer, giving two
  independent 32-bit values that are each summed across the set. Addition is
  order-independent, so there is no sort, and the key is one number however
  many rules an element matches.
- The key is 32 + 21 of those bits: a 53-bit integer, exactly representable,
  used directly as the `stylesFamily` key, so no string is built per lookup.
- Weak-key ids start at 1, since mix32(0) is 0 and an id of 0 would add
  nothing to one half of the hash.

Against the original fold-and-base36 hash, over the same inputs:

  config + every pair of ids 2..2001   (1,999,000 sets)  original 26,293 / new 0
  config + every triple of ids 2..201  (1,313,400 sets)  original 26,648 / new 0
  random sets, 1-46 rules, ids to 3M   (2,000,000 sets)  original 1      / new 0

Unlike the exact join, a 53-bit hash can collide in principle: about
n^2 / 2^54 for n live cache entries (about 6e-11 at 1,000).

Cost on Hermes 0.12, per lookup (key + Map lookup, rules from a Set), against
the sorted-and-joined string key:
  1-8 rules, small ids:     join 3.21 us, this 3.31 us (noise-level)
  45 rules, 7-digit ids:    join 85.7 us, this 44.6 us
A joined string grows with rule count and id size (up to ~320 characters for
a heavily styled element in a long session); this key does not.

Tests:
- state-hash: the string-format check becomes a safe-integer check, plus a
  config-membership check and a test hashing ~180k realistic key sets (a
  config plus every pair from 600 keys, and 46-key sets) with no collisions.
- style-cache-collisions: renders a 40x40 grid of distinct class
  combinations and checks every element renders its own styles.
Both collision tests fail with the original hash swapped back in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants