From ba8bb199056f937d4e9a65388159123c01e6ec15 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 20:21:36 +0300 Subject: [PATCH 1/7] fix(native): write colorScheme.set through to Appearance `colorScheme.set()` moved only this library's observable, so the class layer and React Native's own readers disagreed. `useColorScheme()` and every prop-valued colour read `Appearance`; `dark:` utilities read the observable. An app calling the documented setter moved one and not the other, and rendered a light canvas under dark chrome. Writing both in the one call is the whole fix. It does not try to make a direct `Appearance.setColorScheme()` visible to the class layer: that writer emits no event, and the class layer is push-based, so nothing short of a notification can move an already-mounted element. --- .../native/color-scheme-appearance.test.tsx | 114 ++++++++++++++++++ src/native/api.tsx | 5 +- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/native/color-scheme-appearance.test.tsx 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..1fd4ede5 --- /dev/null +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -0,0 +1,114 @@ +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"; + +// A stand-in for Appearance, matching react-native/Libraries/Utilities/Appearance.js: +// getColorScheme reads a process-lifetime cache and setColorScheme writes it. Under the +// jest preset the real module is the absent-native branch, where every read is null and +// setColorScheme is a no-op, so it cannot express this +type ChangeListener = (event: { colorScheme: ColorSchemeName }) => void; + +interface FakeAppearance { + getColorScheme: () => ColorSchemeName; + setColorScheme: (scheme: ColorSchemeName) => void; + addChangeListener: (listener: ChangeListener) => { remove: () => void }; + emitOperatingSystemChange: (scheme: ColorSchemeName) => void; +} + +jest.mock("react-native", () => { + const ReactNative = jest.requireActual("react-native"); + + let cachedScheme: ColorSchemeName = "light"; + const listeners = new Set(); + + const fakeAppearance: FakeAppearance = { + getColorScheme: () => cachedScheme, + setColorScheme: (scheme) => { + cachedScheme = scheme; + }, + addChangeListener: (listener) => { + listeners.add(listener); + return { + remove: () => { + listeners.delete(listener); + }, + }; + }, + emitOperatingSystemChange: (scheme) => { + cachedScheme = scheme; + for (const listener of listeners) { + listener({ colorScheme: scheme }); + } + }, + }; + + Object.defineProperty(ReactNative, "Appearance", { + configurable: true, + get: () => fakeAppearance, + }); + + return ReactNative as unknown; +}); + +const appearance = Appearance as unknown as FakeAppearance; + +const DARK_SCHEME_CSS = ` +.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(() => { + appearance.setColorScheme("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"); + }); + + expect(colorScheme.get()).toBe("dark"); + expect(appearance.getColorScheme()).toBe("dark"); + + act(() => { + colorScheme.set("light"); + }); + + expect(colorScheme.get()).toBe("light"); + expect(appearance.getColorScheme()).toBe("light"); +}); + +test("colorScheme.set repaints a mounted element", () => { + registerCSS(DARK_SCHEME_CSS); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); + + act(() => { + colorScheme.set("dark"); + }); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); +}); + +test("an OS change event still repaints a mounted element", () => { + registerCSS(DARK_SCHEME_CSS); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); + + act(() => { + appearance.emitOperatingSystemChange("dark"); + }); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); +}); diff --git a/src/native/api.tsx b/src/native/api.tsx index 3d68a3aa..e535982a 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -73,7 +73,10 @@ export const colorScheme: ColorScheme = { return colorSchemeObs.get() ?? Appearance.getColorScheme() ?? "light"; }, set(value) { - return colorSchemeObs.set(value); + // Both readers, in one call: useColorScheme() reads Appearance, the class layer + // reads the observable. Moving one without the other splits the app's own UI + Appearance.setColorScheme(value); + colorSchemeObs.set(value); }, }; From 47e7fa89ee88193cdce119a1e9c5a46a0419b6e3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 21:32:36 +0300 Subject: [PATCH 2/7] fix(native): resolve prefers-color-scheme the way colorScheme.get() does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There were two sources of truth for the scheme with different null semantics. `colorScheme.get()` coalesces through Appearance to a definite value; the class layer read the raw observable. The observable holds null at rest and after `set(null)`, so `prefers-color-scheme: light` and `dark` both failed while `get()` reported light — the element fell through to its unconditional rule. That is the same two-readers-disagree defect this branch is named for, one function along, and it is reachable through the setter the branch just changed. The tests are rewritten around what each one actually pins. The repaint case duplicated media-query.test.tsx byte for byte and is gone; the OS-event case stays, relabelled as the guard it is for Appearance.addChangeListener. The write-through assertion now checks the argument rather than the resulting cache, which passed under every mutation because get() falls back to Appearance. The fixture is three-way so "matched neither branch" is distinguishable from "matched light" — the failure above is invisible to a two-colour fixture. --- .../native/color-scheme-appearance.test.tsx | 67 ++++++++++++++++--- src/native/conditions/media-query.ts | 9 ++- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/__tests__/native/color-scheme-appearance.test.tsx b/src/__tests__/native/color-scheme-appearance.test.tsx index 1fd4ede5..e3555406 100644 --- a/src/__tests__/native/color-scheme-appearance.test.tsx +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -8,7 +8,12 @@ import { colorScheme } from "react-native-css/runtime"; // A stand-in for Appearance, matching react-native/Libraries/Utilities/Appearance.js: // getColorScheme reads a process-lifetime cache and setColorScheme writes it. Under the // jest preset the real module is the absent-native branch, where every read is null and -// setColorScheme is a no-op, so it cannot express this +// setColorScheme is a no-op, so it cannot express this. +// +// It cannot reach useColorScheme, and no fake can: react-native/jest/setup.js:122 replaces +// that hook with jest.fn(() => "light"), and the real one imports { getColorScheme } from +// ./Appearance directly rather than through the namespace object replaced below. So the +// claim that RN's own readers now agree is argued from Appearance's semantics, not tested. type ChangeListener = (event: { colorScheme: ColorSchemeName }) => void; interface FakeAppearance { @@ -16,17 +21,20 @@ interface FakeAppearance { setColorScheme: (scheme: ColorSchemeName) => void; addChangeListener: (listener: ChangeListener) => { remove: () => void }; emitOperatingSystemChange: (scheme: ColorSchemeName) => void; + readSetCalls: () => ColorSchemeName[]; } jest.mock("react-native", () => { const ReactNative = jest.requireActual("react-native"); let cachedScheme: ColorSchemeName = "light"; + const setCalls: ColorSchemeName[] = []; const listeners = new Set(); const fakeAppearance: FakeAppearance = { getColorScheme: () => cachedScheme, setColorScheme: (scheme) => { + setCalls.push(scheme); cachedScheme = scheme; }, addChangeListener: (listener) => { @@ -43,6 +51,7 @@ jest.mock("react-native", () => { listener({ colorScheme: scheme }); } }, + readSetCalls: () => setCalls, }; Object.defineProperty(ReactNative, "Appearance", { @@ -55,13 +64,19 @@ jest.mock("react-native", () => { const appearance = Appearance as unknown as FakeAppearance; -const DARK_SCHEME_CSS = ` -.my-class { color: blue; } +// 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; @@ -76,32 +91,52 @@ test("colorScheme.set writes through to Appearance, so both readers agree", () = colorScheme.set("dark"); }); - expect(colorScheme.get()).toBe("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(appearance.readSetCalls().at(-1)).toBe("dark"); expect(appearance.getColorScheme()).toBe("dark"); act(() => { colorScheme.set("light"); }); - expect(colorScheme.get()).toBe("light"); + expect(appearance.readSetCalls().at(-1)).toBe("light"); expect(appearance.getColorScheme()).toBe("light"); }); -test("colorScheme.set repaints a mounted element", () => { - registerCSS(DARK_SCHEME_CSS); - +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"); }); - expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); + act(() => { + colorScheme.set(null); + }); + + expect(appearance.readSetCalls().at(-1)).toBeNull(); + expect(appearance.getColorScheme()).toBeNull(); + expect(colorScheme.get()).toBe("light"); }); -test("an OS change event still repaints a mounted element", () => { - registerCSS(DARK_SCHEME_CSS); +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); @@ -112,3 +147,13 @@ test("an OS change event still repaints a mounted element", () => { 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/conditions/media-query.ts b/src/native/conditions/media-query.ts index 75cd9006..31168f91 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -import { I18nManager, PixelRatio, Platform } from "react-native"; +import { Appearance, I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; @@ -45,7 +45,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. Reading the raw + // observable instead leaves the class layer matching neither light nor dark + // whenever it holds null — which is its value at rest, and after set(null) + return ( + value === (get(colorScheme) ?? Appearance.getColorScheme() ?? "light") + ); } case "display-mode": return value === "native" || Platform.OS === value; From 727759fc8c767e4179514d1c0357fcd646dc1098 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 20:01:02 +0300 Subject: [PATCH 3/7] fix(native): announce colorScheme.set to Appearance's subscribers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The write-through moved two of the three readers. React Native's setColorScheme assigns Appearance's cache and calls the native module; the only eventEmitter.emit("change") in Libraries/Utilities/Appearance.js sits inside the native `appearanceChanged` handler. So a write the platform does not echo back moves getColorScheme() and notifies nobody — and every documented way to track the scheme, useColorScheme included, is useSyncExternalStore over addChangeListener. colorScheme.set now announces the change 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. DeviceEventEmitter is a public react-native export and `appearanceChanged` is the event Appearance subscribes to through NativeEventEmitter, which registers on that same emitter. Guarded on the cache having actually moved, so this reports a change and never invents one: where there is no native Appearance module the write is a no-op and both reads are null, and a redundant set of the scheme already in force stays silent, matching the observable's own equality guard. The suite now fakes Libraries/Utilities/NativeAppearance rather than replacing Appearance itself, so the cache, the change event, the "unspecified" coercion and their ordering are react-native's own instead of a transcription of them. That is what lets the OS-change test drive the real platform path, and what makes the subscriber claim measurable rather than argued. --- .../native/color-scheme-appearance.test.tsx | 227 +++++++++++++----- src/native/api.tsx | 26 +- 2 files changed, 192 insertions(+), 61 deletions(-) diff --git a/src/__tests__/native/color-scheme-appearance.test.tsx b/src/__tests__/native/color-scheme-appearance.test.tsx index e3555406..c229f7c7 100644 --- a/src/__tests__/native/color-scheme-appearance.test.tsx +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -1,68 +1,94 @@ -import { Appearance, type ColorSchemeName } from "react-native"; +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"; -// A stand-in for Appearance, matching react-native/Libraries/Utilities/Appearance.js: -// getColorScheme reads a process-lifetime cache and setColorScheme writes it. Under the -// jest preset the real module is the absent-native branch, where every read is null and -// setColorScheme is a no-op, so it cannot express this. +// 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. // -// It cannot reach useColorScheme, and no fake can: react-native/jest/setup.js:122 replaces -// that hook with jest.fn(() => "light"), and the real one imports { getColorScheme } from -// ./Appearance directly rather than through the namespace object replaced below. So the -// claim that RN's own readers now agree is argued from Appearance's semantics, not tested. -type ChangeListener = (event: { colorScheme: ColorSchemeName }) => void; - -interface FakeAppearance { - getColorScheme: () => ColorSchemeName; - setColorScheme: (scheme: ColorSchemeName) => void; - addChangeListener: (listener: ChangeListener) => { remove: () => void }; - emitOperatingSystemChange: (scheme: ColorSchemeName) => void; - readSetCalls: () => ColorSchemeName[]; +// 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; } -jest.mock("react-native", () => { - const ReactNative = jest.requireActual("react-native"); +const nativeAppearanceModule: { default: FakeNativeAppearance } = + jest.requireMock("react-native/Libraries/Utilities/NativeAppearance"); +const nativeAppearance = nativeAppearanceModule.default; - let cachedScheme: ColorSchemeName = "light"; - const setCalls: ColorSchemeName[] = []; - const listeners = new Set(); +// 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 }); +}; - const fakeAppearance: FakeAppearance = { - getColorScheme: () => cachedScheme, - setColorScheme: (scheme) => { - setCalls.push(scheme); - cachedScheme = scheme; - }, - addChangeListener: (listener) => { - listeners.add(listener); - return { - remove: () => { - listeners.delete(listener); - }, - }; - }, - emitOperatingSystemChange: (scheme) => { - cachedScheme = scheme; - for (const listener of listeners) { - listener({ colorScheme: scheme }); - } - }, - readSetCalls: () => setCalls, +// 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(); }; +}; - Object.defineProperty(ReactNative, "Appearance", { - configurable: true, - get: () => fakeAppearance, - }); +const readAppearanceColorScheme = (): ColorSchemeName => + Appearance.getColorScheme(); - return ReactNative as unknown; -}); +const SubscribedColorScheme = () => { + const scheme = useSyncExternalStore( + subscribeToAppearance, + readAppearanceColorScheme, + ); -const appearance = Appearance as unknown as FakeAppearance; + 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 = ` @@ -81,7 +107,11 @@ const BLUE = { color: "#00f" } as const; const RED = { color: "#f00" } as const; beforeEach(() => { - appearance.setColorScheme("light"); + // 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", () => { @@ -93,15 +123,95 @@ test("colorScheme.set writes through to Appearance, so both readers agree", () = // 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(appearance.readSetCalls().at(-1)).toBe("dark"); - expect(appearance.getColorScheme()).toBe("dark"); + 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(appearance.readSetCalls().at(-1)).toBe("light"); - expect(appearance.getColorScheme()).toBe("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", () => { @@ -128,8 +238,9 @@ test("set(null) hands the scheme back to Appearance", () => { colorScheme.set(null); }); - expect(appearance.readSetCalls().at(-1)).toBeNull(); - expect(appearance.getColorScheme()).toBeNull(); + // "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"); }); @@ -142,7 +253,7 @@ test("an OS change event repaints a mounted element", () => { expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); act(() => { - appearance.emitOperatingSystemChange("dark"); + emitOperatingSystemChange("dark"); }); expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); diff --git a/src/native/api.tsx b/src/native/api.tsx index e535982a..3bd41646 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"; @@ -73,10 +73,30 @@ export const colorScheme: ColorScheme = { return colorSchemeObs.get() ?? Appearance.getColorScheme() ?? "light"; }, set(value) { - // Both readers, in one call: useColorScheme() reads Appearance, the class layer - // reads the observable. Moving one without the other splits the app's own UI + // 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. + // + // Guarded on the cache having actually moved, so this reports a change and + // never invents one: where there is no native Appearance module the write + // above is a no-op and both reads are null, and a redundant set of the + // current scheme is silent — matching the observable's own equality guard. + const current = Appearance.getColorScheme(); + if (current !== previous) { + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: current }); + } }, }; From 13b29583270744c27fe55d58f4745c11d43b6cef Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 21:13:23 +0300 Subject: [PATCH 4/7] fix(native): announce the requested scheme, not a read of the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving the announcement from Appearance's cache made it depend on the one expression react-native changed at 0.86. `setColorScheme` writes that cache from the requested value on 0.86+; before it, from `toColorScheme(NativeAppearance.getColorScheme())` — a read-back that is stale on both platforms, because Android posts the night-mode switch to the UI thread through `UiThreadUtil.runOnUiThread` (`postDelayed(r, 0)`) and iOS never assigns `_currentColorScheme` in `setColorScheme:`. So the cache-derived guard suppressed its own emit on every react-native below 0.86 and left subscribers to the platform echo, and on 0.86 it broadcast `{colorScheme: null}` for `set(null)` — a scheme no reader can render. The announcement now carries what the caller asked for, so it says the same thing on every version in the declared peer range, and only a resolved scheme is announced. Every other member of ColorSchemeName is a hand-back rather than a scheme — null and undefined before 0.86, the literal "unspecified" from 0.86 on — and only the OS knows what one resolves to; its own echo delivers that. Two suites cover the two cache-write rules, both driving the setter against a platform that applies the write on a later turn, through an explicit flush seam rather than a timer. The 0.81 suite runs the installed Appearance over an asynchronous NativeAppearance; the 0.86 one transcribes the four functions of that version's Appearance.js, which cannot be installed beside it, and keeps react-native's own NativeEventEmitter registration so what reaches its cache is what reaches the real one. --- .../color-scheme-appearance-async.test.tsx | 248 ++++++++++++++++ .../color-scheme-appearance-rn-0-86.test.tsx | 278 ++++++++++++++++++ src/native/api.tsx | 24 +- 3 files changed, 543 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/native/color-scheme-appearance-async.test.tsx create mode 100644 src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx 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..df90e7f8 --- /dev/null +++ b/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx @@ -0,0 +1,278 @@ +import { Appearance, type ColorSchemeName } from "react-native"; + +import { act } from "@testing-library/react-native"; +import { colorScheme } from "react-native-css/runtime"; + +// react-native 0.86 rewrote the one expression the announcement used to read. +// `setColorScheme` writes the cache from the REQUESTED value, and the native +// read-back survives only for the literal "unspecified": +// +// 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(); +}); diff --git a/src/native/api.tsx b/src/native/api.tsx index 3bd41646..31e1fbe8 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -89,13 +89,23 @@ export const colorScheme: ColorScheme = { // same device event the platform uses, so Appearance itself performs the // cache write and the emit exactly as it does for an OS change. // - // Guarded on the cache having actually moved, so this reports a change and - // never invents one: where there is no native Appearance module the write - // above is a no-op and both reads are null, and a redundant set of the - // current scheme is silent — matching the observable's own equality guard. - const current = Appearance.getColorScheme(); - if (current !== previous) { - DeviceEventEmitter.emit("appearanceChanged", { colorScheme: current }); + // 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: from 0.86 it is the requested value, and before that 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. 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.86, the literal "unspecified" from 0.86 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 }); } }, }; From 79c1b06b5b535e755781912078ac944f04d26819 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 17:48:45 +0300 Subject: [PATCH 5/7] fix(native): resolve the scheme over the union, not over nullishness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `colorScheme.get()` and the `prefers-color-scheme` evaluator both resolved what the scheme channel holds with `?? Appearance.getColorScheme() ?? "light"`. That chain fires only on a nullish value, and `"unspecified"` — 0.86's spelling of "follow the system", where 0.81 spells `null` — is not nullish. It passes straight through. A reader handed the literal matches neither `prefers-color-scheme: dark` nor `: light`, so an app that asks to follow a dark system loses every scheme-conditional class rather than falling back to one. On Android nothing repairs that until the user toggles the system theme, because AppearanceModule emits only when the resolved scheme changes. `resolveColorScheme` 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 it keeps answering correctly, where a deny-list would silently gain a third hole. Both readers call it, because the class layer and the prop layer disagreeing about the scheme is the defect — the two copies it replaces had already drifted into being wrong together. The existing `set('unspecified')` test stepped past the window, asserting only after the platform echo repairs it. The new test asserts inside it. --- .../color-scheme-appearance-rn-0-86.test.tsx | 24 +++++++++++++ src/native/api.tsx | 3 +- src/native/conditions/media-query.ts | 22 +++++++----- src/native/reactivity.ts | 35 +++++++++++++++++++ 4 files changed, 75 insertions(+), 9 deletions(-) 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 index df90e7f8..1bc7a074 100644 --- a/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx +++ b/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx @@ -276,3 +276,27 @@ test("a redundant set of the scheme already in force announces nothing", () => { 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/native/api.tsx b/src/native/api.tsx index 31e1fbe8..a34bb1b8 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -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,7 +71,7 @@ export const styled = < export const colorScheme: ColorScheme = { get() { - return colorSchemeObs.get() ?? Appearance.getColorScheme() ?? "light"; + return resolveColorScheme(colorSchemeObs.get()); }, set(value) { // Every reader, in one call. There are three, and they are three separate diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 31168f91..0b93db3e 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -1,9 +1,15 @@ /* eslint-disable */ -import { Appearance, I18nManager, PixelRatio, Platform } from "react-native"; +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,12 +51,12 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "platform": return value === "native" || value === Platform.OS; case "prefers-color-scheme": { - // The same resolution the public colorScheme.get() uses. Reading the raw - // observable instead leaves the class layer matching neither light nor dark - // whenever it holds null — which is its value at rest, and after set(null) - return ( - value === (get(colorScheme) ?? Appearance.getColorScheme() ?? "light") - ); + // 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.86 + 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..6311678f 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.86'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; From 0832a5bb3056fd84d68b9eb28d39f95c2d070ea6 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 17 Aug 2026 01:59:02 +0300 Subject: [PATCH 6/7] docs(native): name the react-native version the colour-scheme break starts at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These comments say 0.86 in four places. Measured across every react-native in the Yarn cache, the break starts at 0.82.0 — four minor versions earlier: 0.81.2 / 0.81.4 / 0.81.5 ColorSchemeName = 'light' | 'dark' | null | undefined 0.82.0 … 0.86.0 ColorSchemeName = 'light' | 'dark' | 'unspecified' The runtime moves in the same release. 0.81.5 coerces the nullish request — `NativeAppearance.setColorScheme(colorScheme ?? 'unspecified')` — and 0.82.0 passes the argument through verbatim. #429 already names 0.82 on its own surface, so left alone the two branches would land in one tree disagreeing about one boundary. The cache-write sentence gets rewritten rather than renumbered, because the range has three states and not two: before 0.82 the cache is a read-back of the native module, from 0.82 it is the requested value, and later in the range `"unspecified"` is resolved against the OS before being stored. Scoping the sentence to a RESOLVED scheme collapses the last two — the only value they disagree about is `"unspecified"`, which is exactly the value the guard below declines to announce — and it keeps the comment from naming a boundary this measurement cannot place: the cache holds 0.84.1 and 0.85.3 but not 0.85.0 through 0.85.2, so the second transition is bounded only to (0.84.1, 0.85.3]. Comments only; no behaviour changes. `reactivity.ts:229` is left alone — "the request 0.81 spells `null`" is a statement about 0.81 and is correct. --- src/native/api.tsx | 13 +++++++------ src/native/conditions/media-query.ts | 2 +- src/native/reactivity.ts | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/native/api.tsx b/src/native/api.tsx index a34bb1b8..d530b454 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -92,15 +92,16 @@ export const colorScheme: ColorScheme = { // // 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: from 0.86 it is the requested value, and before that 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. Reading it back would make this an - // announcement on one react-native and a no-op on another. + // 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.86, the literal "unspecified" from 0.86 on — and only the OS + // 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 diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 0b93db3e..36030d72 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -55,7 +55,7 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { // 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.86 + // after set(null), "unspecified" after a follow-the-system request on 0.82+ return value === resolveColorScheme(get(colorScheme)); } case "display-mode": diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 6311678f..011236bd 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -225,7 +225,7 @@ 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.86's spelling of "follow 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 From 1557b1eebae50766659e9b2fff9e6e61854fe176 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 17 Aug 2026 02:04:04 +0300 Subject: [PATCH 7/7] docs(test): separate the two react-native changes this header collapses into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header attributed to 0.86 a rewrite that landed at 0.82, and then used that number to introduce an expression which is a second, later change. Two boundaries, one version number, and the quoted code belongs to the later one. Measured across the versions available: `setColorScheme` stops reading the cache back and writes the requested value at 0.82.0, and the read-back returns for the literal "unspecified" alone by 0.85.3 — present there, absent at 0.84.1. The header now says "by 0.85.3" rather than naming a start version, because 0.85.0 through 0.85.2 could not be read and at-or-before 0.85.3 is what the evidence supports. The citation at the foot of the quote is untouched: 0.86.0 does ship that expression, and the file models 0.86 deliberately. Comments only. --- .../native/color-scheme-appearance-rn-0-86.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 1bc7a074..e9063217 100644 --- a/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx +++ b/src/__tests__/native/color-scheme-appearance-rn-0-86.test.tsx @@ -3,9 +3,10 @@ import { Appearance, type ColorSchemeName } from "react-native"; import { act } from "@testing-library/react-native"; import { colorScheme } from "react-native-css/runtime"; -// react-native 0.86 rewrote the one expression the announcement used to read. -// `setColorScheme` writes the cache from the REQUESTED value, and the native -// read-back survives only for the literal "unspecified": +// 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 = {