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..29dc5370
--- /dev/null
+++ b/src/__tests__/native/color-scheme-appearance-async.test.tsx
@@ -0,0 +1,252 @@
+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, so
+// the read-back quoted above answers with the requested value — the cache a
+// caller sees from react-native 0.82 on. `color-scheme-appearance-rn-0-86.test.tsx`
+// covers the rest of the current release's setter.
+//
+// Three suites are not three cache-write rules, and the declared peer range
+// (`react-native >= 0.81`) holds three of those:
+// `color-scheme-appearance-cache-rules.test.tsx` is the census, and it is where
+// the 0.82.0-0.84.1 rule between the two ends is 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-cache-rules.test.tsx b/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx
new file mode 100644
index 00000000..83891de1
--- /dev/null
+++ b/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx
@@ -0,0 +1,322 @@
+import { Appearance, 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";
+
+// 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: unknown;
+}
+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 };
+};
+interface CacheWriteRule {
+ handBack: unknown;
+ setColorScheme: (requested: unknown) => unknown;
+}
+
+// `react-native >= 0.81` is the declared peer range, and across it
+// `Appearance.setColorScheme` writes its cache by three different rules. The
+// mock below is the census of those three, transcribed from
+// Libraries/Utilities/Appearance.js at each boundary, and every test here runs
+// against all three rather than against whichever react-native is installed.
+//
+// The device under all three is the same, and it is the platform both OSes
+// actually implement:
+//
+// - `NativeAppearance.getColorScheme()` answers the CONFIGURATION IN FORCE,
+// never a request. Android returns `UiModeUtils.isDarkMode(context)`
+// resolved to "dark"/"light"; iOS returns `_currentColorScheme`, assigned
+// at init and in `appearanceChanged:` only. Neither can answer
+// "unspecified".
+// - `NativeAppearance.setColorScheme()` does not apply inline. Android wraps
+// `AppCompatDelegate.setDefaultNightMode` in `UiThreadUtil.runOnUiThread`;
+// iOS's `setColorScheme:` never assigns `_currentColorScheme`.
+// - `appearanceChanged` fires only when the RESOLVED scheme changes —
+// `AppearanceModule.onConfigurationChanged` guards on
+// `lastEmittedColorScheme != newColorScheme`, and `RCTAppearance` guards on
+// `![_currentColorScheme isEqualToString:newColorScheme]`. Re-affirming the
+// scheme already in force emits nothing at all.
+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;
+
+ let operatingSystemScheme = "dark";
+ let appliedScheme = operatingSystemScheme;
+ let pendingRequest: string | undefined;
+
+ const nativeAppearance = {
+ addListener: () => undefined,
+ // `?ColorSchemeName` in the TurboModule spec — null where there is no
+ // native Appearance module at all
+ getColorScheme: (): string | null => appliedScheme,
+ removeListeners: () => undefined,
+ setColorScheme: (next: string) => {
+ pendingRequest = next;
+ },
+ };
+
+ // The census. Each entry is one release band's `setColorScheme` body, and
+ // nothing else about that release differs on the path these tests walk.
+ const cacheWriteRules = new Map([
+ // NativeAppearance.setColorScheme(colorScheme ?? 'unspecified');
+ // state.appearance = {colorScheme: toColorScheme(NativeAppearance.getColorScheme())};
+ [
+ "<=0.81.5",
+ {
+ // 0.81's Appearance.d.ts declares ColorSchemeName as
+ // 'light' | 'dark' | null | undefined, so null is how a caller on this
+ // band spells "follow the system"
+ handBack: null,
+ setColorScheme: (requested: unknown) => {
+ nativeAppearance.setColorScheme(
+ (requested as string | null) ?? "unspecified",
+ );
+ return nativeAppearance.getColorScheme();
+ },
+ },
+ ],
+ // NativeAppearance.setColorScheme(colorScheme);
+ // state.appearance = {colorScheme};
+ [
+ "0.82.0-0.84.1",
+ {
+ // 0.82's Appearance.d.ts declares ColorSchemeName as
+ // 'light' | 'dark' | 'unspecified' and null leaves the type
+ handBack: "unspecified",
+ setColorScheme: (requested: unknown) => {
+ nativeAppearance.setColorScheme(requested as string);
+ return requested;
+ },
+ },
+ ],
+ // NativeAppearance.setColorScheme(colorScheme);
+ // state.appearance = {colorScheme: colorScheme === 'unspecified'
+ // ? (NativeAppearance.getColorScheme() ?? colorScheme) : colorScheme};
+ [
+ ">=0.85.3",
+ {
+ handBack: "unspecified",
+ setColorScheme: (requested: unknown) => {
+ nativeAppearance.setColorScheme(requested as string);
+ return requested === "unspecified"
+ ? (nativeAppearance.getColorScheme() ?? requested)
+ : requested;
+ },
+ },
+ ],
+ ]);
+
+ const readRule = (name: string): CacheWriteRule => {
+ const rule = cacheWriteRules.get(name);
+ if (rule === undefined) {
+ throw new Error(`No cache-write rule named ${name}`);
+ }
+ return rule;
+ };
+
+ let activeRule = ">=0.85.3";
+
+ const eventEmitter = new EventEmitter();
+ let appearance: { colorScheme: unknown } | undefined;
+
+ new NativeEventEmitter(nativeAppearance).addListener(
+ "appearanceChanged",
+ (newAppearance) => {
+ appearance = { colorScheme: newAppearance.colorScheme };
+ eventEmitter.emit("change", appearance);
+ },
+ );
+
+ return {
+ addChangeListener: (
+ listener: (payload: { colorScheme: unknown }) => void,
+ ) => eventEmitter.addListener("change", listener),
+ getColorScheme: () => {
+ appearance ??= { colorScheme: nativeAppearance.getColorScheme() };
+ return appearance.colorScheme;
+ },
+ setColorScheme: (requested: unknown) => {
+ appearance = {
+ colorScheme: readRule(activeRule).setColorScheme(requested),
+ };
+ },
+
+ // Test seams — the census, the band selector, and the device
+ cacheWriteRuleNames: () => [...cacheWriteRules.keys()],
+ handBackFor: (rule: string) => readRule(rule).handBack,
+ useCacheWriteRule: (rule: string) => {
+ activeRule = rule;
+ },
+ // The device: the OS preference, the configuration in force, and the JS
+ // cache all put back to `scheme`, without going through the setter under
+ // test
+ resetDeviceTo: (scheme: string) => {
+ operatingSystemScheme = scheme;
+ appliedScheme = scheme;
+ pendingRequest = undefined;
+ appearance = { colorScheme: scheme };
+ },
+ // The UI-thread post landing. Answers with the scheme now in force, and
+ // whether the platform would echo it — it only does when that scheme
+ // CHANGED.
+ applyPendingWrite: () => {
+ const before = appliedScheme;
+ if (pendingRequest !== undefined) {
+ appliedScheme =
+ pendingRequest === "unspecified"
+ ? operatingSystemScheme
+ : pendingRequest;
+ pendingRequest = undefined;
+ }
+ return { applied: appliedScheme, emits: appliedScheme !== before };
+ },
+ };
+});
+
+interface CacheRuleSeams {
+ cacheWriteRuleNames: () => string[];
+ handBackFor: (rule: string) => ColorSchemeName;
+ useCacheWriteRule: (rule: string) => void;
+ resetDeviceTo: (scheme: string) => void;
+ applyPendingWrite: () => { applied: string; emits: boolean };
+}
+
+const {
+ cacheWriteRuleNames,
+ handBackFor,
+ useCacheWriteRule,
+ resetDeviceTo,
+ applyPendingWrite,
+} = jest.requireMock(
+ "react-native/Libraries/Utilities/Appearance",
+);
+
+// react-native 0.82+ 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 that band writes.
+const setColorSchemeAcrossBands = colorScheme.set as (
+ value: ColorSchemeName,
+) => void;
+
+const emitAppearanceChanged = (scheme: string): void => {
+ const { DeviceEventEmitter } = jest.requireActual<{
+ DeviceEventEmitter: { emit: (event: string, payload: unknown) => void };
+ }>("react-native");
+
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme });
+};
+
+// Put the whole world — the OS, the configuration in force, react-native's
+// cache and react-native-css's observable — on `scheme`, through the platform
+// path rather than through the setter under test.
+const settleEverythingOn = (scheme: string): void => {
+ act(() => {
+ resetDeviceTo(scheme);
+ emitAppearanceChanged(scheme);
+ });
+};
+
+const DARK_CONDITIONAL_CSS = `
+.my-class { color: green; }
+
+@media (prefers-color-scheme: light) {
+ .my-class { color: blue; }
+}
+
+@media (prefers-color-scheme: dark) {
+ .my-class { color: red; }
+}`;
+
+const RED = { color: "#f00" } as const;
+
+const ruleNames = cacheWriteRuleNames();
+
+test("the cache-write census is not empty", () => {
+ // The vacuity guard: an empty census would make every describe.each below
+ // generate no cases and leave this file silently green
+ expect(ruleNames.length).toBeGreaterThan(0);
+});
+
+describe.each(ruleNames)("react-native %s", (rule) => {
+ beforeEach(() => {
+ useCacheWriteRule(rule);
+ settleEverythingOn("dark");
+ });
+
+ test("the fixture starts with every layer on the dark system scheme", () => {
+ expect(Appearance.getColorScheme()).toBe("dark");
+ expect(colorScheme.get()).toBe("dark");
+ });
+
+ test("a follow-the-system request leaves colorScheme.get() on the OS scheme", () => {
+ act(() => {
+ setColorSchemeAcrossBands(handBackFor(rule));
+ });
+
+ // The user asked to follow the system and the system is dark. What the
+ // caller wrote is identical on every band; what they read back must be too
+ expect(colorScheme.get()).toBe("dark");
+ });
+
+ test("a follow-the-system request leaves the class layer on the OS scheme", () => {
+ registerCSS(DARK_CONDITIONAL_CSS);
+ render();
+
+ act(() => {
+ setColorSchemeAcrossBands(handBackFor(rule));
+ });
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual(RED);
+ });
+
+ test("a follow-the-system request leaves Appearance answering a scheme", () => {
+ // Not this library's channel — this is react-native's own cache, which
+ // `useColorScheme()` and every store built the documented way read.
+ // `colorScheme.set` is the only caller of `Appearance.setColorScheme` here,
+ // so whatever this answers afterwards is what the library left in the app's
+ // cache for everyone else.
+ act(() => {
+ setColorSchemeAcrossBands(handBackFor(rule));
+ });
+
+ expect(["light", "dark"]).toContain(Appearance.getColorScheme());
+ });
+
+ test("no platform echo arrives to repair it, because the resolved scheme never changed", () => {
+ act(() => {
+ setColorSchemeAcrossBands(handBackFor(rule));
+ });
+
+ act(() => {
+ const { applied, emits } = applyPendingWrite();
+
+ // Re-affirming the scheme already in force resolves to the same scheme,
+ // so neither AppearanceModule nor RCTAppearance emits. Nothing arrives to
+ // overwrite a cache left holding a non-scheme; on Android the next event
+ // is the user toggling their system theme.
+ expect(applied).toBe("dark");
+ expect(emits).toBe(false);
+ });
+
+ expect(colorScheme.get()).toBe("dark");
+ });
+});
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..81b7d261
--- /dev/null
+++ b/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx
@@ -0,0 +1,308 @@
+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. The rest
+// of the distance to the shape above is 0.82.0, in one release rather than
+// spread across the range: `toColorScheme` and its invariant were deleted, and
+// `Libraries/Utilities/Appearance.d.ts` re-declared `ColorSchemeName` from
+// 'light' | 'dark' | null | undefined to 'light' | 'dark' | 'unspecified'. So
+// from 0.82 on, "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 the 0.81 shape and the
+// 0.86 shape are both shipping and no single installed react-native can express
+// both. The two sibling suites drive the installed module; this one stands in
+// for the current release, 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.
+//
+// Neither shape is the whole range. `color-scheme-appearance-cache-rules.test.tsx`
+// carries the census of all three cache-write rules, including the
+// 0.82.0-0.84.1 one that neither this file nor the installed module can reach.
+
+// 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..8fa1edc2 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,8 @@ import { mappingToConfig, useNativeCss } from "./react/useNativeCss";
import { usePassthrough } from "./react/usePassthrough";
import {
colorScheme as colorSchemeObs,
+ holdsRequestNotScheme,
+ resolveColorScheme,
VAR_SYMBOL,
type Effect,
type Getter,
@@ -70,10 +72,66 @@ 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();
+
+ // Resolved BEFORE the write, because on react-native 0.82.0-0.84.1 the
+ // write is what destroys the ability to resolve: that band caches the
+ // REQUESTED value verbatim, so a follow-the-system request leaves
+ // Appearance.getColorScheme() answering the literal "unspecified" to every
+ // reader in the app. react-native removed that in 0.85.3 by caching the
+ // scheme in force instead; on the band that did not, this is the scheme in
+ // force.
+ const inForce = resolveColorScheme(colorSchemeObs.get());
+
+ 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 });
+ } else if (holdsRequestNotScheme(Appearance.getColorScheme())) {
+ // The hand-back went through, and on 0.82.0-0.84.1 it left react-native's
+ // own cache holding the request. That cache is not this library's — it is
+ // what `useColorScheme()` and every documented store read — so routing
+ // around it would leave the app answering "unspecified" while this
+ // library answered correctly. Put the scheme in force back where every
+ // reader looks for it, on the same device event the platform uses, and
+ // Appearance performs the cache write and the emit exactly as it does for
+ // an OS change. Nothing is announced on the bands whose cache can still
+ // answer, because there the platform's own echo is still the only thing
+ // that should move the scheme.
+ DeviceEventEmitter.emit("appearanceChanged", { colorScheme: inForce });
+ }
},
};
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..12c076d0 100644
--- a/src/native/reactivity.ts
+++ b/src/native/reactivity.ts
@@ -221,6 +221,63 @@ 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";
+}
+
+/**
+ * Whether `Appearance`'s cache is holding a REQUEST rather than an answer.
+ *
+ * `NativeAppearance.getColorScheme()` cannot produce one on either platform:
+ * Android resolves the configuration to "dark" or "light"
+ * (`AppearanceModule.colorSchemeForCurrentConfiguration`) and iOS returns
+ * `_currentColorScheme`. So the only way a non-nullish non-scheme reaches that
+ * cache is react-native 0.82.0-0.84.1 storing a `setColorScheme` argument
+ * verbatim — the band between the read-back 0.81 performs on every path and the
+ * read-back 0.85.3 restored for `"unspecified"` alone.
+ *
+ * Nullish is not that, and stays a real answer: it means the platform reports
+ * no scheme at all, which is what the `"light"` last resort is for.
+ */
+export function holdsRequestNotScheme(held: ColorSchemeName): boolean {
+ return held !== null && held !== undefined && !isScheme(held);
+}
+
+function isScheme(held: ColorSchemeName): held is "light" | "dark" {
+ return held === "light" || held === "dark";
+}
+
/** Containers ****************************************************************/
export type ContainerContextValue = Record;