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