fix(native): release resolved-style entries, and compute a derived observable once per change - #436
Open
YevheniiKotyrlo wants to merge 7 commits into
Open
Conversation
`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.
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.
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
mainand 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 —cleanupdeletes the family entry once nothing observes the observable. The mechanism is correct and was unreachable:cleanupEffectremoves 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'srun, which closes over itssetState, stays reachable from the observable graph.Running this PR's three release tests against #434's code, printing the count instead of asserting it:
vars()With this PR:
1 → 0,1 → 1 → 0(refcounted, so a shared entry survives its first leaver), and13 → 1 → 0.The third row is the one that matters in an app.
vars()returns a fresh object per call, sostyle={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:
updateRulesdetaches both. It passed onlyruleEffect, so a component moving to a new observable left the old one holding an observer.cleanupdrops 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.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
Nothing set
didInitfor a derived observable — only the static-init branch andsetdid. So a computed value was recomputed per read rather than per change. ForstylesFamilythat iscalculateProps— 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.runre-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:
getStyledProps1952ns → 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 fullcalculatePropsper 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
getrecomputed: 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:After a resize the box keeps
padding: 8until the next viewport change — and becausestylesFamilyhands one memoised object to every consumer, a component mounted after the rotation gets the stale value too.Both drains — the
Dimensionshandler andStyleCollection.inject— now call one exporteddrainObservableBatch, 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 previousfor (const effect of batch)the diamond test observes2,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
getregistered the caller before computing, so a read function that throws —resolvedoes, 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
familytakes an optionalmaxSize, andstylesFamilypasses 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
maxSizekeeps 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, nottoBeGreaterThan, 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,deleteIfidentity semantics, and that an unbounded family is unchanged.Verification
yarn typecheckandyarn lintboth exit 0. Full suite (yarn test), same runner, measured against #434's head:152b5ad)Exactly
+28, all passing. The 3 failures are identical on both sides and on unmodifiedmain: 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 outsidesrc/__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.