Repository navigation
fix(native): subscribe a component's rule effect once it commits - #486
Open
YevheniiKotyrlo wants to merge 4 commits into
Open
YevheniiKotyrlo wants to merge 4 commits into
YevheniiKotyrlo wants to merge 4 commits into
Conversation
YevheniiKotyrlo
marked this pull request as ready for review
October 16, 2026 06:55
This was referenced Oct 16, 2026
Open
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:05
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
force-pushed
the
fix/rule-effect-waits-for-commit
branch
from
October 16, 2026 10:06
cf1c880 to
1caa1c4
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
updateRulescleans 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 observablestestRulereads throughstate.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:observeReadssubscribes them, releases what the committed state no longer read, and answersmovedwhen 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.testRuletakes 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 snapshotscleanupEffecttakes,hasChangedDependenciesand the committed flag go. #466'sreactivity-activity.test.tsxpasses 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:mainthe committed class's media query stops applying;mainthe discarded class's rule list keeps 1 observer;Activityhid while its rules changed - these pass onmainand on fix(native): subscribe a component's style effect once it commits #485 too, and hold the new path to its behaviour;<Activity>showed it again, holds this path too: without fix(native): subscribe a component's style effect once it commits #485'sgetOrInsertthe last two render here as well.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.