Skip to content

fix: own the color scheme union, so the package compiles on react-native 0.81 - #429

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/react-native-0-86-types
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/react-native-0-86-types

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

peerDependencies says react-native: ">=0.81", but the package does not compile on 0.81. src/jest/index.ts calls Appearance.setColorScheme("unspecified") and the web colorScheme.set passes name ?? "unspecified", while react-native 0.81 declares setColorScheme(scheme: ColorSchemeName | null | undefined) with ColorSchemeName = 'light' | 'dark' | null | undefined (0.81.4's Appearance.d.ts):

error TS2345: Argument of type '"unspecified"' is not assignable to parameter of type 'ColorSchemeName'.

0.82 and later declare 'light' | 'dark' | 'unspecified' and reject null, so no one literal for "follow the system" type-checks on both. At runtime every version hands "unspecified" to the native module for that state, since 0.81 maps null to it.

Solution

  • src/runtime.types.ts owns ColorSchemeName, "light" | "dark" | "unspecified" | null | undefined, rather than re-exporting react-native's, whose meaning changes inside the peer range. colorScheme and src/native/reactivity.ts use it.
  • setAppearanceColorScheme (src/color-scheme.ts) writes light and dark as every version declares them, and restates the declaration for "unspecified" alone. The jest helper and the web colorScheme.set call it.

Tests

  • src/__tests__/native/color-scheme.test.tsx: each of light, dark, unspecified, null and undefined reaches the native module as the scheme every version expects, and prefers-color-scheme follows the scheme and releases on "unspecified".

Verification

On Windows with Node 26: yarn typecheck clean against the repository's react-native; against react-native 0.81.4's Appearance declaration, main's call fails as above and setAppearanceColorScheme compiles; yarn lint clean on the changed files; yarn test 1347 passed.

@YevheniiKotyrlo YevheniiKotyrlo changed the title fix: compile and test on the whole react-native peer range (0.81 to 0.86) fix: compile on every react-native in the peer range, not just 0.81 Aug 15, 2026
@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 added a commit to YevheniiKotyrlo/react-native-css that referenced this pull request Aug 16, 2026
…tarts at

These comments say 0.86 in four places. Measured across every react-native in
the Yarn cache, the break starts at 0.82.0 — four minor versions earlier:

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

The runtime moves in the same release. 0.81.5 coerces the nullish request —
`NativeAppearance.setColorScheme(colorScheme ?? 'unspecified')` — and 0.82.0
passes the argument through verbatim.

nativewind#429 already names 0.82 on its own surface, so left alone the two branches
would land in one tree disagreeing about one boundary.

The cache-write sentence gets rewritten rather than renumbered, because the
range has three states and not two: before 0.82 the cache is a read-back of the
native module, from 0.82 it is the requested value, and later in the range
`"unspecified"` is resolved against the OS before being stored. Scoping the
sentence to a RESOLVED scheme collapses the last two — the only value they
disagree about is `"unspecified"`, which is exactly the value the guard below
declines to announce — and it keeps the comment from naming a boundary this
measurement cannot place: the cache holds 0.84.1 and 0.85.3 but not 0.85.0
through 0.85.2, so the second transition is bounded only to (0.84.1, 0.85.3].

Comments only; no behaviour changes. `reactivity.ts:229` is left alone — "the
request 0.81 spells `null`" is a statement about 0.81 and is correct.
@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.

…ive 0.81

react-native changes ColorSchemeName inside the peer range: 0.81 declares
"light" | "dark" | null | undefined, and 0.82 and later "light" | "dark" |
"unspecified". The jest helper and the web colorScheme pass "unspecified"
to Appearance.setColorScheme, which 0.81's declaration rejects.

The package owns its ColorSchemeName union, and setAppearanceColorScheme
writes light and dark as every version declares them and restates the
declaration for "unspecified", which every version hands to the native
module.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/react-native-0-86-types branch from 8189bb6 to 4e1bcc4 Compare October 22, 2026 13:32
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix: compile on every react-native in the peer range, not just 0.81 fix: own the color scheme union, so the package compiles on react-native 0.81 Oct 22, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 22, 2026 13:35

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