Skip to content

fix(native): release resolved-style entries, and compute a derived observable once per change - #436

Open
YevheniiKotyrlo wants to merge 7 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/bounded-styles-cache
Open

fix(native): release resolved-style entries, and compute a derived observable once per change#436
YevheniiKotyrlo wants to merge 7 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/bounded-styles-cache

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Stacked on #434. Its commit is the first of the seven here, so review that one first — everything below is the diff against it, not against main.

GitHub's stacked pull requests do not support cross-fork stacks, so this has to target main and carries #434's commit as its first. To give you the layered view anyway I stacked the two branches in my fork: PR #2 there shows only the six new commits — 6 commits, 14 files, based on #434's branch — and that is the diff to review. (Plain compare if you would rather not leave a diff view.) If #434 lands first, this rebases onto it with nothing left over.

#434 made the resolved-style cache share correctly. This one makes it shrink, and fixes the reason it was cheap to leave growing: every consumer was recomputing its styles from scratch on every render anyway.

The cache was designed to release, and nothing reached the release

stylesFamily's factory already carries it — cleanup deletes the family entry once nothing observes the observable. The mechanism is correct and was unreachable:

// useNativeCss.ts — both effects share one Set
const observers = new Set<Effect>();
const ruleEffect: Effect = { observers, run:  };
const styleEffect: Effect = { observers, run:  };

// …and only one is ever detached
useEffect(() => () => cleanupEffect(state.ruleEffect), [state.ruleEffect]);

cleanupEffect removes the effect it was handed from each observable, then clears the shared set. The sibling is now unreachable and still registered on every observable it ever read. The observer count never reaches zero, so the entry is never deleted — and the dead component's run, which closes over its setState, stays reachable from the observable graph.

Running this PR's three release tests against #434's code, printing the count instead of asserting it:

after mount after unmount
one element 1 1
two elements sharing one entry 1 1
one element, 12 re-renders with an inline vars() 13 13

With this PR: 1 → 0, 1 → 1 → 0 (refcounted, so a shared entry survives its first leaver), and 13 → 1 → 0.

The third row is the one that matters in an app. vars() returns a fresh object per call, so style={vars({…})} hands the cache a new weak key on every render. Each render supersedes the last and every superseded entry is unobserved immediately — 13 entries for one element that was only ever mounted once.

Four things had to be true for the release to work:

  • Each effect owns its dependency set, and both are detached on unmount. Sharing saved one allocation per component and made detaching unexpressible.
  • updateRules detaches both. It passed only ruleEffect, so a component moving to a new observable left the old one holding an observer.
  • cleanup drops the reverse edge, or a component that superseded its entry still lists it as a dependency and its unmount walks back to release an entry it no longer uses — possibly one another component has since joined.
  • The delete is by identity, not by hash. deleteIf(hash, entry) — the hash may since map to a different observable, and deleting by hash alone destroys somebody else's live entry.

A derived observable recomputed on every read

if (!didInit) { value = init(getter, undefined); }

Nothing set didInit for a derived observable — only the static-init branch and set did. So a computed value was recomputed per read rather than per change. For stylesFamily that is calculateProps — the whole style resolution — on every render of every styled element, which is exactly the work the cache exists to avoid.

Latching it after the first compute is the whole change. Twenty reads of a derived observable: 21 computes before, 1 after. Recomputation on change is a different path and is untouched — a dependency notifies, effect.run re-runs the read function and re-assigns, subscribers are notified from there. The memo only removes the redundant work between those.

Timed at the library's own entry point rather than through a render, because an end-to-end render bench had a wider run-to-run spread than the entire contribution: getStyledProps 1952ns → 488ns, 4.0×, stable to about 5% across nine repetitions either side. That figure is Node — the repo's own jest environment — so treat the ratio as indicative and the compute count as the claim. On any engine the removed work is one full calculateProps per read.

Why the memo cannot ship on its own

A batch is a Set, and iterating one does not revisit a member it has already passed. An effect notified a second time during a drain — because a derived observable it depends on recomputed after the effect ran — was dropped.

That was survivable while every get recomputed: whenever the effect ran, it pulled fresh values out of its dependencies. It stops being survivable the moment a derived observable memoises, because the effect then reads the value its dependency held before the recompute and nothing runs it again. The stale value is permanent, not a transient glitch.

