diff --git a/src/__tests__/native/color-scheme-appearance.test.tsx b/src/__tests__/native/color-scheme-appearance.test.tsx
new file mode 100644
index 00000000..aa0a5271
--- /dev/null
+++ b/src/__tests__/native/color-scheme-appearance.test.tsx
@@ -0,0 +1,377 @@
+import { useSyncExternalStore } from "react";
+import {
+ Appearance,
+ DeviceEventEmitter,
+ Text,
+ type ColorSchemeName,
+} 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 { colorScheme as colorSchemeObservable } from "../../native/reactivity";
+
+type Scheme = "light" | "dark";
+type Platform = "ios" | "android";
+
+// A device applies an override after the call returns and sends appearanceChanged only when its scheme moves, as RCTAppearance and AppearanceModule do
+jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => {
+ let platform = "ios";
+ let systemScheme = "light";
+ let override: string | null = null;
+ let sentScheme = "light";
+ let pendingOverrides: string[] = [];
+ let setColorSchemeCalls: string[] = [];
+
+ const schemeInForce = () => override ?? systemScheme;
+ const sendIfMoved = (sent: string[]) => {
+ if (schemeInForce() !== sentScheme) {
+ sentScheme = schemeInForce();
+ sent.push(sentScheme);
+ }
+ };
+
+ return {
+ __esModule: true,
+ default: {
+ addListener: () => undefined,
+ removeListeners: () => undefined,
+ // RCTAppearance answers the scheme it last sent; AppearanceModule reads the configuration in force
+ getColorScheme: () => (platform === "ios" ? sentScheme : schemeInForce()),
+ setColorScheme: (next: string) => {
+ setColorSchemeCalls.push(next);
+ pendingOverrides.push(next);
+ },
+ startDevice: (nextPlatform: string, scheme: string) => {
+ platform = nextPlatform;
+ systemScheme = scheme;
+ override = null;
+ sentScheme = scheme;
+ pendingOverrides = [];
+ setColorSchemeCalls = [];
+ },
+ // iOS applies the overrides of one main-queue turn together; Android applies each posted override in turn
+ applyPendingOverrides: (): string[] => {
+ const applied =
+ platform === "ios" ? pendingOverrides.slice(-1) : pendingOverrides;
+ pendingOverrides = [];
+ const sent: string[] = [];
+ for (const next of applied) {
+ override = next === "unspecified" || next === "auto" ? null : next;
+ sendIfMoved(sent);
+ }
+ return sent;
+ },
+ changeSystemScheme: (scheme: string): string[] => {
+ systemScheme = scheme;
+ const sent: string[] = [];
+ sendIfMoved(sent);
+ return sent;
+ },
+ readSetColorSchemeCalls: () => [...setColorSchemeCalls],
+ },
+ };
+});
+
+interface DeviceDouble {
+ readonly startDevice: (platform: Platform, scheme: Scheme) => void;
+ readonly applyPendingOverrides: () => readonly Scheme[];
+ readonly changeSystemScheme: (scheme: Scheme) => readonly Scheme[];
+ readonly readSetColorSchemeCalls: () => readonly string[];
+}
+
+const device = jest.requireMock<{ default: DeviceDouble }>(
+ "react-native/Libraries/Utilities/NativeAppearance",
+).default;
+
+const deliver = (sent: readonly Scheme[]): readonly Scheme[] => {
+ for (const scheme of sent) {
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme });
+ }
+ return sent;
+};
+
+const subscribeToAppearance = (onStoreChange: () => void): (() => void) => {
+ const subscription = Appearance.addChangeListener(onStoreChange);
+ return () => {
+ subscription.remove();
+ };
+};
+
+const readAppearance = (): ColorSchemeName | null | undefined =>
+ Appearance.getColorScheme();
+
+// The store react-native's useColorScheme builds, which the jest preset replaces with a constant
+const SubscribedColorScheme = () => {
+ const scheme = useSyncExternalStore(subscribeToAppearance, readAppearance);
+
+ return {scheme ?? "unset"};
+};
+
+const TRI_STATE_CSS = `
+.my-class { color: green; }
+
+@media (prefers-color-scheme: light) {
+ .my-class { color: blue; }
+}
+
+@media (prefers-color-scheme: dark) {
+ .my-class { color: red; }
+}`;
+
+const STYLE_FOR: Readonly> = {
+ light: { color: "#00f" },
+ dark: { color: "#f00" },
+};
+
+const LATE_TEST_ID = "late-element";
+
+const readEveryLayer = () => ({
+ classLayer: screen.getByTestId(testID).props.style,
+ colorSchemeGet: colorScheme.get(),
+ subscribed: screen.getByTestId("subscribed-color-scheme").props.children,
+});
+
+const everyLayerOn = (scheme: Scheme) => ({
+ classLayer: STYLE_FOR[scheme],
+ colorSchemeGet: scheme,
+ subscribed: scheme,
+});
+
+const opposite = (scheme: Scheme): Scheme =>
+ scheme === "light" ? "dark" : "light";
+
+const readColorScheme = (): Scheme => {
+ const scheme = colorScheme.get();
+ if (scheme !== "light" && scheme !== "dark") {
+ throw new Error(`colorScheme.get() answered ${String(scheme)}`);
+ }
+ return scheme;
+};
+
+// React Native 0.82 to 0.87 cache a setColorScheme request at the call, so an Appearance subscriber that renders before the platform reports reads the request; this library's own layers wait for the report
+const expectLibraryLayersToAgree = (): void => {
+ const scheme = readColorScheme();
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(
+ STYLE_FOR[scheme],
+ );
+};
+
+const recordAppearanceEvents = (): {
+ sentByCaller: () => readonly unknown[];
+ stop: () => void;
+} => {
+ const emit = jest.spyOn(DeviceEventEmitter, "emit");
+
+ return {
+ sentByCaller: () =>
+ emit.mock.calls.filter(([event]) => event === "appearanceChanged"),
+ stop: () => {
+ emit.mockRestore();
+ },
+ };
+};
+
+describe.each([
+ ["ios", "light"],
+ ["ios", "dark"],
+ ["android", "light"],
+ ["android", "dark"],
+] as const)("on %s with a %s system scheme", (platform, system) => {
+ const other = opposite(system);
+
+ beforeEach(() => {
+ device.startDevice(platform, system);
+ act(() => {
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: system });
+ });
+ registerCSS(TRI_STATE_CSS);
+ render(
+ <>
+
+
+ >,
+ );
+ });
+
+ test("colorScheme.set hands each request to Appearance and sends no event of its own", () => {
+ const { sentByCaller, stop } = recordAppearanceEvents();
+
+ try {
+ act(() => {
+ colorScheme.set(other);
+ colorScheme.set(null);
+ colorScheme.set("unspecified");
+ });
+
+ expect(device.readSetColorSchemeCalls().slice(-3)).toStrictEqual([
+ other,
+ "unspecified",
+ "unspecified",
+ ]);
+ expect(sentByCaller()).toStrictEqual([]);
+ } finally {
+ stop();
+ }
+ });
+
+ test("the class layer and colorScheme.get() keep the scheme in force until the platform applies a request, then every layer takes it", () => {
+ act(() => {
+ colorScheme.set(other);
+ });
+
+ expectLibraryLayersToAgree();
+
+ act(() => {
+ deliver(device.applyPendingOverrides());
+ });
+
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(other));
+ });
+
+ test.each([null, "unspecified"] as const)(
+ "set(%p) returns every layer to the system scheme",
+ (handBack) => {
+ act(() => {
+ colorScheme.set(other);
+ });
+ act(() => {
+ deliver(device.applyPendingOverrides());
+ });
+ act(() => {
+ colorScheme.set(handBack);
+ });
+ act(() => {
+ deliver(device.applyPendingOverrides());
+ });
+
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(system));
+ },
+ );
+
+ test("a request and its hand-back made before the platform applies either leave the library's layers, then every layer, on the system scheme", () => {
+ act(() => {
+ colorScheme.set(other);
+ colorScheme.set(null);
+ });
+
+ expectLibraryLayersToAgree();
+
+ let sent: readonly Scheme[] = [];
+ act(() => {
+ sent = deliver(device.applyPendingOverrides());
+ });
+
+ expect(sent).toStrictEqual(platform === "ios" ? [] : [other, system]);
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(system));
+ });
+
+ test("each scheme the platform applies moves every layer, ending on the last request", () => {
+ act(() => {
+ colorScheme.set(other);
+ });
+ act(() => {
+ colorScheme.set(system);
+ });
+
+ expectLibraryLayersToAgree();
+
+ let sent: readonly Scheme[] = [];
+ act(() => {
+ sent = deliver(device.applyPendingOverrides());
+ });
+
+ expect(sent).toStrictEqual(platform === "ios" ? [] : [other, system]);
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(system));
+ });
+
+ test("a request for the scheme in force sends nothing and moves no layer", () => {
+ act(() => {
+ colorScheme.set(system);
+ });
+
+ let sent: readonly Scheme[] = [];
+ act(() => {
+ sent = deliver(device.applyPendingOverrides());
+ });
+
+ expect(sent).toStrictEqual([]);
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(system));
+ });
+
+ test("a request after app code hands Appearance back with null still reaches the platform", () => {
+ // React Native 0.81 documented setColorScheme(null) as the hand-back; from 0.82 Appearance caches the null as it came
+ act(() => {
+ Reflect.apply(Appearance.setColorScheme, Appearance, [null]);
+ });
+ act(() => {
+ colorScheme.set(other);
+ });
+ act(() => {
+ deliver(device.applyPendingOverrides());
+ });
+
+ expect(device.readSetColorSchemeCalls().at(-1)).toBe(other);
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(other));
+ });
+
+ test("a scheme set through Appearance itself moves every layer once the platform applies it", () => {
+ act(() => {
+ Appearance.setColorScheme(other);
+ });
+ act(() => {
+ deliver(device.applyPendingOverrides());
+ });
+
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(other));
+ });
+
+ test("an element mounted before the platform applies a request renders what one mounted earlier renders", () => {
+ act(() => {
+ colorScheme.set(null);
+ Appearance.setColorScheme(other);
+ });
+ const earlierStyle: unknown = screen.getByTestId(testID).props.style;
+ render();
+
+ expect(earlierStyle).toStrictEqual(STYLE_FOR[readColorScheme()]);
+ expect(screen.getByTestId(LATE_TEST_ID).props.style).toStrictEqual(
+ earlierStyle,
+ );
+ });
+
+ test("an OS change moves every layer, and none while a request overrides it", () => {
+ act(() => {
+ deliver(device.changeSystemScheme(other));
+ });
+
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(other));
+
+ act(() => {
+ colorScheme.set(other);
+ });
+ act(() => {
+ deliver(device.applyPendingOverrides());
+ });
+ act(() => {
+ deliver(device.changeSystemScheme(system));
+ });
+
+ expect(readEveryLayer()).toStrictEqual(everyLayerOn(other));
+ });
+});
+
+test("a value Appearance reports that names no scheme resolves to light on both layers", () => {
+ device.startDevice("ios", "dark");
+ registerCSS(TRI_STATE_CSS);
+ render();
+
+ act(() => {
+ colorSchemeObservable.set("unspecified");
+ });
+
+ expect(colorScheme.get()).toBe("light");
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(STYLE_FOR.light);
+});
diff --git a/src/__tests__/native/color-scheme-jest-reset.test.tsx b/src/__tests__/native/color-scheme-jest-reset.test.tsx
new file mode 100644
index 00000000..6fd56dde
--- /dev/null
+++ b/src/__tests__/native/color-scheme-jest-reset.test.tsx
@@ -0,0 +1,43 @@
+import { Appearance } 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 { resetBetweenTests } from "../../jest/reset";
+import { colorScheme as heldColorScheme } from "../../native/reactivity";
+
+jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => ({
+ __esModule: true,
+ default: {
+ addListener: () => undefined,
+ removeListeners: () => undefined,
+ getColorScheme: () => "dark",
+ setColorScheme: () => undefined,
+ },
+}));
+
+test("the jest helper's reset puts back the scheme Appearance reports after a test changed it", () => {
+ act(() => {
+ heldColorScheme.set("light");
+ });
+ expect(colorScheme.get()).toBe("light");
+
+ act(() => {
+ resetBetweenTests();
+ });
+ registerCSS(`
+.my-class { color: green; }
+
+@media (prefers-color-scheme: dark) {
+ .my-class { color: red; }
+}`);
+ render();
+
+ expect(Appearance.getColorScheme()).toBe("dark");
+ expect(colorScheme.get()).toBe("dark");
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ color: "#f00",
+ });
+});
diff --git a/src/__tests__/native/color-scheme-without-appearance-module.test.tsx b/src/__tests__/native/color-scheme-without-appearance-module.test.tsx
new file mode 100644
index 00000000..a61d8ef3
--- /dev/null
+++ b/src/__tests__/native/color-scheme-without-appearance-module.test.tsx
@@ -0,0 +1,50 @@
+import { Appearance } 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";
+
+const TRI_STATE_CSS = `
+.my-class { color: green; }
+
+@media (prefers-color-scheme: light) {
+ .my-class { color: blue; }
+}
+
+@media (prefers-color-scheme: dark) {
+ .my-class { color: red; }
+}`;
+
+const BLUE = { color: "#00f" } as const;
+const RED = { color: "#f00" } as const;
+
+beforeEach(() => {
+ registerCSS(TRI_STATE_CSS);
+ render();
+});
+
+test("without an Appearance module, colorScheme.set holds the scheme for both layers", () => {
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ expect(Appearance.getColorScheme()).toBeNull();
+ expect(colorScheme.get()).toBe("dark");
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(RED);
+});
+
+test.each([null, "unspecified"] as const)(
+ "without an Appearance module, set(%p) resolves light on both layers",
+ (handBack) => {
+ act(() => {
+ colorScheme.set("dark");
+ });
+ act(() => {
+ colorScheme.set(handBack);
+ });
+
+ expect(colorScheme.get()).toBe("light");
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE);
+ },
+);
diff --git a/src/jest/index.ts b/src/jest/index.ts
index a8d249f5..57aa5a3d 100644
--- a/src/jest/index.ts
+++ b/src/jest/index.ts
@@ -1,11 +1,9 @@
-import { Appearance, 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 { colorScheme, dimensions } from "../native/reactivity";
+import { resetBetweenTests } from "./reset";
declare global {
/* eslint-disable @typescript-eslint/no-namespace */
@@ -18,12 +16,7 @@ declare global {
export const testID = "react-native-css";
-beforeEach(() => {
- StyleCollection.styles.clear();
- dimensions.set(Dimensions.get("window"));
- Appearance.setColorScheme("unspecified");
- colorScheme.set(null);
-});
+beforeEach(resetBetweenTests);
const debugDefault = Boolean(
process.env.REACT_NATIVE_CSS_TEST_DEBUG &&
diff --git a/src/jest/reset.ts b/src/jest/reset.ts
new file mode 100644
index 00000000..2c4530b4
--- /dev/null
+++ b/src/jest/reset.ts
@@ -0,0 +1,13 @@
+import { Appearance, Dimensions } from "react-native";
+
+import { StyleCollection } from "react-native-css/native";
+
+import { colorScheme, dimensions } from "../native/reactivity";
+
+// Each test starts from the scheme the platform reports once a previous test's request is handed back
+export function resetBetweenTests(): void {
+ StyleCollection.styles.clear();
+ dimensions.set(Dimensions.get("window"));
+ Appearance.setColorScheme("unspecified");
+ colorScheme.set(Appearance.getColorScheme());
+}
diff --git a/src/native/api.tsx b/src/native/api.tsx
index 6736f746..9e6cefec 100644
--- a/src/native/api.tsx
+++ b/src/native/api.tsx
@@ -18,6 +18,8 @@ import { usePassthrough } from "./react/usePassthrough";
import {
cleanupEffect,
colorScheme as colorSchemeObs,
+ hasAppearanceModule,
+ resolveColorScheme,
VAR_SYMBOL,
type Effect,
type Getter,
@@ -69,10 +71,15 @@ export const styled: Styled = (
export const colorScheme: ColorScheme = {
get() {
- return colorSchemeObs.get() ?? Appearance.getColorScheme() ?? "light";
+ return resolveColorScheme(colorSchemeObs.get());
},
set(value) {
- return colorSchemeObs.set(value === "unspecified" ? null : value);
+ // Without Appearance's native module nothing but this library holds a scheme
+ if (hasAppearanceModule) {
+ Appearance.setColorScheme(value ?? "unspecified");
+ } else {
+ colorSchemeObs.set(value);
+ }
},
};
diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts
index afd51250..9c44d4e3 100644
--- a/src/native/conditions/media-query.ts
+++ b/src/native/conditions/media-query.ts
@@ -3,7 +3,13 @@ import { I18nManager, PixelRatio, Platform } from "react-native";
import type { MediaCondition } from "react-native-css/compiler";
-import { colorScheme, vh, vw, type Getter } from "../reactivity";
+import {
+ colorScheme,
+ resolveColorScheme,
+ vh,
+ vw,
+ type Getter,
+} from "../reactivity";
export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) {
return mediaQueries.every((query) => test(query, get));
@@ -45,7 +51,7 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean {
case "platform":
return value === "native" || value === Platform.OS;
case "prefers-color-scheme": {
- return value === get(colorScheme);
+ return value === resolveColorScheme(get(colorScheme));
}
case "display-mode":
return value === "native" || Platform.OS === value;
diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts
index 296988a6..878120e9 100644
--- a/src/native/reactivity.ts
+++ b/src/native/reactivity.ts
@@ -228,11 +228,23 @@ Dimensions.addEventListener("change", ({ window }) => {
/** Color Scheme **************************************************************/
+const colorSchemeAtImport = Appearance.getColorScheme();
+
+// Appearance answers null at import only without its native module, which app code's later setColorScheme(null) cannot fake
+export const hasAppearanceModule = colorSchemeAtImport !== null;
+
export const colorScheme = observable(
- Appearance.getColorScheme(),
+ colorSchemeAtImport,
);
Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));
+// A value that names no scheme states no preference, which MQ5 ยง12.5 answers with light
+export function resolveColorScheme(
+ value: ColorSchemeName | null | undefined,
+): "light" | "dark" {
+ return value === "light" || value === "dark" ? value : "light";
+}
+
/** Containers ****************************************************************/
export type ContainerContextValue = Record<