Skip to content

fix(native): make colorScheme.set move every reader of the scheme - #415

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/color-scheme-appearance-projection
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/color-scheme-appearance-projection

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On main, colorScheme.set() moves this library's observable and nothing else:

  • useColorScheme() and every store built on Appearance.addChangeListener keep the old scheme. An app calling the setter therefore renders its dark: classes on one scheme and its prop-valued colours on the other.
  • The class layer reads the raw observable, which holds null after a follow-the-system request. prefers-color-scheme: light and dark then both fail while colorScheme.get() reports a scheme, and the element falls through to its unconditional rule.

Solution

  • set() writes through to Appearance.setColorScheme and announces the scheme on the appearanceChanged device event.
    • Appearance.setColorScheme writes its cache without notifying a listener.
    • appearanceChanged is the event Appearance itself handles, so it performs its own cache write and change emit, as it does for an OS change.
    • Only a resolved scheme that changes is announced.
  • A follow-the-system request reaches Appearance as "unspecified". React Native 0.82.0 to 0.84.1 caches that request verbatim, and the platform echoes nothing because the resolved scheme did not change. So on that band the scheme in force before the hand-back is put back in the cache.
  • colorScheme.get() and prefers-color-scheme resolve the held value through resolveColorScheme. For any value that is not a scheme, it answers the scheme Appearance reports, then light (MQ5 §12.5.1).

Tests

  • color-scheme-appearance.test.tsx runs the installed React Native 0.86 Appearance against a fake native module that behaves like both platforms: it applies a write later and reports the scheme in force. It covers:
    • the write-through, the announcement and its echo, and a repeated set
    • a subscribed reader, and both layers agreeing
    • a hand-back through null and through "unspecified"
    • a held request, and an OS change
  • color-scheme-appearance-cache-rules.test.tsx runs the hand-back scenarios against the three cache-write rules across the react-native >= 0.81 peer range, including a hand-back made through Appearance itself.
  • On main, 13 of the 31 cases fail. Each of 9 mutations fails its own cases.

Verification

On Windows with Node 26:

  • yarn lint clean
  • yarn typecheck clean
  • yarn test --coverage: every failure also fails on main on this machine (the four babel cases)
  • yarn build clean
  • yarn example expo export --platform web exported

Device evidence, before and after: #415 (comment)

Known limits

  • This is native. On web, colorScheme.set calls Appearance.setColorScheme, which react-native-web 0.21 does not have, so it throws on main; what overriding the scheme should mean on web is a separate question.

Merge order

It also shares lines with #431 (native/api.tsx), #459 (native/conditions/media-query.ts); whichever lands second rebases.

Base

Re-written on main (a5002c5), which installs React Native 0.86.3; this PR was written against 0.81.4. The installed-module suites now run 0.86's Appearance, so the suite that transcribed 0.86 is gone, and the census covers the 0.81 rule. main maps "unspecified" to null in the observable, which this keeps.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft August 15, 2026 14:35
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): write colorScheme.set through to Appearance fix(native): make colorScheme.set move every reader of the scheme Aug 15, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review August 15, 2026 19:13
@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Outdated: this compared the August heads of #415 and #429. #415 is now re-written onto main (react-native 0.86), and #429 no longer merges into main cleanly.

The original note

Cross-referencing #415 and #429 — they merge with zero conflicts and the result does not compile. Flagging it now, because nothing will warn whoever lands the second one.

git merge-tree --write-tree reports no conflict at all. Running tsc --noEmit on that merged tree gives exactly one error, repo-wide:

src/native/api.tsx(83,31): error TS2345: Argument of type 'ColorSchemeName' is not
  assignable to parameter of type '…Appearance").ColorSchemeName'.
  Type '"unspecified"' is not assignable to type 'ColorSchemeName'.

The two halves are each correct alone:

On the merge, #429's wider union reaches a call react-native 0.81 types as 'light' | 'dark' | null | undefined, and "unspecified" is not a member.

Neither PR can pre-empt this, which is why it is a note rather than a fix on one of them. #415 cannot import src/color-scheme.ts — that file does not exist on main or on its branch. #429 has no Appearance.setColorScheme call in src/native/api.tsx to route, because on its branch that file has no setter.

The resolution is one line at merge time: route the native setter through the same seam the web one already uses, rather than calling Appearance directly.

