Skip to content

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

Open
YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/rule-effect-waits-for-commit
Open

YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/rule-effect-waits-for-commit

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

updateRules cleans the rule effect up and subscribes it to everything the rule pass reads - each class's rule list and the media, pseudo-class and container observables testRule reads through state.ruleEffectGetter - while a render runs. When React throws that render away (a class change rendered in a transition that suspends), the mounted element is left on the discarded render's rules and off the ones it still shows, so a media query on the committed class stops applying when the viewport changes.

The initializer then cleans the rule effect up, and the mount effect resubscribes it to a snapshot of what the initializer read (#466), running the pass again on a replay or when a snapshot moved.

Solution

The rule pass reads through recordReads(), the getter #485 adds, and the state it returns carries what it read (ruleReads). A layout effect makes the rule effect observe exactly those reads once the state commits: observeReads subscribes them, releases what the committed state no longer read, and answers moved when a read changed since the pass, which runs the pass again. The unmount releases both effects. A pass that keeps the same rules still returns the same state unless it read other observables. testRule takes the element's key beside the getter, since the recording getter does not identify the element.

That replaces the mount effect's catch-up: a first commit, StrictMode's replay and an <Activity> that shows the element again all observe the reads the state carries, so the dependency snapshots cleanupEffect takes, hasChangedDependencies and the committed flag go. #466's reactivity-activity.test.tsx passes unchanged.

The rule effect computes its update from the committed state and sets it as a value. An updater would be computed eagerly from the queue's lastRenderedState, which a render-phase update writes even in a render React then discards: after the discarded class change, the update was computed from the discarded state, compared equal to it and bailed out. A pass that changes nothing sets nothing, so a resize that moves no media query renders nothing.

Tests

src/__tests__/native/reactivity-lifecycle.test.tsx:

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), #463 (native/react/rules.ts), #482 (native/react/rules.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.
updateRules unsubscribed the rule effect from the previous rules and
subscribed it to every rule and condition it read while a render ran, so
a re-render React discards (a suspended transition) left a mounted
element on the discarded render's rules and off the ones it still shows:
a media query on the committed class stopped applying.

The rule pass now reads through a recording getter, and the state it
produces carries what it read. A layout effect makes the rule effect
observe exactly that once the state commits, releases what it no longer
reads, and runs the pass again when a read moved since. A pass that
keeps the same rules keeps the state unless it read other observables.
testRule takes the element's key beside the getter, since the getter no
longer identifies the element.

This replaces the mount effect's catch-up: a first commit, a StrictMode
replay and a re-shown Activity all observe the reads the state carries,
so the dependency snapshots cleanupEffect took, hasChangedDependencies
and the committed flag go.

The rule effect computes its update from the committed state and sets
it as a value. An updater is computed eagerly from the state React last
rendered, which a render it discards still writes, so after a discarded
class change the update was computed from the discarded state and bailed
out. A pass that changes nothing sets nothing, so a resize that moves no
rule renders nothing.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/rule-effect-waits-for-commit branch from cf1c880 to 1caa1c4 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