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); }, };