Skip to content

fix(native): subscribe a component's style effect once it commits - #485

Open
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/effects-wait-for-commit
Open

YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/effects-wait-for-commit

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

useNativeCss reads a component's styles during render with state.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 setState from inside the other component's render, which React reports as "Cannot update a component while rendering a different component".

updateRules also 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: observeReads subscribes the reads, releases whatever the committed render no longer read, and answers moved when a read changed since the render, which re-renders once. A second layout effect releases everything on unmount. updateRules no 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 from stylesFamily while 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.

getStyledProps now 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:

  • a sibling that sets vw while it renders: the element commits with the new width and React reports nothing - on main it reports the cross-component update;
  • an element inside a Suspense boundary that never resolves: vw has no observers right after the render and a later change reaches nothing - on main the discarded element stays subscribed;
  • a class change rendered in a transition that suspends: the committed element keeps following vw - on main it is left unsubscribed and keeps a stale width;
  • a mount computes the element's styles once - twice on main;
  • a viewport change that moves no rule, on an element mounted once, after StrictMode's replay and after <Activity> hid and showed it: nothing renders and no style is computed - on main each one renders the element, and without getOrInsert the last two do.

src/__tests__/native/reactivity-dependencies.test.tsx covers 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.

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
YevheniiKotyrlo force-pushed the fix/effects-wait-for-commit branch from 854c6c5 to 36dc8ad Compare October 16, 2026 10:06

This branch has not been deployed

No deployments
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