// src/native/api.tsx
import { setAppearanceColorScheme } from "../color-scheme";
…
setAppearanceColorScheme(value);   // instead of Appearance.setColorScheme(value)

Measured: that takes the merged tree from one error to zero. The seam exists precisely because the one member the supported react-native range declares incompatibly is the "follow the system" write, so this is what it was built for.

Worth pairing with a compile-level guard, since the failure is a type error rather than a behaviour: the merged tree must typecheck. A runtime twin already exists on #429 at src/__tests__/native/color-scheme.test.tsx:74-76, which drives colorScheme.set("unspecified").

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Outdated: this compared the August heads of #415 and #429. #415 is now re-written onto main (react-native 0.86), and #429 no longer merges into main cleanly.

The original note

Following up on the cross-reference above with two measured additions, one of which is a trap worth naming explicitly.

An inline cast at the call site compiles, and is the wrong resolution

The obvious shortcut for the merge break is to widen the argument in place rather than route through the seam:

Appearance.setColorScheme(value as Parameters<typeof Appearance.setColorScheme>[0]);

That does take the merged tree to zero type errors, on both react-native ends of the peer range. It is still wrong, because it silences both arms of a union that disagrees for a reason:

0.81.5  Appearance.js   NativeAppearance.setColorScheme(colorScheme ?? 'unspecified');
0.82.0  Appearance.js   NativeAppearance.setColorScheme(colorScheme);

Up to 0.81.5 a nullish request is coerced to "unspecified" before it reaches the native module. From 0.82.0 the coercion is gone and the argument is stored and forwarded verbatim. colorScheme is exported from react-native-css/runtime, and #429 deliberately widens the public ColorScheme.set to accept whichever spelling the installed version emits — so with a cast in place, colorScheme.set(null) puts null into Appearance's cache and hands null to the native module on ≥0.82.

That is the failure #415's own comment forbids in the same function: "Announcing it would put a value in Appearance's cache that no reader can render." setAppearanceColorScheme exists precisely to prevent it, so the seam is the resolution and the cast routes around it.

The boundary is 0.82.0, not 0.86

Measured across every react-native in the Yarn cache, by extracting Libraries/Utilities/Appearance.d.ts from each:

0.81.2 / 0.81.4 / 0.81.5   type ColorSchemeName = 'light' | 'dark' | null | undefined;
0.82.0 … 0.86.0            type ColorSchemeName = 'light' | 'dark' | 'unspecified';

The type narrowing and the loss of the ?? 'unspecified' coercion land in the same release. #429 already names 0.82 on its own surface; #415 said 0.86 in four comments and now names 0.82 too, so the two branches no longer disagree about one boundary when they meet.

One nuance worth recording for whoever touches this next, because it is easy to collapse into the same number: there is a third state later in the range, where setColorScheme resolves "unspecified" against the OS before storing it, rather than storing it verbatim. It is present in 0.85.3 and absent in 0.84.1. I have not placed it more tightly than (0.84.1, 0.85.3], so nothing above names it as a version.

A latent one, for #415

#415's three new colour-scheme test files do not type-check against ≥0.82 — colorScheme.set(null) and friends, where null is no longer in ColorSchemeName. That includes color-scheme-appearance-rn-0-86.test.tsx, which models 0.86 while being written against 0.81's type surface. It is latent for both PRs because neither bumps devDependencies.react-native past 0.81.4, so CI never exercises the surface either PR exists to support — which is itself worth a look on #429.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The two readings disagree — the class layer moved and Appearance never heard about it.

FIXED — The class-layer reading and the Appearance reading agree, and the swatch matches both.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

colorScheme.set moved this library's observable and nothing else, so the
class layer and React Native's own readers disagreed: useColorScheme() and
every store built on Appearance.addChangeListener kept the old scheme.

The setter now writes through to Appearance and announces the scheme on
the appearanceChanged device event, because Appearance.setColorScheme
writes its cache without notifying a listener, and that event is the one
Appearance itself handles. It announces only a resolved scheme it changes.
A follow-the-system request is handed to Appearance as "unspecified"; on
react-native 0.82.0-0.84.1, which caches that request verbatim, the scheme
in force before the hand-back is put back in the cache.

colorScheme.get() and prefers-color-scheme resolve the held value through
one function, which answers a scheme for any value that is not one: the
reported scheme, then light (MQ5 §5.4). The class layer read the raw
observable, so a held null or request matched neither scheme.

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