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