The reachable shape is vw → { stylesObs, rootVariables } → stylesObs — a style that reaches a viewport unit directly through the unit resolver and again through a root variable whose media query tests a width. Ordinary CSS:

@media (prefers-color-scheme: dark) and (min-width: 600px) { :root { --gap: 16 } }
:root { --gap: 8 }
.box { width: 50vw; padding: var(--gap) }

After a resize the box keeps padding: 8 until the next viewport change — and because stylesFamily hands one memoised object to every consumer, a component mounted after the rotation gets the stale value too.

Both drains — the Dimensions handler and StyleCollection.inject — now call one exported drainObservableBatch, which removes a member before running it, so a re-notification re-enqueues rather than landing on an entry the iteration has passed. Mutation-proven: with the previous for (const effect of batch) the diamond test observes 2,10; with the work list, 2,20.

This is why the six commits are one PR. The memo is the win, the drain fix is what makes the memo correct, and the release is what makes the cache bounded. Landing any of them alone is worse than landing none.

A read that throws leaves the observable as it found it

get registered the caller before computing, so a read function that throws — resolve does, on an unknown function — left the caller subscribed to an observable that never initialised: on the list, owed nothing, counted by every "does anyone observe me" question. Computing first means a failure leaves the observable exactly as the call found it.

Found by fuzzing rather than by reading: across 40 000 randomised operation sequences the previous ordering left 5 subscribers attached to an observable that never initialised, and this ordering leaves none.

The cap is a backstop, not the mechanism

family takes an optional maxSize, and stylesFamily passes 2048. Releasing on unmount is what keeps the cache proportional to what is on screen; the cap is what makes exhaustion unrepresentable if a future path ever leaks again. It sits far above any real screen — a 150-control stress screen measures 271 entries on a device — so a correctly-behaving app never reaches it.

Eviction is safe by construction rather than by policy: a miss re-derives the value from the rules the caller brought, so the worst an evicted entry costs is the work of rebuilding it, and a consumer still holding a previously-returned value keeps a live reference and is unaffected.

A bounded family evicts the least recently read, not the oldest. That ordering is the point here: the workload that fills this cache is a churn of single-use keys arriving beside a small set read on every render, and insertion order would discard exactly the entries worth keeping. Renewing on a hit costs a delete plus a set on the read path — ~23ns against a ~265ns key derivation, under a tenth of the work it protects. Omitting maxSize keeps a family unbounded, which is what every other call site does, so nothing else changes behaviour.

Tests

Twenty-eight across nine files, each written red first.

  • style-cache-release.test.tsx — release on unmount; a shared entry survives its first leaver; a superseded entry goes while the component stays mounted.
  • style-cache-release-reactive.test.tsx — the entry still updates through media queries, variables and colour scheme after the release wiring changed.
  • style-cache-release-safety.test.tsx — an animated viewport-unit class survives a dimension change; unmounting never deletes another component's live entry; a superseded component never deletes the entry it left behind.
  • style-cache-stress.test.tsx — mounting and unmounting a tree repeatedly returns the cache to empty; an entry shared across many siblings survives until the last one goes; per-render key churn stays flat and drains completely.
  • observable-memo.test.ts — computes once across twenty reads; still recomputes on change (pinning the count, not toBeGreaterThan, which the old behaviour also satisfied); a conditional dependency is picked up the first time its branch is taken; a static observable is unaffected.
  • observable-batch-drain.test.ts — the diamond above; an empty drain is a no-op.
  • observable-init-failure.test.ts — a read that throws registers no observer; a successful read still registers.
  • native-style-mapping-idempotence.test.tsx — draining a cached entry twice produces the same props.
  • family.test.ts — eviction order, deleteIf identity semantics, and that an unbounded family is unchanged.

Verification

yarn typecheck and yarn lint both exit 0. Full suite (yarn test), same runner, measured against #434's head:

#434 (152b5ad) this branch
passed 1067 1095
failed 3 3
skipped 21 21

Exactly +28, all passing. The 3 failures are identical on both sides and on unmodified main: they are the babel import-plugin's relative-import cases, which are the Windows backslash-vs-forward-slash path defect I reported in #390 and are green on Linux and macOS. Nothing outside src/__tests__/babel/ fails.

Beyond the suite, this branch is running in my app: the full patch renders a 150-control stress screen on a physical Android device (Xiaomi Mi MIX 3) with no exceptions, composed with 26 other local contributions to this library.

