fix(native): one resolved-style cache key per distinct input set - #434
Open
YevheniiKotyrlo wants to merge 1 commit into
Open
fix(native): one resolved-style cache key per distinct input set#434YevheniiKotyrlo wants to merge 1 commit into
YevheniiKotyrlo wants to merge 1 commit into
Conversation
YevheniiKotyrlo
force-pushed
the
fix/mapping-config-identity
branch
from
August 19, 2026 17:51
c877b35 to
7bc057a
Compare
YevheniiKotyrlo
force-pushed
the
fix/mapping-config-identity
branch
from
August 19, 2026 19:58
7bc057a to
18b5d2b
Compare
`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
force-pushed
the
fix/mapping-config-identity
branch
from
August 20, 2026 04:55
18b5d2b to
152b5ad
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #433.
The defect
stylesFamilyis keyed bygenerateStateHash, which hashes weak keys by object identity — so the cache shares exactly when equal inputs arrive as one object: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
mappingToConfigis pure inmapping, 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.
familyandweakFamilycache on truthinesshashKeyFamilyreturnshashKeyCount++, so the first weak key hashed is assigned0, reads back as a miss, and never settles: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.
familyreturns the entry it holds and ignores therulesthe 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:<View className="text-sm" />End to end on
main:.probe-redand.probe-blueboth hash to4u1, 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 behindhoverFamily,getRuleVariationandhashKeyFamily. The function body is untouched.Cache on presence —
if (!value)becomesif (value === undefined). Of the twelvefamily/weakFamilycall sites,hashKeyFamilyis 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.sortneeds 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 aSetbuilt in render order; joining rather than folding makes distinct key sets distinct strings.MODandPRIMEhave no remaining reader.The loop's inherited
if (!key) continuegoes 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:WeakKeyadmits no falsy value, and all four key sources are objects by construction. What changes is that an out-of-domain key now fails at theWeakMap, rather than either throwing later incalculatePropsor 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.configsis always a key — and the two parameters no caller passes go at the same time.familygainssize()beside its existingdeleteandclear, because a cache whose size cannot be read is one whose central invariant cannot be regression-tested.familyandstylesFamilyare not themselves exported — butrootVariablesanduniversalVariablesarefamilyinstances published through./native-internal, so they gain the method too. Additive, and consistent with thedeleteandclearalready on them.What it measures
50 identical elements, this repo's jest environment:
mainThe residual
2is the keyhashKeyFamilyassigns0.A physical Android device (Xiaomi Mi MIX 3), 150 identical radio controls, reading
stylesFamilythrough 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
stylesFamilyis never released on unmount —stylesObs.cleanupis 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:
mainThe 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
--cpurun 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.freezecannot 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 intogetStyledProps's config loop turns it red with the exact diff.Three consequences:
An array
targetis 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:
useStatealready 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 theWeakMapand raisesInvalid value used as weak map keyfrom insidereactivity— naming neitherstyled()nor the argument. The predicate is deliberately narrower than thatWeakMapcontract: 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.entriesthrew only fornullandundefined; 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-legalstyled(C, {})does that too and this PR does not change it. Two consequences follow: on thestyled()path the throw lands at module evaluation rather than at render, and web'sstyledstill 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:sort((a,b)=>a-b)Float64Array.sort()The cost is the comparator, not the sort and not the string.
Array.prototype.sortcannot 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.
Float64Arrayrather thanInt32Array:hashKeyCountis 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 outsideWeakKeyis 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:
Verification
yarn typecheckandyarn lintboth exit 0. Full suite, same runner, both sides measured together:f70c402)Exactly
+19, and all nineteen pass. The 29 failures are identical on the unmodified base and are entirely the threesrc/__tests__/babel/suites (17 + 10 + 2):prettierfails to load underbabel-plugin-testerin my Windows checkout, so those suites never reach an assertion. Nothing outsidesrc/__tests__/babel/fails on either side.One correction to the issue: its reproduction counts entries via
stylesFamily.keys(), which does not exist onmain—family()exposesdeleteandclearonly. The tests above need no instrumentation.