Skip to content

fix(native): one resolved-style cache key per distinct input set - #434

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/mapping-config-identity
Open

fix(native): one resolved-style cache key per distinct input set#434
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/mapping-config-identity

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #433.

The defect

stylesFamily is keyed by generateStateHash, which hashes weak keys by object identity — so the cache shares exactly when equal inputs arrive as one object:

// src/native/react/rules.ts
const stylesObs = stylesFamily(generateStateHash(state, rules), rules);
const keys = [state.configs, ...iterableKeys];

The rules half holds: StyleCollection.styles(className) is keyed by the class string. Three defects break the rest.

1. The config is minted per instance

// api.tsx:54 — styled(): once, at module scope. Shares.
const configs = mappingToConfig(mapping);
// api.tsx:90 — useCssElement(): once per INSTANCE. Does not.
const [config] = useState(() => mappingToConfig(mapping));

mappingToConfig is pure in mapping, so it returns the same value and a different identity every time — and every wrapper this library ships takes the second path with a module constant. N identical elements therefore hold N entries, each a separately sorted rule array and a retained observable. getRuleVariation's rule clone is keyed on the same identity, so that was per element rather than per mapping.

2. family and weakFamily cache on truthiness

let value = map.get(key);
if (!value) { value = fn(key, args); map.set(key, value); }

hashKeyFamily returns hashKeyCount++, so the first weak key hashed is assigned 0, reads back as a miss, and never settles:

generateHash([key]) x3, first object hashed:   "v"  "x"  "x"
generateHash([key]) x3, any later object:      "z"  "z"  "z"

3. The key is lossy, and a collision is silent

Folding every key's number into one number and printing it base-36 is a digest. family returns the entry it holds and ignores the rules the second caller brought, so two rule sets meeting on one string do not cost a cache miss — the second element renders the first element's styles.

(a ^ b) + (a + 31)(b + 31) is a coarse lattice over consecutive integers, so collisions concentrate where the library spends its time:

key shape current this PR
1 config + 1 rule — a bare <View className="text-sm" /> 31.99% (28792/90000) 0
all pairs over keys 0..2999 39.26% 0

End to end on main: .probe-red and .probe-blue both hash to 4u1, resolve to the same observable, and the red element renders {"color":"#00f"}. Over those 90000 inputs the fold held 61208 entries — 28792 of the "saved" entries were wrongly-rendered elements.

The three interact: fixing (1) and (2) re-numbers the keys and moves which pairs collide, so a fix leaving the key lossy trades a memory win for a rendering bug.

The fix

Derive one config per mapping, through this library's own weakFamily — already the derive-once-per-identity primitive behind hoverFamily, getRuleVariation and hashKeyFamily. The function body is untouched.

Cache on presenceif (!value) becomes if (value === undefined). Of the twelve family/weakFamily call sites, hashKeyFamily is the only one whose factory can return a falsy value, so this changes behaviour at exactly the broken site.

Key injectively — collect the numbers into a Float64Array, sort it, join. The typed array is what makes the ordering native: Array.prototype.sort needs a comparator to order numbers and calls it O(n log n) times, and on Hermes that callback is most of what the ordering costs (see Cost). Sorting preserves the order-independence the fold had, which the caller relies on because the rule set is a Set built in render order; joining rather than folding makes distinct key sets distinct strings. MOD and PRIME have no remaining reader.

The loop's inherited if (!key) continue goes with them. A skipped key is erased from the value, so [a, b] and [a, falsy, b] encoded to one string — the same collision class, surviving inside the rewrite meant to remove it. Nothing reachable is affected: WeakKey admits no falsy value, and all four key sources are objects by construction. What changes is that an out-of-domain key now fails at the WeakMap, rather than either throwing later in calculateProps or silently taking the colliding entry, depending on which of the two arrived at the cache first.

generateStateHash's empty-string sentinel goes with it: a join makes that value collidable with a real state. It was unreachable — state.configs is always a key — and the two parameters no caller passes go at the same time.

family gains size() beside its existing delete and clear, because a cache whose size cannot be read is one whose central invariant cannot be regression-tested. family and stylesFamily are not themselves exported — but rootVariables and universalVariables are family instances published through ./native-internal, so they gain the method too. Additive, and consistent with the delete and clear already on them.

What it measures

50 identical elements, this repo's jest environment:

configuration entries
main 50
falsy-cache fix only 50
config derived once per mapping only 2
all three 1

The residual 2 is the key hashKeyFamily assigns 0.

A physical Android device (Xiaomi Mi MIX 3), 150 identical radio controls, reading stylesFamily through a temporary log: 1785 → 270-271 (the count settles a single entry apart between runs; 1785 is stable across all of them). The iOS measurement in the issue is 2307 → 417 on an iPhone 12 Pro Max; different screens, so the ratios are not comparable, but neither is a rounding effect.