`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.
…mounts

The resolved-style cache is never released. Not on unmount, not when an entry is
superseded, not ever — a plain `<View className="x" />` that mounts and unmounts
leaves its entry in the map for the life of the process, and every unmounted
component stays reachable from every observable it read, through the `run`
closure that holds its `setState`.

The release was written and never reached. Three things had to be true for it to
work and none of them was.

**The dependency graph is one-directional.** `observable.get(effect)` records the
observable's view of its subscriber and not the subscriber's view of what it
reads, so `effect.observers` is always empty. `cleanupEffect` walks exactly that
set to detach a subscriber from everything it read, so it has always iterated
nothing: the whole cleanup path is dead code. Recording the reverse edge in `get`
is what makes it live.

**Both effects shared one dependency Set** — "to improve memory usage", one
allocation per component. It also made detaching unexpressible: `cleanupEffect`
removes the effect it was handed from each observable and then clears the shared
set, so the sibling stays registered on every observable it ever read. Each
effect now owns its set and both are cleaned.

**The observable was never told.** An entry can only be dropped at the moment its
last observer leaves, which is a fact only the detaching code knows. `Effect`
gains an optional `cleanup`, `cleanupEffect` calls it after detaching, and the
supersede path passes both effects rather than one.

`family` gains an optional `maxSize`, and `stylesFamily` takes one. That is a
backstop rather than the fix — the release is what keeps the cache proportional
to what is mounted — and it evicts the least recently READ, because the workload
that would fill it is a churn of single-use keys beside a small set read every
render, where insertion order discards exactly the entries worth keeping.
Renewing on a hit costs ~23ns against a ~265ns key derivation. Eviction is safe
by construction: a miss re-derives from the rules the caller brought.

Measured, a component rendering with an inline `vars()` — which returns a fresh
object per call, so each render mints a new key: 13 entries over 12 renders
before, 1 after, 0 once it unmounts.

Six tests, each red first and each proven red again with the fix reverted.
…elease by identity

Follow-up to the commit before it, which recorded the reverse edge into the wrong
Set. Two reviewers with opposing lenses found the same cause independently.

`observable()` gave its internal effect the SAME Set object as the observable's
subscriber list, so recording `effect.observers.add(obs)` put a source observable
into the derived observable's SUBSCRIBER list — where `notify` walks it. It
typechecks because `Observable` structurally satisfies `Effect`.

Two measured consequences. An entry reading `vw`, an undefined `var()`, or a
`:root` variable under `prefers-color-scheme` could never reach zero observers,
so the release did nothing for the commonest real styles — the previous commit's
own measurement inverts with one extra declaration: `.churn { color: var(--x) }`
gave 13 entries before and 1 after, `.churn { width: 50vw; color: var(--x) }`
gave 13 and 13. And `w-[50vw] animate-spin` plus a dimension change recursed to
`RangeError: Maximum call stack size exceeded`, because the keyframes observable
notifies unconditionally while the styles observable never compares equal.

Three changes:

- The internal effect gets its OWN dependency set, leaving `obs.observers` a pure
  subscriber list. Everything above is downstream of this one line.
- `cleanup` drops the reverse edge as well as the forward one. Leaving it meant a
  component that superseded its own entry still listed it as a dependency, so its
  unmount walked back and released an entry another component had since joined —
  reproduced directly: A supersedes, B joins the old entry, A unmounts, B's live
  entry is destroyed.
- The release is by IDENTITY. `family` gains `deleteIf`, because the entry closes
  over the hash it was created under and that hash may since map to a different
  observable, after an eviction and a rebuild or after the stale-reference shape
  above. Deleting by hash alone destroys whatever took the key.

Eleven more tests: the release for an entry that reads another observable (a
viewport unit, an undefined variable, a dark-mode root variable), that such an
entry stays reactive while mounted, that a root-variable change does not notify
unrelated `colorScheme` subscribers, the animated-viewport-unit crash, the two
identity cases, and `deleteIf` pinned directly.

Reverting the per-effect Set turns 5 of 8 red. The reverse-edge drop and the
identity check are individually redundant for the stale case — either alone
covers it — so `deleteIf` is pinned on `family` rather than through a revert that
would not demonstrate it.
… per read

`getStyledProps` costs 1952ns per call and 488ns after this — 4.0x, on the path
every styled element pays on every render.

`observable()`'s `get` recomputed whenever `didInit` was falsy, and nothing ever
set it for a DERIVED observable: only the static-init branch and `set` did. So a
computed value was recomputed per read rather than per change. For the
resolved-style cache that is `calculateProps` — the whole style resolution — on
every render of every styled element, which is precisely the work the cache
exists to avoid.

Latching it after the first compute is the whole change. Recomputation on CHANGE
is a different path and is untouched: a dependency notifies, `effect.run` re-runs
the read function and re-assigns `value`, and subscribers are notified from
there. The memo only removes the redundant work between those.

Measured with the library's own entry points rather than a render, because an
end-to-end bench is dominated by the test renderer — its run-to-run spread was
wider than the entire contribution being measured. `getStyledProps` is stable to
about 5% across nine repetitions either side.

Three tests: a derived observable computes once across twenty reads, it still
recomputes when a dependency changes, and a static observable is unaffected. The
first is red before this at 21 computes for 21 reads.
…ns again

A batch is a `Set`, and iterating one does not revisit a member it has already
passed. So an effect notified a SECOND time during a drain — because a derived
observable it depends on recomputed after the effect ran — was dropped.

That was survivable while every `get` recomputed: whenever the effect ran it
pulled fresh values out of its dependencies. It is not survivable now that a
derived observable memoises, because the effect reads the value its dependency
held BEFORE the recompute and nothing runs it again. The stale value is
permanent, not a transient glitch.

The reachable shape is `vw -> { stylesObs, rootVariables } -> stylesObs`: a style
that reaches a viewport unit directly through the unit resolver AND again through
a root variable whose media query tests a width. Ordinary CSS:

    @media (prefers-color-scheme: dark) and (min-width: 600px) { :root { --gap: 16 } }
    :root { --gap: 8 }
    .box { width: 50vw; padding: var(--gap) }

After a resize the box keeps `padding: 8` until the next viewport change — and
because `stylesFamily` hands one memoised object to every consumer, a component
mounted after the rotation gets the stale value too.

Both drains — the `Dimensions` change handler and `StyleCollection.inject` — now
call one exported `drainObservableBatch`, which removes a member BEFORE running
it so a re-notification re-enqueues rather than landing on an entry the iteration
has passed.

Two tests. The diamond is mutation-proven: with the previous `for (const effect
of batch)` it observes `2,10` — the derived value from before the change — and
`2,20` with the work list. The memo suite gains a conditional-dependency test
(the first compute takes a branch that never reads B, and opening the gate still
picks up B's CURRENT value, because `effect.run` registers and pulls in the same
pass), and its change test now pins the recompute COUNT rather than asserting it
grew — the loose form was satisfied by the per-read recompute being removed, so
it passed on both sides and guarded nothing.
…a cached entry

`nativeStyleMapping` drains keys OUT of the resolved style object and writes them
onto real props — `delete source[key]`, then `props[path] = value`. It mutates.

That was harmless while every read of the styles observable recomputed: each
render got a fresh object and the mutation died with it. The memo makes the
object it mutates the CACHED one, shared by every consumer of that entry and
surviving every render.

It is idempotent — the second pass finds the key already drained and continues —
and every shape measures correct. But nothing enforced that, and "it happens to
be a no-op" is not a property to leave resting on an accident while the memo
makes it load-bearing.

Two tests, on the one shipped component with a `target: false` mapping: ten
re-renders read the same mapped value, and a second component joining the same
cache entry sees it too. A drain that stopped being idempotent would surface as a
MISSING prop on the second consumer rather than a wrong one, which is the harder
failure to notice.
`get` registered the caller's effect BEFORE running the read function, so a
compute that threw left a subscriber attached to an observable that had never
initialised — on the observer list, owed nothing, and counted by every question
that asks whether anything observes this. `resolve` throws on an unknown
function, so the shape is reachable rather than theoretical.

Computing first makes the failure leave no trace: the throw propagates to the
caller and the observable is exactly as it was before the call. The reverse edge
moves with it, so a failed read cannot leave a dangling dependency either.

It also closes the one residual an exhaustive review found in the memo two
commits back. Over 813,615 no-throw operation sequences that review recorded zero
divergences from the pre-memo behaviour and zero firings of the guard the memo
was thought to supersede; every firing it could produce had the same shape — a
read that threw, registering an observer, followed by a later successful compute.
With the registration moved, that sequence cannot arise.

Two tests: a read that throws leaves both directions of the graph untouched and
the observable still usable once the condition clears, and a successful read
still registers.
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.

1 participant