Repository navigation
fix(native): subscribe a component's style effect once it commits - #485
Open
YevheniiKotyrlo wants to merge 3 commits into
Open
YevheniiKotyrlo wants to merge 3 commits into
YevheniiKotyrlo wants to merge 3 commits into
Conversation
This was referenced Oct 16, 2026
Open
YevheniiKotyrlo
marked this pull request as ready for review
October 16, 2026 06:08
YevheniiKotyrlo
marked this pull request as draft
October 16, 2026 07:31
YevheniiKotyrlo
marked this pull request as ready for review
October 16, 2026 08:04
A render subscribed the component's style effect to every observable it
read, and updateRules unsubscribed it from the previous styles, so a
render React discards (a suspended transition, a Suspense fallback) left
the effect on the observables of a component that never mounted. Every
later change to those styles ran that component's effect ("Can't perform
a React state update on a component that hasn't mounted yet"), and the
discarded render could detach a mounted component from the styles it
still shows.
A render now reads through a getter that subscribes nothing and records
each read beside the observable's version, and getStyledProps takes that
getter, so every read obeys the same gate. After every commit the style
effect observes exactly what that render read, releases what it no
longer reads, and re-renders when a read moved since the render.
Observables carry a version, and a computed value keeps the sources it
was computed from with their versions, so a read without a subscriber
is served from cache until a source changes, and a first subscriber
keeps the value instead of computing it again.
The sources a computation had read when it threw described no value, so a later read found them unchanged and returned the previous value without computing. A throwing computation now forgets its sources, and the next read computes again.
…effects StrictMode's effect replay and a re-shown <Activity> clean an element's effects up and run them again. The cleanup's unsubscribe drops the element's styles entry from the cache, and the commit then re-subscribes the entry the element still holds, so its hash maps to nothing. The next rule pass that keeps the element's rules finds no entry, builds another and renders; releasing the old entry then deletes the new one, which shares its hash, so every later such pass does the same. An entry that gains its first observer goes back under its hash when the hash holds nothing.
YevheniiKotyrlo
force-pushed
the
fix/effects-wait-for-commit
branch
from
October 16, 2026 10:06
854c6c5 to
36dc8ad
Compare
This branch has not been deployed
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.
Problem
useNativeCssreads a component's styles during render withstate.stylesObs?.get(state.styleEffect), which subscribes the style effect - and through the styles, every viewport, scheme and variable observable they read - before React commits the render. When React throws that render away (a sibling suspends, a transition is interrupted), nothing removes the subscription: every later change runs the effect of a component that never mounted, React warns "Can't perform a React state update on a component that hasn't mounted yet", and the observables keep the effect for the life of the app.A change between a render and its commit - a sibling that resizes the viewport while it renders, a colour-scheme change - runs the uncommitted component's
setStatefrom inside the other component's render, which React reports as "Cannot update a component while rendering a different component".updateRulesalso unsubscribes the style effect from the previous styles while it renders, so a mounted element whose class change is rendered in a transition that suspends keeps showing the old styles and stops following them.Solution
A render no longer subscribes. It reads through
recordReads(), a getter that records each observable beside its version. After every commit, a layout effect makes the style effect observe exactly what that render read:observeReadssubscribes the reads, releases whatever the committed render no longer read, and answersmovedwhen a read changed since the render, which re-renders once. A second layout effect releases everything on unmount.updateRulesno longer touches the style effect, and the mount effect catches up only the rule effect, as #466 left it.React cleans an element's effects up and runs them again for StrictMode's replay and for an
<Activity>that shows it again, so that release drops the element's styles entry fromstylesFamilywhile the element still holds it. An entry that gains its first observer goes back under its hash when the hash holds nothing (family.getOrInsert). Without that, the next rule pass that keeps the element's rules builds another entry and re-renders, and releasing the old entry deletes the new one, which shares its hash.Reading without subscribing needs a cache, because a computed observable dropped its value once nothing observed it. Observables now carry a version, and a computed one keeps the sources it was computed from with their versions, so a read with no subscriber is served from cache until a source changes, and a first subscriber attaches to those sources instead of computing again. A computation that throws forgets its sources, so the next read computes again. As a side effect a mount computes an element's styles once, where
updateRules' own unsubscribed read and the render's read each computed them.getStyledPropsnow takes the getter the render reads through, so a read added beside it is recorded the same way.The rule effect still subscribes while a render runs; this PR changes the style effect only.
Tests
src/__tests__/native/reactivity-lifecycle.test.tsx:vwwhile it renders: the element commits with the new width and React reports nothing - onmainit reports the cross-component update;vwhas no observers right after the render and a later change reaches nothing - onmainthe discarded element stays subscribed;vw- onmainit is left unsubscribed and keeps a stale width;main;<Activity>hid and showed it: nothing renders and no style is computed - onmaineach one renders the element, and withoutgetOrInsertthe last two do.src/__tests__/native/reactivity-dependencies.test.tsxcovers the cache and the versions: a computed value read without a subscriber computes once until a source changes, a first subscriber keeps it, a value released by its last subscriber is not recomputed until a source changes, a chain follows a change at its root, a version moves only with the value, a computation that threw computes again, and recorded reads subscribe nothing until a commit observes them, report a change since the read, and release what the commit no longer read.Verification
On Ubuntu with the workflow's steps - build, lint, typecheck,
yarn test,node --test node/__tests__/*.mjs, both library builds, the web export, the unstaged-changes checks and commitlint - everything passes.Not run: the iOS and Android example builds.
Merge order
It also shares lines with #412 (
native/reactivity.ts), #434 (native/react/useNativeCss.ts,native/reactivity.ts), #436 (__tests__/native/reactivity-dependencies.test.tsx,native/react/useNativeCss.ts,native/reactivity.ts,native/styles/index.ts), #484 (native/react/rules.ts,native/react/useNativeCss.ts); whichever lands second rebases.