The cache stops growing without limit

stylesFamily is never released on unmount — stylesObs.cleanup is reached only when a component's observable CHANGES. That is a pre-existing defect and this PR does not fix it.

What changes is whether a second visit ADDS to what is held. A remount used to produce fresh config identities, fresh hashes and fresh entries while the old ones stayed:

mount cycle 1 2 3
main 2 4 6 grows without limit
this PR 2 2 2 flat

The bound moves from the number of MOUNTS — unbounded in a navigating app — to the number of distinct class lists, which is a property of the stylesheet.

Rendered output is unchanged

Every element resolves the same declarations to the same values; what changes is how many times the library computes and retains them. The 1048 other tests in the suite, which assert rendered props extensively, are unchanged.

CPU is small and not claimed: one Instruments --cpu run per side over a 160-instance stress scene showed the JS thread at 2914ms → 2794ms and 2129ms → 2082ms, inside that suite's run-to-run spread.

Is sharing one config safe?

While each element minted its own config, a write to one was private; sharing makes "the consumers only read it" load-bearing.

Object.freeze cannot check that here — a write to a frozen object throws only in strict mode, and in this repo's jest environment it does not throw at all, so a freeze-based test passes whatever the code does. The test compares the config's VALUE across a render instead, and is mutation-proven: injecting a write into getStyledProps's config loop turns it red with the exact diff.

Three consequences:

  • An array target is aliased to the caller's array, before and after. Deriving once changes how many config objects exist, never that aliasing.

  • A mutated mapping is no longer re-derived. This narrows behaviour rather than changing it: useState already froze the derivation per instance, so a mutation reached only newly mounted elements — one mapping meaning two things depending on mount order.

  • A non-object mapping is refused by name. styled() is reached from untyped JavaScript, where the type forbidding one is absent, and without the guard a primitive reaches the WeakMap and raises Invalid value used as weak map key from inside reactivity — naming neither styled() nor the argument. The predicate is deliberately narrower than that WeakMap contract: a function and an unregistered symbol are both valid weak keys, and both are refused, because neither is a mapping.

    It narrows six input kinds, which is worth your judgement rather than mine. Object.entries threw only for null and undefined; a number, boolean, function or symbol derived an empty config, and a string derived one config per character. None of those rendered anything usable — an empty config drops every prop, though note the type-legal styled(C, {}) does that too and this PR does not change it. Two consequences follow: on the styled() path the throw lands at module evaluation rather than at render, and web's styled still no-ops on all six, so the platforms now disagree for input the types forbid.

Cost

Instrumented over a 300-element render the key count is min 5 · p50 10 · p90 15 · max 16, so the band that matters is 5 to 16 keys.

The numbers below are ratios between engines measured inside one run, not absolute timings, captured on a physical Android device through a dev client whose clocks are thermally clamped. A uniform clamp scales every candidate in a run by the same factor, which is checkable rather than assumed: across two independent captures the ratios agreed to within 5.8% everywhere and 1.5% at 11 keys and above, while the absolutes they were computed from differed by more.

Warm lookups — hash plus Map.get — relative to the fold this replaces:

keys fold sort((a,b)=>a-b) Float64Array.sort()
5 1.00 1.33 1.22
11 1.00 1.64 1.10
20 1.00 2.20 1.19
50 1.00 2.75 1.27

The cost is the comparator, not the sort and not the string. Array.prototype.sort cannot order numbers without one, and that comparator is a JS function the engine calls O(n log n) times. A typed array sorts numerically in native code with no callback at all. Isolating the ordering step on the same device, at 11 keys: sort((a,b)=>a-b) 7354 ns, Float64Array.sort() 3319 ns, no ordering at all 195 ns — so the callback is most of what the ordering costs, and removing it recovers most of that.

This is also where the engines disagree, which is worth stating because it decides the shape rather than merely the constant. On V8 the difference barely registers and an insertion sort is fastest at these sizes; on Hermes the same insertion sort loses badly by 50 keys, and the typed array wins at every size. The version above is the one that is never wrong on either.

Float64Array rather than Int32Array: hashKeyCount is unbounded and an int32 wraps silently at 2^31, which would map two distinct key sets onto one string — reintroducing exactly the collision this PR removes, in a form that only appears after two billion keys. Verified across 20 000 distinct key sets that the typed encoding agrees with the comparator sort on every one, stays order-independent, and collides on none.

Order-independence buys nothing measurable on the screens I have. Keying without the sort produced 271 distinct entries against the sorted key's 271 on a 150-control screen, and 119 against 119 on the shell alone. Those are counts, so no clock is involved. It is also not correctness — "p-4 bg-red" and "bg-red p-4" reach the same rules, so an order-dependent key costs a duplicate entry rather than wrong styles. I kept the sort because "no two class lists on these two screens were permutations of each other" is a fact about my stylesheet and not about your users'; at 1.10x it is no longer a trade worth making.

