fix(native): write colorScheme.set through to Appearance - #415
Draft
YevheniiKotyrlo wants to merge 2 commits into
Draft
fix(native): write colorScheme.set through to Appearance#415YevheniiKotyrlo wants to merge 2 commits into
YevheniiKotyrlo wants to merge 2 commits into
Conversation
`colorScheme.set()` moved only this library's observable, so the class layer and React Native's own readers disagreed. `useColorScheme()` and every prop-valued colour read `Appearance`; `dark:` utilities read the observable. An app calling the documented setter moved one and not the other, and rendered a light canvas under dark chrome. Writing both in the one call is the whole fix. It does not try to make a direct `Appearance.setColorScheme()` visible to the class layer: that writer emits no event, and the class layer is push-based, so nothing short of a notification can move an already-mounted element.
There were two sources of truth for the scheme with different null semantics. `colorScheme.get()` coalesces through Appearance to a definite value; the class layer read the raw observable. The observable holds null at rest and after `set(null)`, so `prefers-color-scheme: light` and `dark` both failed while `get()` reported light — the element fell through to its unconditional rule. That is the same two-readers-disagree defect this branch is named for, one function along, and it is reachable through the setter the branch just changed. The tests are rewritten around what each one actually pins. The repaint case duplicated media-query.test.tsx byte for byte and is gone; the OS-event case stays, relabelled as the guard it is for Appearance.addChangeListener. The write-through assertion now checks the argument rather than the resulting cache, which passed under every mutation because get() falls back to Appearance. The fixture is three-way so "matched neither branch" is distinguishable from "matched light" — the failure above is invisible to a two-colour fixture.
YevheniiKotyrlo
marked this pull request as draft
August 15, 2026 14:35
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
colorScheme.set()moves this library's observable and nothing else, so the two halves of an app's theming disagree.The class layer (
dark:utilities,@media (prefers-color-scheme)) reads the observable. React Native's own readers —useColorScheme()and every prop-valued colour — readAppearance. An app calling the documented setter moves the first and not the second, and renders a light canvas under dark chrome.src/web/api.tsxalready intends to write through toAppearance; native does not. (That web call has its own problem — see the last section.)Fix
Two halves, both about the same thing — the library had two sources of truth for the scheme and they disagreed.
The setter writes both.
And the class layer resolves the scheme the way
colorScheme.get()already does.get()coalesces throughAppearanceto a definite value;conditions/media-query.tsread the raw observable. That observable holdsnullat rest and afterset(null), soprefers-color-scheme: lightanddarkboth failed whileget()reportedlight, and the element fell through to its unconditional rule. Same defect as above, one function along, and reachable through the setter this PR changes:What the setter does not do, precisely
It updates React Native's
Appearancecache. It does not itself notify RN's readers:useColorSchemeisuseSyncExternalStore(addChangeListener, getColorScheme), and RN's JSsetColorSchemeemits no event. On device the write round-trips — iOS setsoverrideUserInterfaceStyle, which fires a trait change thatRCTAppearancere-emits asappearanceChanged; Android'ssetDefaultNightModereachesonConfigurationChanged— so a mounteduseColorScheme()consumer converges rather than updating synchronously. I have not measured that on hardware, and no test here can: RN's own jest setup replaces the hook withjest.fn(() => "light"), and the real one importsgetColorSchemefrom./Appearancedirectly rather than through the namespace object a test can substitute.What this deliberately does not do
It does not make a direct
Appearance.setColorScheme()move an already-mounted element. Measured on this branch: the element stays on its old colour whilecolorScheme.get()reports the new scheme.The reason is narrower than "no event is emitted". The observable is seeded once at import and never re-read, so a fresh mount after a direct write is stale too — no notification would fix that, and no pull would either. Making it work means the class layer subscribing to
Appearancerather than mirroring it, which is a change to how the observable is constructed and a separate question from this one.Reading
Appearancethrough on every observableget()is the obvious way to try, and it is a trap worth recording:get()on a function-init observable assigns the cached value without notifying, whilerun()'s equality guard compares against that same cache. A read landing between a change and its notification swallows the notification, permanently. I measured that as two elements with the same class rendering different colours. It does not apply to the observable as it stands — seeded with a value, it is static — but it is why the read-through is not the shortcut it looks like.Tests
Five, in
src/__tests__/native/color-scheme-appearance.test.tsx, and every one is killed by at least one of the two mutations:get()set(null)hands the scheme backThe write-through test asserts the argument passed to
Appearance.setColorScheme, not the resulting cache. The earlier version checkedcolorScheme.get(), which passed under every mutation, becauseget()falls back toAppearance.getColorScheme()and either writer alone satisfies it.The fixture is three-way — unconditional green,
lightblue,darkred — so "matched neither branch" is distinguishable from "matched light". Theset(null)defect is invisible to a two-colour fixture. The last row is the floor: it holds under both mutations by design, and would catch a fallback that silently picked a side on a platform reporting nothing.One test was deleted rather than kept: the previous "repaints a mounted element" case duplicated
src/__tests__/native/media-query.test.tsx:41-63byte for byte, and the mutation it caught was caught by that pre-existing test too. The OS-event test stays, relabelled — it does not guard this change, it guardsAppearance.addChangeListenerinreactivity.ts, which nothing else covered.Appearanceis faked because under the jest preset the real module takes its absent-native branch — every read isnullandsetColorSchemeis a no-op — so it cannot express the behaviour under test.Full suite, typecheck and lint measured against a pristine-
mainbaseline on the same machine: 1053 passing, no new failures. The twosrc/__tests__/babel/*suites fail identically on both.Note
This changes the observable behaviour of a public API, which
CONTRIBUTING.mdasks be discussed in an issue first. Happy to move it to one if you would rather — I opened it as a PR because the change and its reproduction are easier to read as a diff.Separately, the web half of this API is broken
src/web/api.tsx:76callsAppearance.setColorScheme(name), andreact-native-web@0.21.1does not implement it — itsAppearanceexports exactlygetColorSchemeandaddChangeListener. So that call is aTypeErrorfor the first caller.Nothing in the repo can see it:
src/web/api.tsx:8importsAppearancefrom"react-native", so TypeScript resolves RN's.d.ts, which does declaresetColorScheme, and the swap to react-native-web happens at bundler resolution. There are no runtime tests undersrc/web/**at all. It predates this change (aeb0085), and I have not touched it — flagging it because it is the same public API, and because the premise here, one setter moves every reader, is not dischargeable on web at all:dark:there is a real CSS media query the browser owns.Happy to send that as its own PR if you want it fixed rather than just noted.