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 a rule or a :root variable falls through to its unconditional value.

Solution

  • As Hand colorScheme.set() to Appearance, so every reader of the scheme moves with it #510 proposes, set() hands the request to Appearance.setColorScheme, which holds it on the platform, so it moves every reader the way the documented Appearance.setColorScheme does. The class layer follows the scheme the platform reports through Appearance and announces none of its own.
  • Until the platform has reported a scheme, what Appearance answers after this library's latest request stands for it; without a native module, as in a jest suite, Appearance answers null and the request itself stands.
  • colorScheme.get() and prefers-color-scheme, on a rule and on a :root variable, resolve through resolveColorScheme, where a value that names no scheme answers light, as MQ5 §12.5 answers no preference.
  • react-native-css/jest's reset, resetBetweenTests() in src/jest/reset.ts, starts each test from what Appearance answers.

React Native decides when the readers move:

  • The platform applies a request after the call returns, Android on the UI thread (AppearanceModule.setColorScheme) and iOS on the main queue (RCTAppearance), and reports it as appearanceChanged; until then the class layer and colorScheme.get() keep the scheme the platform last reported. From 0.82, Appearance.setColorScheme caches the request at the call without notifying a listener, so an Appearance subscriber that renders before the report reads the request.
  • Nothing reports a scheme applied to an Android activity that does not handle uiMode itself, or to a ReactFragment host, since only an activity's onConfigurationChanged reaches AppearanceModule.onConfigurationChanged, nor one an iOS app sets in the background, whose trait change RCTSurfaceHostingView drops. The class layer keeps the previous scheme there until the next report.
  • React Native caches a hand-back as it came: 'unspecified' on 0.82.0 to 0.83.4, 0.84.0 and 0.84.1, which react/react-native#54993 fixed in 0.83.5 and 0.85, and from 0.82 a null passed against its type. Where set(null) then moves no scheme, Appearance.getColorScheme() answers that value until the platform next reports; where it is what Appearance answers when this library loads, the class layer answers light until the platform reports a scheme or a request of this library's leaves Appearance answering one. A call to Appearance.setColorScheme made outside this library notifies no listener, so it reaches the class layer, as any subscriber, only through that report.

Tests

  • color-scheme-appearance.test.tsx runs the installed React Native 0.86 Appearance over a device double that applies an override after the call returns and sends appearanceChanged only when its scheme moves, as iOS and Android do: iOS applying one turn's overrides together, Android each in turn, each with a light and a dark system scheme. Its cases cover requests, hand-backs through null and "unspecified", requests the platform has not applied yet, a late mount, an OS change, a scheme set through Appearance itself, and app code's null hand-back made before this library loads or after.
  • color-scheme-without-appearance-module.test.tsx holds the scheme without a native module, for a rule and for a :root variable; color-scheme-jest-reset.test.tsx the reset; compiler/media-query.test.ts the conditions as compiled.
  • Each expected colour is what Chromium 156, Firefox 157 and WebKit 27.2 render for the same sheet in a light scheme, in a dark one and with no preference.
  • On main, 56 of the 67 cases fail; all pass here.

Verification

Every ci.yml job passes on this head merged into main, with 1408 tests, 1414 with #477's harness fix merged too, and the iOS dev build on Xcode 27.1, where ci.yml pins 26.6. The changed native tests also pass with every sheet compiled inlineVariables: false and after a JSON round trip, as Metro hands a sheet to a device. The prefers-color-scheme arm takes 9.8 ns per evaluation against main's 9.8 with a scheme held, and 11.1 against 8.9 where the held value names none, which main matches to neither scheme, past the 1.2 ns between main's own runs (jest, Node 26).

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

Related

Merge order

@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 alone, so useColorScheme()
and every Appearance.addChangeListener store kept the old scheme, and after a
follow-the-system request the class layer held null and matched neither
prefers-color-scheme: light nor dark.

set() hands each request to Appearance.setColorScheme, and the class layer
follows the scheme the platform reports through Appearance. Until the platform
has reported one, what Appearance answers after the latest request stands for
it, which without a native module, as in a jest suite, is the request itself.
colorScheme.get() and prefers-color-scheme, on a rule and on a :root variable,
resolve a value that names no scheme to light (MQ5 §12.5), and the jest reset
starts each test from what Appearance answers.

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