Tests

Nineteen across five files, each written red first.

  • style-cache-sharing.test.tsx — 50 identical elements produce one entry; different class lists keep distinct entries.
  • config-identity.test.ts — two consumers of one mapping produce one key; differing mappings do not; a state hash is never the empty string; a non-object mapping is refused; a mutated mapping is not re-derived.
  • family.test.ts — the factory-once-per-key contract for both families with a falsy result.
  • state-hash.test.ts — distinct key sets never share a key; a key set encodes the same however ordered; a key outside WeakKey is refused rather than erased; the encoding is integers rather than floats or exponents.
  • style-cache-bounds.test.tsx — nothing writes to the shared config; a shared entry survives being read; 25 mount cycles hold at one count.

Reverting each fix sends exactly the expected tests red:

state failing entries for 50 elements counts over 25 cycles
all three 0 of 19 1 1 distinct
injective key reverted 1 1 1 distinct
+ config memo reverted 4 50 25 distinct
all three reverted 7 50 25 distinct

Verification

yarn typecheck and yarn lint both exit 0. Full suite, same runner, both sides measured together:

base (f70c402) this branch
passed 1022 1041
failed 29 29
skipped 21 21

Exactly +19, and all nineteen pass. The 29 failures are identical on the unmodified base and are entirely the three src/__tests__/babel/ suites (17 + 10 + 2): prettier fails to load under babel-plugin-tester in my Windows checkout, so those suites never reach an assertion. Nothing outside src/__tests__/babel/ fails on either side.

One correction to the issue: its reproduction counts entries via stylesFamily.keys(), which does not exist on mainfamily() exposes delete and clear only. The tests above need no instrumentation.

@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): equal inputs produce one resolved-style cache key fix(native): equal inputs produce one resolved-style cache key, and distinct inputs never share one Aug 19, 2026
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/mapping-config-identity branch from c877b35 to 7bc057a Compare August 19, 2026 17:51
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): equal inputs produce one resolved-style cache key, and distinct inputs never share one fix(native): one resolved-style cache key per distinct input set Aug 19, 2026
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/mapping-config-identity branch from 7bc057a to 18b5d2b Compare August 19, 2026 19:58
`generateStateHash` keys the resolved-style cache on weak-key identity, so it
shares only when equal inputs arrive as one object. Three defects break that.

**The config is minted per instance.** `styled()` derives it once at module scope;
`useCssElement` derives one per instance from a module constant — same value, fresh
identity, so N identical elements each hold their own entry, sorted rule array and
observable. `getRuleVariation`'s rule clone is keyed on that identity too, so it was
per element rather than per mapping. Deriving through `weakFamily` keys the config
on the mapping. A mapping that is not an object is refused by name rather than
through the `Invalid value used as weak map key` a primitive would otherwise raise
from inside `reactivity`; the predicate is narrower than that WeakMap contract,
since a function is a valid weak key and still not a mapping.

**`family` and `weakFamily` cache on truthiness.** A factory returning `0` is re-run
on every lookup, and `hashKeyFamily` hands out `hashKeyCount++` — so the first weak
key hashed never settles. Of twelve call sites it is the only one whose result can
be falsy, so caching on presence changes nothing else.

**The key is lossy.** Folding N numbers into one and printing it base-36 is a digest,
and `family` returns the entry it holds while ignoring the rules the second caller
brought — so a collision renders one element's styles on another. Over one config
plus one rule, the commonest shape, the fold collides 31.99% of the time. Sorting
and joining is exact and keeps order-independence. The numbers are collected into a
`Float64Array` so the sort is native: ordering them with `Array.prototype.sort` needs
a comparator, and that callback is most of what ordering costs on Hermes — 1.10x the
fold at the median key count against 1.64x. Float64 rather than Int32 because the key
counter is unbounded and an int32 wraps silently at 2^31. Two inherited lines go with the
fold: `generateStateHash`'s empty-string sentinel — unreachable, and collidable once
the key is a join — together with the two parameters no caller passes, and the
loop's `if (!key) continue`, which erased a member from a value whose whole point is
that it erases nothing.

`family` gains `size()` so the invariant is assertable. It reaches `rootVariables`
and `universalVariables`, which are `family` instances published through
`./native-internal` — additive, beside the `delete` and `clear` already on them.

50 identical elements: 50 entries before, 1 after. A 150-control screen on a
physical Android device: 1785 → 270-271. Re-entering a screen grew the cache without
limit (2, 4, 6 …) and is now flat; entries are still never released on unmount,
which is a separate defect.

19 tests across five files, each red first.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/mapping-config-identity branch from 18b5d2b to 152b5ad Compare August 20, 2026 04:55
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.

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

1 participant