diff --git a/src/__tests__/native/color-scheme-appearance-async.test.tsx b/src/__tests__/native/color-scheme-appearance-async.test.tsx
new file mode 100644
index 00000000..e19a2349
--- /dev/null
+++ b/src/__tests__/native/color-scheme-appearance-async.test.tsx
@@ -0,0 +1,248 @@
+import { useSyncExternalStore } from "react";
+import {
+ Appearance,
+ DeviceEventEmitter,
+ Text,
+ type ColorSchemeName,
+} from "react-native";
+
+import { act, render, screen } from "@testing-library/react-native";
+import { colorScheme } from "react-native-css/runtime";
+
+// The platform applies a `setColorScheme` write LATER, and the react-native
+// pinned here writes its own cache from a read-back taken before that apply
+// lands:
+//
+// NativeAppearance.setColorScheme(colorScheme ?? 'unspecified');
+// state.appearance = {colorScheme: toColorScheme(NativeAppearance.getColorScheme())};
+// — react-native 0.81.4, Libraries/Utilities/Appearance.js
+//
+// Both platforms make that read-back stale. Android's `AppearanceModule`
+// wraps the night-mode switch in `UiThreadUtil.runOnUiThread {}`, which is
+// `mainHandler.postDelayed(runnable, 0)` — always posted, never inline, so
+// `getColorScheme()` still answers from the applied configuration. iOS's
+// `RCTAppearance` `getColorScheme` returns `_currentColorScheme`, assigned at
+// init and inside `appearanceChanged:`, and never by `setColorScheme:`.
+//
+// So the fake below records the request and moves nothing. `applyPendingWrite`
+// is the seam the UI-thread post stands for: an explicit call rather than a
+// timer, so a slow machine cannot change what any test here observes.
+//
+// The sibling `color-scheme-appearance.test.tsx` applies the write inline,
+// which is the shape a caller sees on react-native >= 0.86 — there the cache is
+// the requested value. `color-scheme-appearance-rn-0-86.test.tsx` covers the
+// rest of that version's setter. Between the three, both cache-write rules in
+// the declared peer range (`react-native >= 0.81`) are driven.
+jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => {
+ // What the OS itself reports, and therefore what "unspecified" resolves to
+ const operatingSystemScheme = "light";
+ let appliedScheme: ColorSchemeName = operatingSystemScheme;
+ let pendingRequest: string | undefined;
+
+ const resolveRequest = (request: string): ColorSchemeName =>
+ request === "unspecified"
+ ? operatingSystemScheme
+ : (request as ColorSchemeName);
+
+ return {
+ __esModule: true,
+ default: {
+ // NativeEventEmitter's listener-refcount contract
+ addListener: () => undefined,
+ // The scheme in force, which is not the scheme most recently requested
+ getColorScheme: () => appliedScheme,
+ removeListeners: () => undefined,
+ setColorScheme: (next: string) => {
+ pendingRequest = next;
+ },
+ // The UI-thread post landing. Answers with the scheme now in force, which
+ // is what the platform then echoes on `appearanceChanged`.
+ applyPendingWrite: () => {
+ if (pendingRequest !== undefined) {
+ appliedScheme = resolveRequest(pendingRequest);
+ pendingRequest = undefined;
+ }
+ return appliedScheme;
+ },
+ writeDeviceScheme: (next: ColorSchemeName) => {
+ appliedScheme = next;
+ pendingRequest = undefined;
+ },
+ },
+ };
+});
+
+interface FakeNativeAppearance {
+ applyPendingWrite: () => ColorSchemeName;
+ writeDeviceScheme: (next: ColorSchemeName) => void;
+}
+
+const nativeAppearanceModule: { default: FakeNativeAppearance } =
+ jest.requireMock("react-native/Libraries/Utilities/NativeAppearance");
+const nativeAppearance = nativeAppearanceModule.default;
+
+// The UI-thread post landing, and the `appearanceChanged` event the platform
+// then fires. Appearance.js registers the listener that turns that event into
+// its cache write and its `change` emit.
+const applyAndEchoPlatformWrite = (): void => {
+ const applied = nativeAppearance.applyPendingWrite();
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: applied });
+};
+
+// Reset through the platform path, so the fixture does not depend on the setter
+// under test
+const resetToLight = (): void => {
+ nativeAppearance.writeDeviceScheme("light");
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: "light" });
+};
+
+// The shape of react-native's own useColorScheme: subscribe through
+// addChangeListener, snapshot through getColorScheme. The real hook cannot be
+// used here — react-native/jest/setup.js replaces it with jest.fn(() => "light")
+// — but it is this store, and so is every other documented way to track the
+// scheme.
+const subscribeToAppearance = (onStoreChange: () => void): (() => void) => {
+ const subscription = Appearance.addChangeListener(onStoreChange);
+ return () => {
+ subscription.remove();
+ };
+};
+
+const readAppearanceColorScheme = (): ColorSchemeName =>
+ Appearance.getColorScheme();
+
+const SubscribedColorScheme = () => {
+ const scheme = useSyncExternalStore(
+ subscribeToAppearance,
+ readAppearanceColorScheme,
+ );
+
+ return {scheme ?? "unset"};
+};
+
+const readSubscribedColorScheme = (): unknown =>
+ screen.getByTestId("subscribed-color-scheme").props.children;
+
+const recordChangeEvents = (): {
+ heard: ColorSchemeName[];
+ stop: () => void;
+} => {
+ const heard: ColorSchemeName[] = [];
+ const subscription = Appearance.addChangeListener((event) => {
+ heard.push(event.colorScheme);
+ });
+
+ return {
+ heard,
+ stop: () => {
+ subscription.remove();
+ },
+ };
+};
+
+beforeEach(() => {
+ act(() => {
+ resetToLight();
+ });
+});
+
+test("colorScheme.set announces the requested scheme before the platform applies it", () => {
+ const { heard, stop } = recordChangeEvents();
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ // Nothing has been flushed — the platform is still holding the write, so
+ // `Appearance.setColorScheme` has cached a read-back of the OLD scheme. An
+ // announcement derived from that cache reports no change at all; one carrying
+ // the requested value reports the change the caller asked for.
+ expect(heard).toStrictEqual(["dark"]);
+ expect(Appearance.getColorScheme()).toBe("dark");
+ expect(colorScheme.get()).toBe("dark");
+
+ stop();
+});
+
+test("a reader subscribed the way useColorScheme is moves before the platform echo", () => {
+ render();
+ expect(readSubscribedColorScheme()).toBe("light");
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ // The whole point of the setter: an app that offers a light/dark preference
+ // gets its chrome and its `dark:` utilities on the same scheme in one call,
+ // rather than one of them a UI-thread hop later
+ expect(readSubscribedColorScheme()).toBe("dark");
+});
+
+test("the platform echo that follows repeats the scheme and settles there", () => {
+ const { heard, stop } = recordChangeEvents();
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+ act(() => {
+ applyAndEchoPlatformWrite();
+ });
+
+ // A platform that echoes the write back delivers the same value a second
+ // time. useSyncExternalStore bails on an identical snapshot, and every
+ // reader here holds the scheme that was asked for.
+ expect(heard).toStrictEqual(["dark", "dark"]);
+ expect(Appearance.getColorScheme()).toBe("dark");
+ expect(colorScheme.get()).toBe("dark");
+
+ stop();
+});
+
+test("a redundant set of the scheme the announcement already put in force says nothing", () => {
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ const { heard, stop } = recordChangeEvents();
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ // The announcement reaches Appearance's own `appearanceChanged` handler, so
+ // the cache it wrote is what the next call reads as the scheme in force. That
+ // is what keeps the second call silent without the setter tracking anything
+ // of its own.
+ expect(heard).toStrictEqual([]);
+
+ stop();
+});
+
+test("set(null) hands the scheme back without announcing a scheme of its own", () => {
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ const { heard, stop } = recordChangeEvents();
+
+ act(() => {
+ colorScheme.set(null);
+ });
+
+ // There is nothing truthful to announce: the caller named no scheme, and only
+ // the OS knows what handing it back resolves to. `null` is not a scheme any
+ // reader can render — broadcasting it tells useColorScheme() the app has no
+ // scheme at all.
+ expect(heard).toStrictEqual([]);
+
+ act(() => {
+ applyAndEchoPlatformWrite();
+ });
+
+ // The platform's own echo is what delivers the resolved scheme, exactly as it
+ // does for an OS theme change
+ expect(heard).toStrictEqual(["light"]);
+ expect(colorScheme.get()).toBe("light");
+
+ stop();
+});
diff --git a/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx b/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx
new file mode 100644
index 00000000..e9063217
--- /dev/null
+++ b/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx
@@ -0,0 +1,303 @@
+import { Appearance, type ColorSchemeName } from "react-native";
+
+import { act } from "@testing-library/react-native";
+import { colorScheme } from "react-native-css/runtime";
+
+// react-native 0.82 rewrote the one expression the announcement used to read:
+// `setColorScheme` stopped reading the cache back and wrote the REQUESTED
+// value instead. By 0.85.3 a read-back had returned for the literal
+// "unspecified" alone — the shape quoted here, and the one 0.86.0 ships:
+//
+// NativeAppearance.setColorScheme(colorScheme);
+// state.appearance = {
+// colorScheme:
+// colorScheme === 'unspecified'
+// ? (NativeAppearance.getColorScheme() ?? colorScheme)
+// : colorScheme,
+// };
+// — react-native 0.86.0, Libraries/Utilities/Appearance.js
+//
+// The 0.81.4 pinned in this repo reads the cache back on every path, and
+// `toColorScheme` is gone by 0.86 along with its invariant. `ColorSchemeName`
+// moved too: 0.81 declares 'light' | 'dark' | null | undefined, 0.86 declares
+// 'light' | 'dark' | 'unspecified', so on the current release "unspecified" is
+// the type-legal way to hand the scheme back and `null` is not in the type at
+// all.
+//
+// `react-native >= 0.81` is the declared peer range, so both are shipping
+// behaviour and no single installed react-native can express both. The two
+// sibling suites drive the installed module; this one stands in for the version
+// that cannot be installed beside it, transcribing the four functions of
+// Appearance.js and nothing else — the `appearanceChanged` registration is
+// still react-native's own `NativeEventEmitter`, so what reaches this cache is
+// what reaches the real one.
+
+// Declared out here because babel's `jest.mock` hoist check reads a parameter
+// name inside an inline constructor type as a variable access
+interface AppearancePreferences {
+ colorScheme: ColorSchemeName;
+}
+type NativeEventEmitterConstructor = new (nativeModule: unknown) => {
+ addListener: (
+ event: string,
+ listener: (preferences: AppearancePreferences) => void,
+ ) => void;
+};
+type AppearanceEmitterConstructor = new () => {
+ emit: (event: string, payload: AppearancePreferences) => void;
+ addListener: (
+ event: string,
+ listener: (payload: AppearancePreferences) => void,
+ ) => { remove: () => void };
+};
+
+jest.mock("react-native/Libraries/Utilities/Appearance", () => {
+ const NativeEventEmitter = jest.requireActual<{ default: unknown }>(
+ "react-native/Libraries/EventEmitter/NativeEventEmitter",
+ ).default as NativeEventEmitterConstructor;
+ const EventEmitter = jest.requireActual<{ default: unknown }>(
+ "react-native/Libraries/vendor/emitter/EventEmitter",
+ ).default as AppearanceEmitterConstructor;
+
+ // The platform applies the write on a later turn and answers from the
+ // configuration in force until it does — Android posts the night-mode switch
+ // to the UI thread, iOS never assigns `_currentColorScheme` in the setter.
+ const operatingSystemScheme: ColorSchemeName = "light";
+ let appliedScheme: ColorSchemeName = operatingSystemScheme;
+ let pendingRequest: string | undefined;
+
+ const nativeAppearance = {
+ addListener: () => undefined,
+ getColorScheme: () => appliedScheme,
+ removeListeners: () => undefined,
+ setColorScheme: (next: string) => {
+ pendingRequest = next;
+ },
+ };
+
+ const eventEmitter = new EventEmitter();
+ let appearance: AppearancePreferences | undefined;
+
+ new NativeEventEmitter(nativeAppearance).addListener(
+ "appearanceChanged",
+ (newAppearance) => {
+ appearance = { colorScheme: newAppearance.colorScheme };
+ eventEmitter.emit("change", appearance);
+ },
+ );
+
+ return {
+ addChangeListener: (listener: (payload: AppearancePreferences) => void) =>
+ eventEmitter.addListener("change", listener),
+ getColorScheme: () => {
+ appearance ??= { colorScheme: nativeAppearance.getColorScheme() };
+ return appearance.colorScheme;
+ },
+ setColorScheme: (requested: ColorSchemeName) => {
+ nativeAppearance.setColorScheme(requested as string);
+ appearance = {
+ colorScheme:
+ (requested as string) === "unspecified"
+ ? (nativeAppearance.getColorScheme() ?? requested)
+ : requested,
+ };
+ },
+ // The UI-thread post landing. Answers with the scheme now in force, which is
+ // what the platform then echoes on `appearanceChanged`.
+ applyPendingWrite: () => {
+ if (pendingRequest !== undefined) {
+ appliedScheme =
+ pendingRequest === "unspecified"
+ ? operatingSystemScheme
+ : (pendingRequest as ColorSchemeName);
+ pendingRequest = undefined;
+ }
+ return appliedScheme;
+ },
+ writeDeviceScheme: (next: ColorSchemeName) => {
+ appliedScheme = next;
+ pendingRequest = undefined;
+ },
+ };
+});
+
+interface Rn086Appearance {
+ applyPendingWrite: () => ColorSchemeName;
+ writeDeviceScheme: (next: ColorSchemeName) => void;
+}
+
+const { applyPendingWrite, writeDeviceScheme } = jest.requireMock<
+ typeof Appearance & Rn086Appearance
+>("react-native/Libraries/Utilities/Appearance");
+
+// react-native 0.86's ColorSchemeName carries "unspecified" where 0.81 carried
+// null, and `colorScheme.set` is typed by whichever one is installed. Under this
+// repo's 0.81 pin the literal is outside the type, so reaching it needs the
+// bridge — the call itself is what a caller on the current release writes.
+const setColorScheme086 = colorScheme.set as (value: string) => void;
+
+const emitAppearanceChanged = (scheme: ColorSchemeName): void => {
+ // Where the platform's own event arrives — the emitter NativeEventEmitter
+ // registered the handler on
+ const { DeviceEventEmitter } = jest.requireActual<{
+ DeviceEventEmitter: { emit: (event: string, payload: unknown) => void };
+ }>("react-native");
+
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme });
+};
+
+const applyAndEchoPlatformWrite = (): void => {
+ emitAppearanceChanged(applyPendingWrite());
+};
+
+const recordChangeEvents = (): {
+ heard: ColorSchemeName[];
+ stop: () => void;
+} => {
+ const heard: ColorSchemeName[] = [];
+ const subscription = Appearance.addChangeListener((event) => {
+ heard.push(event.colorScheme);
+ });
+
+ return {
+ heard,
+ stop: () => {
+ subscription.remove();
+ },
+ };
+};
+
+beforeEach(() => {
+ // Reset through the platform path, so the fixture does not depend on the
+ // setter under test
+ act(() => {
+ writeDeviceScheme("light");
+ emitAppearanceChanged("light");
+ });
+});
+
+test("colorScheme.set announces the requested scheme once", () => {
+ const { heard, stop } = recordChangeEvents();
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ expect(heard).toStrictEqual(["dark"]);
+ expect(Appearance.getColorScheme()).toBe("dark");
+ expect(colorScheme.get()).toBe("dark");
+
+ act(() => {
+ applyAndEchoPlatformWrite();
+ });
+
+ // A platform that echoes the write back delivers the same value a second
+ // time, and every reader settles on the scheme that was asked for
+ expect(heard).toStrictEqual(["dark", "dark"]);
+ expect(Appearance.getColorScheme()).toBe("dark");
+
+ stop();
+});
+
+test("set(null) hands the scheme back without broadcasting a null scheme", () => {
+ act(() => {
+ colorScheme.set("dark");
+ applyAndEchoPlatformWrite();
+ });
+
+ const { heard, stop } = recordChangeEvents();
+
+ act(() => {
+ colorScheme.set(null);
+ });
+
+ // 0.86 caches the requested value as-is, so the cache goes null on this call
+ // and an announcement derived from it broadcasts `{colorScheme: null}` to
+ // every subscriber — telling useColorScheme() the app has no scheme. The
+ // caller named no scheme, so there is nothing truthful to announce.
+ expect(heard).toStrictEqual([]);
+
+ // What the platform makes of a null request is not modelled: 0.86 forwards it
+ // to the native module unchanged, where the spec's ColorSchemeName is
+ // 'light' | 'dark' | 'unspecified' and null is not a member. The next test
+ // covers the spelling 0.86's own type asks for. What matters here is that the
+ // channel is intact — an OS change still reaches every reader.
+ act(() => {
+ writeDeviceScheme("light");
+ emitAppearanceChanged("light");
+ });
+
+ expect(heard).toStrictEqual(["light"]);
+ expect(colorScheme.get()).toBe("light");
+
+ stop();
+});
+
+test("set('unspecified') hands the scheme back without broadcasting the literal", () => {
+ act(() => {
+ colorScheme.set("dark");
+ applyAndEchoPlatformWrite();
+ });
+
+ const { heard, stop } = recordChangeEvents();
+
+ act(() => {
+ setColorScheme086("unspecified");
+ });
+
+ // The same hand-back, spelled the way 0.86's type requires. "unspecified" is
+ // a request, never a scheme: broadcasting it puts a value in the cache that
+ // no `prefers-color-scheme` reader can match, and on 0.81 it trips
+ // `toColorScheme`'s invariant outright.
+ expect(heard).toStrictEqual([]);
+
+ act(() => {
+ applyAndEchoPlatformWrite();
+ });
+
+ expect(heard).toStrictEqual(["light"]);
+ expect(colorScheme.get()).toBe("light");
+
+ stop();
+});
+
+test("a redundant set of the scheme already in force announces nothing", () => {
+ act(() => {
+ colorScheme.set("dark");
+ applyAndEchoPlatformWrite();
+ });
+
+ const { heard, stop } = recordChangeEvents();
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ expect(heard).toStrictEqual([]);
+
+ stop();
+});
+
+test("set('unspecified') resolves to a renderable scheme before the platform echoes", () => {
+ act(() => {
+ colorScheme.set("dark");
+ applyAndEchoPlatformWrite();
+ });
+
+ act(() => {
+ setColorScheme086("unspecified");
+ });
+
+ // No echo yet. The test above steps straight past this window, which is why
+ // nothing caught the leak: "unspecified" is a REQUEST to follow the system,
+ // never a scheme, and the resolution chain totalizes on NULLISHNESS, so the
+ // literal passes through every `??` untouched.
+ //
+ // A reader handed it matches neither `prefers-color-scheme: dark` nor
+ // `: light`, so every scheme-conditional class goes dead rather than falling
+ // back — the app asks to follow a dark system and loses its dark styling.
+ // On Android nothing repairs it until the user toggles the system theme,
+ // because AppearanceModule only emits when the RESOLVED scheme changes.
+ expect(colorScheme.get()).not.toBe("unspecified");
+ expect(["dark", "light"]).toContain(colorScheme.get());
+});
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..c229f7c7
--- /dev/null
+++ b/src/__tests__/native/color-scheme-appearance.test.tsx
@@ -0,0 +1,270 @@
+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";
+
+// Under the jest preset TurboModuleRegistry.get("Appearance") is null, so
+// react-native's Appearance takes its absent-native branch: every read is null,
+// setColorScheme is a no-op and no `appearanceChanged` listener is registered.
+//
+// Faking that ONE module — rather than replacing Appearance itself — leaves the
+// real Libraries/Utilities/Appearance.js running, so the cache, the change
+// event, the `unspecified` coercion and their ordering are react-native's own
+// rather than a transcription of them. That matters here specifically: the
+// behaviour under test is which of Appearance's two write paths emits.
+jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => {
+ let deviceScheme: ColorSchemeName = "light";
+ const setColorSchemeCalls: string[] = [];
+
+ return {
+ __esModule: true,
+ default: {
+ // NativeEventEmitter's listener-refcount contract
+ addListener: () => undefined,
+ getColorScheme: () => deviceScheme,
+ readSetColorSchemeCalls: () => [...setColorSchemeCalls],
+ removeListeners: () => undefined,
+ setColorScheme: (next: string) => {
+ setColorSchemeCalls.push(next);
+ // The platform resolves "unspecified" to whatever it is following. With
+ // no OS behind this fake, that is nothing.
+ deviceScheme =
+ next === "unspecified" ? null : (next as ColorSchemeName);
+ },
+ writeDeviceScheme: (next: ColorSchemeName) => {
+ deviceScheme = next;
+ },
+ },
+ };
+});
+
+interface FakeNativeAppearance {
+ readSetColorSchemeCalls: () => string[];
+ writeDeviceScheme: (next: ColorSchemeName) => void;
+}
+
+const nativeAppearanceModule: { default: FakeNativeAppearance } =
+ jest.requireMock("react-native/Libraries/Utilities/NativeAppearance");
+const nativeAppearance = nativeAppearanceModule.default;
+
+// What an OS theme change is: the native module's own state moves, then it
+// emits `appearanceChanged`. Appearance.js registers the listener that turns
+// that event into its cache write and its `change` emit.
+const emitOperatingSystemChange = (scheme: ColorSchemeName): void => {
+ nativeAppearance.writeDeviceScheme(scheme);
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme });
+};
+
+// The shape of react-native's own useColorScheme: subscribe through
+// addChangeListener, snapshot through getColorScheme. The real hook cannot be
+// used here — react-native/jest/setup.js replaces it with jest.fn(() => "light")
+// — but it is this store, and so is every other documented way to track the
+// scheme.
+const subscribeToAppearance = (onStoreChange: () => void): (() => void) => {
+ const subscription = Appearance.addChangeListener(onStoreChange);
+ return () => {
+ subscription.remove();
+ };
+};
+
+const readAppearanceColorScheme = (): ColorSchemeName =>
+ Appearance.getColorScheme();
+
+const SubscribedColorScheme = () => {
+ const scheme = useSyncExternalStore(
+ subscribeToAppearance,
+ readAppearanceColorScheme,
+ );
+
+ return {scheme ?? "unset"};
+};
+
+const readSubscribedColorScheme = (): unknown =>
+ screen.getByTestId("subscribed-color-scheme").props.children;
+
+// Three-way, so "matched neither branch" is distinguishable from "matched light"
+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 GREEN = { color: "#008000" } as const;
+const BLUE = { color: "#00f" } as const;
+const RED = { color: "#f00" } as const;
+
+beforeEach(() => {
+ // Reset through the platform path, so the fixture does not depend on the
+ // setter under test
+ act(() => {
+ emitOperatingSystemChange("light");
+ });
+});
+
+test("colorScheme.set writes through to Appearance, so both readers agree", () => {
+ // useColorScheme() reads Appearance, the class layer reads the observable — one
+ // writer has to move both
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ // The argument, not just the resulting cache: without the write-through the cache
+ // would still read "light" here, but so would a fix that passed the wrong value
+ expect(nativeAppearance.readSetColorSchemeCalls().at(-1)).toBe("dark");
+ expect(Appearance.getColorScheme()).toBe("dark");
+
+ act(() => {
+ colorScheme.set("light");
+ });
+
+ expect(nativeAppearance.readSetColorSchemeCalls().at(-1)).toBe("light");
+ expect(Appearance.getColorScheme()).toBe("light");
+});
+
+test("colorScheme.set notifies Appearance's subscribers, not just its cache", () => {
+ // The write-through moves getColorScheme() and nothing else: RN's
+ // setColorScheme assigns the cache and calls the native module, and the only
+ // eventEmitter.emit("change") in Appearance.js is inside the native
+ // `appearanceChanged` handler. So a write the platform does not echo back
+ // moves the direct read and tells no subscriber.
+ const heard: ColorSchemeName[] = [];
+ const subscription = Appearance.addChangeListener((event) => {
+ heard.push(event.colorScheme);
+ });
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ expect(Appearance.getColorScheme()).toBe("dark");
+ expect(heard).toStrictEqual(["dark"]);
+
+ act(() => {
+ colorScheme.set("light");
+ });
+
+ expect(heard).toStrictEqual(["dark", "light"]);
+
+ subscription.remove();
+});
+
+test("a colorScheme.set to the scheme already in force announces nothing", () => {
+ // The announcement reports a change and never invents one. Same guard that
+ // keeps it silent where there is no native Appearance module to move, and
+ // the same equality the observable's own set applies
+ const heard: ColorSchemeName[] = [];
+ const subscription = Appearance.addChangeListener((event) => {
+ heard.push(event.colorScheme);
+ });
+
+ act(() => {
+ colorScheme.set("light");
+ });
+
+ expect(Appearance.getColorScheme()).toBe("light");
+ expect(heard).toStrictEqual([]);
+
+ subscription.remove();
+});
+
+test("a reader subscribed the way useColorScheme is moves with colorScheme.set", () => {
+ render();
+ expect(readSubscribedColorScheme()).toBe("light");
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ // Without the notification this reads "light" while Appearance.getColorScheme()
+ // already answers "dark" — the cache moved and the store was never told to
+ // re-read it
+ expect(readSubscribedColorScheme()).toBe("dark");
+});
+
+test("the class layer and a subscribed reader agree after one colorScheme.set", () => {
+ registerCSS(TRI_STATE_CSS);
+ render(
+ <>
+
+
+ >,
+ );
+
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ // The split this API exists to prevent: a `dark:` utility and a subscribed
+ // colour prop rendering different schemes in one tree
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(RED);
+ expect(readSubscribedColorScheme()).toBe("dark");
+ expect(colorScheme.get()).toBe("dark");
+});
+
+test("the class layer resolves the scheme the same way colorScheme.get() does", () => {
+ // The observable holds null at rest and after set(null). Reading it raw leaves every
+ // prefers-color-scheme query unmatched while get() reports a definite scheme, which is
+ // the same two-readers-disagree defect one function along
+ registerCSS(TRI_STATE_CSS);
+ render();
+
+ act(() => {
+ colorScheme.set(null);
+ });
+
+ expect(colorScheme.get()).toBe("light");
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE);
+});
+
+test("set(null) hands the scheme back to Appearance", () => {
+ act(() => {
+ colorScheme.set("dark");
+ });
+
+ act(() => {
+ colorScheme.set(null);
+ });
+
+ // "unspecified" is what RN's setColorScheme sends the platform for null
+ expect(nativeAppearance.readSetColorSchemeCalls().at(-1)).toBe("unspecified");
+ expect(Appearance.getColorScheme()).toBeNull();
+ expect(colorScheme.get()).toBe("light");
+});
+
+test("an OS change event repaints a mounted element", () => {
+ // Guards Appearance.addChangeListener in reactivity.ts, which nothing else covers —
+ // not this change, which does not touch it
+ registerCSS(TRI_STATE_CSS);
+
+ render();
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE);
+
+ act(() => {
+ emitOperatingSystemChange("dark");
+ });
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(RED);
+});
+
+test("a scheme the runtime cannot resolve matches no prefers-color-scheme query", () => {
+ // The unconditional rule is the floor. If both queries ever matched at once, or the
+ // fallback above silently picked a side on a platform that reports nothing, this is
+ // what would catch it
+ registerCSS(`.my-class { color: green; }`);
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(GREEN);
+});
diff --git a/src/native/api.tsx b/src/native/api.tsx
index 3d68a3aa..d530b454 100644
--- a/src/native/api.tsx
+++ b/src/native/api.tsx
@@ -1,6 +1,6 @@
/* eslint-disable */
import { useContext, useState, type ComponentType } from "react";
-import { Appearance } from "react-native";
+import { Appearance, DeviceEventEmitter } from "react-native";
import type { StyleDescriptor } from "react-native-css/compiler";
import { VariableContext } from "react-native-css/native-internal";
@@ -16,6 +16,7 @@ import { mappingToConfig, useNativeCss } from "./react/useNativeCss";
import { usePassthrough } from "./react/usePassthrough";
import {
colorScheme as colorSchemeObs,
+ resolveColorScheme,
VAR_SYMBOL,
type Effect,
type Getter,
@@ -70,10 +71,44 @@ export const styled = <
export const colorScheme: ColorScheme = {
get() {
- return colorSchemeObs.get() ?? Appearance.getColorScheme() ?? "light";
+ return resolveColorScheme(colorSchemeObs.get());
},
set(value) {
- return colorSchemeObs.set(value);
+ // Every reader, in one call. There are three, and they are three separate
+ // channels: the class layer reads the observable, useColorScheme() reads
+ // Appearance's cache, and every store built the documented way is wired to
+ // Appearance.addChangeListener. Moving one without the others splits the
+ // app's own UI
+ const previous = Appearance.getColorScheme();
+ Appearance.setColorScheme(value);
+ colorSchemeObs.set(value);
+
+ // RN's setColorScheme assigns the cache and calls the native module; the
+ // only eventEmitter.emit("change") in Libraries/Utilities/Appearance.js is
+ // inside the `appearanceChanged` handler. So a write the platform does not
+ // echo back moves getColorScheme() and notifies nobody. Announce it on the
+ // same device event the platform uses, so Appearance itself performs the
+ // cache write and the emit exactly as it does for an OS change.
+ //
+ // The announcement carries the REQUESTED scheme rather than a read of the
+ // cache, because what that cache holds at this point differs across the
+ // supported range: before 0.82 it is a read-back of the native module,
+ // which is stale on both platforms — Android posts the night-mode switch to
+ // the UI thread, iOS never assigns _currentColorScheme in the setter —
+ // while from 0.82 a resolved scheme is stored as requested. Reading it back
+ // would make this an announcement on one react-native and a no-op on
+ // another.
+ //
+ // Only a resolved scheme is announced. Every other member of
+ // ColorSchemeName is a hand-back rather than a scheme — null and undefined
+ // before 0.82, the literal "unspecified" from 0.82 on — and only the OS
+ // knows what one resolves to. Announcing it would put a value in
+ // Appearance's cache that no reader can render; the platform's own echo
+ // delivers the resolved scheme instead, exactly as it does for an OS
+ // change. `previous` keeps a set of the scheme already in force silent.
+ if ((value === "dark" || value === "light") && value !== previous) {
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: value });
+ }
},
};
diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts
index 75cd9006..36030d72 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,12 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean {
case "platform":
return value === "native" || value === Platform.OS;
case "prefers-color-scheme": {
- return value === get(colorScheme);
+ // The same resolution the public colorScheme.get() uses — through the one
+ // function both call, so the class layer and the prop layer cannot answer
+ // differently. Reading the raw observable instead leaves this matching
+ // neither light nor dark whenever it holds a non-scheme: null at rest and
+ // after set(null), "unspecified" after a follow-the-system request on 0.82+
+ 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 0824edeb..011236bd 100644
--- a/src/native/reactivity.ts
+++ b/src/native/reactivity.ts
@@ -221,6 +221,41 @@ export const colorScheme = observable(
);
Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));
+/**
+ * What a reader renders, from whatever the scheme channel is holding.
+ *
+ * Totalized over the scheme UNION rather than over nullishness, and that is the
+ * whole of it. `"unspecified"` is react-native 0.82's spelling of "follow the
+ * system" — the request 0.81 spells `null` — so it is a REQUEST, never a scheme.
+ * A `?? Appearance.getColorScheme() ?? "light"` chain only fires on nullish, so
+ * the literal passes straight through, and a reader handed it matches neither
+ * `prefers-color-scheme: dark` nor `: light`: every scheme-conditional class
+ * goes dead rather than falling back. On Android nothing repairs that until the
+ * user toggles the system theme, because `AppearanceModule` emits only when the
+ * RESOLVED scheme changes.
+ *
+ * It accepts a resolved scheme and rejects everything else, rather than naming
+ * the members it must reject. That is what makes it total: a future release can
+ * add another "no scheme yet" spelling and this keeps answering correctly,
+ * where a deny-list would silently gain a third hole. It is also why nothing
+ * here compares against `"unspecified"`, which is outside the `ColorSchemeName`
+ * the installed react-native declares.
+ *
+ * One function rather than the expression written at each reader, because both
+ * readers have to give the SAME answer — the class layer and the prop layer
+ * disagreeing about the scheme is the defect, not the duplication. The two
+ * copies this replaces had already drifted into being wrong together.
+ *
+ * `"light"` is the last resort, per MQ5 §5.4.
+ */
+export function resolveColorScheme(held: ColorSchemeName): "light" | "dark" {
+ if (held === "light" || held === "dark") {
+ return held;
+ }
+ const reported = Appearance.getColorScheme();
+ return reported === "light" || reported === "dark" ? reported : "light";
+}
+
/** Containers ****************************************************************/
export type ContainerContextValue = Record;