Skip to content

fix(native): write colorScheme.set through to Appearance - #415

Draft
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/color-scheme-appearance-projection
Draft

fix(native): write colorScheme.set through to Appearance#415
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/color-scheme-appearance-projection

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 — read Appearance. An app calling the documented setter moves the first and not the second, and renders a light canvas under dark chrome.

import { colorScheme } from 'react-native-css/runtime';
import { Appearance } from 'react-native';

colorScheme.set('dark');
Appearance.getColorScheme();   // 'light' — useColorScheme() still reports light

src/web/api.tsx already intends to write through to Appearance; 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.

set(value) {
  Appearance.setColorScheme(value);
  colorSchemeObs.set(value);
},

And the class layer resolves the scheme the way colorScheme.get() already does. get() coalesces through Appearance to a definite value; conditions/media-query.ts read the raw observable. That observable holds null at rest and after set(null), so prefers-color-scheme: light and dark both failed while get() reported light, and the element fell through to its unconditional rule. Same defect as above, one function along, and reachable through the setter this PR changes:

value === (get(colorScheme) ?? Appearance.getColorScheme() ?? "light")

What the setter does not do, precisely

It updates React Native's Appearance cache. It does not itself notify RN's readers: useColorScheme is useSyncExternalStore(addChangeListener, getColorScheme), and RN's JS setColorScheme emits no event. On device the write round-trips — iOS sets overrideUserInterfaceStyle, which fires a trait change that RCTAppearance re-emits as appearanceChanged; Android's setDefaultNightMode reaches onConfigurationChanged — so a mounted useColorScheme() 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 with jest.fn(() => "light"), and the real one imports getColorScheme from ./Appearance directly 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 while colorScheme.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 Appearance rather than mirroring it, which is a change to how the observable is constructed and a separate question from this one.

Reading Appearance through on every observable get() 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, while run()'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:

revert the setter revert the media-query fallback
writes through to Appearance red green
class layer resolves like get() green red
set(null) hands the scheme back red green
an OS change repaints green red
an unresolvable scheme matches no query green green — the floor, see below

The write-through test asserts the argument passed to Appearance.setColorScheme, not the resulting cache. The earlier version checked colorScheme.get(), which passed under every mutation, because get() falls back to Appearance.getColorScheme() and either writer alone satisfies it.

The fixture is three-way — unconditional green, light blue, dark red — so "matched neither branch" is distinguishable from "matched light". The set(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-63 byte 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 guards Appearance.addChangeListener in reactivity.ts, which nothing else covered.

Appearance is faked because under the jest preset the real module takes its absent-native branch — every read is null and setColorScheme is a no-op — so it cannot express the behaviour under test.

Full suite, typecheck and lint measured against a pristine-main baseline on the same machine: 1053 passing, no new failures. The two src/__tests__/babel/* suites fail identically on both.

Note

This changes the observable behaviour of a public API, which CONTRIBUTING.md asks 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:76 calls Appearance.setColorScheme(name), and react-native-web@0.21.1 does not implement it — its Appearance exports exactly getColorScheme and addChangeListener. So that call is a TypeError for the first caller.

Nothing in the repo can see it: src/web/api.tsx:8 imports Appearance from "react-native", so TypeScript resolves RN's .d.ts, which does declare setColorScheme, and the swap to react-native-web happens at bundler resolution. There are no runtime tests under src/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.

`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.
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