From 4e1bcc43c6274898865967c0b1134af6f1b8730b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 16:31:36 +0300 Subject: [PATCH] fix: own the color scheme union, so the package compiles on react-native 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. --- src/__tests__/native/color-scheme.test.tsx | 105 +++++++++++++++++++++ src/color-scheme.ts | 35 +++++++ src/jest/index.ts | 5 +- src/native/reactivity.ts | 9 +- src/runtime.types.ts | 24 +++-- src/web/api.tsx | 3 +- 6 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/native/color-scheme.test.tsx create mode 100644 src/color-scheme.ts diff --git a/src/__tests__/native/color-scheme.test.tsx b/src/__tests__/native/color-scheme.test.tsx new file mode 100644 index 00000000..0beced3f --- /dev/null +++ b/src/__tests__/native/color-scheme.test.tsx @@ -0,0 +1,105 @@ +import { + Appearance, + type ColorSchemeName as ReactNativeColorSchemeName, +} from "react-native"; + +import { act, render, screen } from "@testing-library/react-native"; +import { View } from "react-native-css/components/View"; +import { registerCSS, testID } from "react-native-css/jest"; +import { colorScheme } from "react-native-css/runtime"; +import type { ColorSchemeName } from "react-native-css/runtime.types"; + +import { setAppearanceColorScheme } from "../../color-scheme"; + +const mockSetColorScheme = jest.fn(); + +// `Appearance` reads its native module lazily, so mocking the module is enough to make +// the writes observable. Without it `TurboModuleRegistry.get('Appearance')` is null +// under jest and every `setColorScheme` is a silent no-op. +jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => ({ + __esModule: true, + default: { + setColorScheme: (scheme: string) => { + mockSetColorScheme(scheme); + }, + // `getColorScheme` feeds react-native's own post-write bookkeeping, which asserts + // the value is a resolved scheme or absent. + getColorScheme: () => undefined, + addListener: () => undefined, + removeListeners: () => undefined, + }, +})); + +/******************************* Compile plane ******************************/ + +type Assert = T; +type Covers = [Actual] extends [Accepted] ? true : false; + +type AppearanceChangeColorScheme = Parameters< + Parameters[0] +>[0]["colorScheme"]; + +/** + * `ColorSchemeName` is react-native-css's own union rather than react-native's, because + * react-native's changes shape inside this package's peer range: up to 0.85 "follow the + * system" is `null | undefined`, from 0.86 it is `"unspecified"`. + * + * These aliases are read off the installed react-native rather than restating either + * spelling, so `yarn typecheck` fails at whichever end of the range the checkout is on + * the moment the package stops covering a value react-native hands it. + */ +export type CoversReactNativeUnion = Assert< + Covers +>; +export type CoversAppearanceRead = Assert< + Covers, ColorSchemeName> +>; +export type CoversAppearanceEvent = Assert< + Covers +>; + +/******************************* Runtime plane ******************************/ + +beforeEach(() => { + mockSetColorScheme.mockClear(); +}); + +test.each([ + ["light", "light"], + ["dark", "dark"], + ["unspecified", "unspecified"], + [null, "unspecified"], + [undefined, "unspecified"], +] satisfies [ColorSchemeName, string][])( + "setAppearanceColorScheme(%p) reaches the native module as %p", + (value, expected) => { + setAppearanceColorScheme(value); + + expect(mockSetColorScheme).toHaveBeenCalledTimes(1); + expect(mockSetColorScheme).toHaveBeenCalledWith(expected); + }, +); + +test("prefers-color-scheme follows the scheme and releases on 'unspecified'", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-color-scheme: dark) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + colorScheme.set("dark"); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + colorScheme.set("unspecified"); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); +}); diff --git a/src/color-scheme.ts b/src/color-scheme.ts new file mode 100644 index 00000000..669a595f --- /dev/null +++ b/src/color-scheme.ts @@ -0,0 +1,35 @@ +import { Appearance } from "react-native"; + +import type { ColorSchemeName } from "./runtime.types"; + +/** + * `Appearance.setColorScheme` is not declared the same way across the supported + * `react-native` range. On 0.81 it takes `"light" | "dark" | null | undefined`; from + * 0.82 it takes `"light" | "dark" | "unspecified"` and rejects `null`. The two + * parameter types overlap only on `"light" | "dark"`, so no single call can name the + * "follow the system" value and type-check on both. + * + * `"unspecified"` is the value that is right at runtime on every version: 0.82 and + * later hand the argument straight to the native module, and 0.81 maps `null` to + * `"unspecified"` before doing the same. So only the declaration has to be restated, + * and only for the one value the versions disagree about. + */ +interface AppearanceColorSchemeReset { + setColorScheme: (colorScheme: "unspecified") => void; +} + +/** + * Writes a color scheme to `Appearance` in a way that compiles and runs on every + * `react-native` this package supports. + */ +export function setAppearanceColorScheme(value: ColorSchemeName): void { + if (value === "light" || value === "dark") { + // Declared identically by every version in the range. + Appearance.setColorScheme(value); + return; + } + + (Appearance as unknown as AppearanceColorSchemeReset).setColorScheme( + "unspecified", + ); +} diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..1c335f71 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -1,10 +1,11 @@ -import { Appearance, Dimensions } from "react-native"; +import { Dimensions } from "react-native"; import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; +import { setAppearanceColorScheme } from "../color-scheme"; import { colorScheme, dimensions } from "../native/reactivity"; declare global { @@ -21,7 +22,7 @@ export const testID = "react-native-css"; beforeEach(() => { StyleCollection.styles.clear(); dimensions.set(Dimensions.get("window")); - Appearance.setColorScheme("unspecified"); + setAppearanceColorScheme(null); colorScheme.set(null); }); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index a32ac64d..8cc70c8b 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -1,14 +1,11 @@ /* eslint-disable */ import { createContext } from "react"; -import { - Appearance, - Dimensions, - type ColorSchemeName, - type LayoutRectangle, -} from "react-native"; +import { Appearance, Dimensions, type LayoutRectangle } from "react-native"; import type { StyleDescriptor } from "react-native-css/compiler"; +import type { ColorSchemeName } from "../runtime.types"; + export type Effect = { observers: Set>; run(): void; diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..2ca2726d 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -9,12 +9,7 @@ import type { FunctionComponent, ReactElement, } from "react"; -import type { - ColorSchemeName, - ImageStyle, - TextStyle, - ViewStyle, -} from "react-native"; +import type { ImageStyle, TextStyle, ViewStyle } from "react-native"; import type { DotNotation, ResolveDotPath } from "react-native-css/utilities"; @@ -146,6 +141,23 @@ export type RNStyle = ViewStyle & TextStyle & ImageStyle; /******************************** Globals ********************************/ +/** + * A color scheme, plus every spelling of "follow the system" in the supported + * `react-native` range. + * + * This deliberately does not reuse `react-native`'s own `ColorSchemeName`: that type + * is not stable across the `react-native` peer range. On 0.81 it is + * `"light" | "dark" | null | undefined`; from 0.82 it is + * `"light" | "dark" | "unspecified"`. Owning the union keeps `colorScheme` one API + * across the whole range, and accepts whichever spelling the installed version emits. + */ +export type ColorSchemeName = + | "light" + | "dark" + | "unspecified" + | null + | undefined; + export interface ColorScheme { get: () => ColorSchemeName | null | undefined; set: (value: ColorSchemeName | null | undefined) => void; diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..99bbe38f 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -13,6 +13,7 @@ import type { StyledOptions, } from "react-native-css"; +import { setAppearanceColorScheme } from "../color-scheme"; import type { ReactComponent, Styled } from "../runtime.types"; import { assignStyle } from "./assign-style"; @@ -68,7 +69,7 @@ export const colorScheme: ColorScheme = { return Appearance.getColorScheme(); }, set(name) { - Appearance.setColorScheme(name ?? "unspecified"); + setAppearanceColorScheme(name); }, };