From 79fc5d508622d96da8eea1c45923dba57e91c41c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 10 Oct 2026 01:47:05 +0300 Subject: [PATCH 1/7] fix(native): make colorScheme.set move every reader of the scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit colorScheme.set moved this library's observable and nothing else, so the class layer and React Native's own readers disagreed: useColorScheme() and every store built on Appearance.addChangeListener kept the old scheme. The setter now writes through to Appearance and announces the scheme on the appearanceChanged device event, because Appearance.setColorScheme writes its cache without notifying a listener, and that event is the one Appearance itself handles. It announces only a resolved scheme it changes. A follow-the-system request is handed to Appearance as "unspecified"; on react-native 0.82.0-0.84.1, which caches that request verbatim, the scheme in force before the hand-back is put back in the cache. colorScheme.get() and prefers-color-scheme resolve the held value through one function, which answers a scheme for any value that is not one: the reported scheme, then light (MQ5 §5.4). The class layer read the raw observable, so a held null or request matched neither scheme. --- ...lor-scheme-appearance-cache-rules.test.tsx | 283 ++++++++++++++++ .../native/color-scheme-appearance.test.tsx | 304 ++++++++++++++++++ src/native/api.tsx | 21 +- src/native/conditions/media-query.ts | 10 +- src/native/reactivity.ts | 22 +- 5 files changed, 634 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx create mode 100644 src/__tests__/native/color-scheme-appearance.test.tsx 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..eab761f3 --- /dev/null +++ b/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx @@ -0,0 +1,283 @@ +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"; + +import { colorScheme as colorSchemeObservable } from "../../native/reactivity"; + +// Declared here, since babel's jest.mock hoist check reads a parameter name in an inline constructor type as a variable +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; +} + +// Each band of the react-native >= 0.81 peer range writes Appearance's cache by its own rule; the device under all of them applies a write later, reports the scheme in force, and echoes only a resolved change +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, + getColorScheme: (): string | null => appliedScheme, + removeListeners: () => undefined, + setColorScheme: (next: string) => { + pendingRequest = next; + }, + }; + + const cacheWriteRules = new Map([ + [ + "<=0.81.5", + { + handBack: null, + setColorScheme: (requested: unknown) => { + nativeAppearance.setColorScheme( + (requested as string | null) ?? "unspecified", + ); + return nativeAppearance.getColorScheme(); + }, + }, + ], + [ + "0.82.0-0.84.1", + { + handBack: "unspecified", + setColorScheme: (requested: unknown) => { + nativeAppearance.setColorScheme(requested as string); + return requested; + }, + }, + ], + [ + ">=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), + }; + }, + + cacheWriteRuleNames: () => [...cacheWriteRules.keys()], + handBackFor: (rule: string) => readRule(rule).handBack, + useCacheWriteRule: (rule: string) => { + activeRule = rule; + }, + resetDeviceTo: (scheme: string) => { + operatingSystemScheme = scheme; + appliedScheme = scheme; + pendingRequest = undefined; + appearance = { colorScheme: scheme }; + }, + 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 | null; + 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", +); + +const emitAppearanceChanged = (scheme: string): void => { + const { DeviceEventEmitter } = jest.requireActual<{ + DeviceEventEmitter: { emit: (event: string, payload: unknown) => void }; + }>("react-native"); + + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme }); +}; + +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 BLUE = { color: "#00f" } as const; +const RED = { color: "#f00" } as const; + +const { setColorScheme: setColorSchemeThroughAppearance } = jest.requireMock<{ + setColorScheme: (requested: unknown) => void; +}>("react-native/Libraries/Utilities/Appearance"); + +// Only the band that caches a request leaves JS without the OS scheme, so it falls back to light (MQ5 §12.5.1) +const schemeAfterDirectHandBack: Readonly> = { + "<=0.81.5": "dark", + "0.82.0-0.84.1": "light", + ">=0.85.3": "dark", +}; + +const ruleNames = cacheWriteRuleNames(); + +test("the cache-write census is not empty", () => { + 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(() => { + colorScheme.set(handBackFor(rule)); + }); + + 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(() => { + colorScheme.set(handBackFor(rule)); + }); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); + }); + + test("a follow-the-system request leaves Appearance answering a scheme", () => { + act(() => { + colorScheme.set(handBackFor(rule)); + }); + + expect(["light", "dark"]).toContain(Appearance.getColorScheme()); + }); + + test("no platform echo arrives to repair it, because the resolved scheme never changed", () => { + act(() => { + colorScheme.set(handBackFor(rule)); + }); + + act(() => { + const { applied, emits } = applyPendingWrite(); + + expect(applied).toBe("dark"); + expect(emits).toBe(false); + }); + + expect(colorScheme.get()).toBe("dark"); + }); + + test("a follow-the-system request made through Appearance leaves both layers on one scheme", () => { + registerCSS(DARK_CONDITIONAL_CSS); + render(); + + act(() => { + setColorSchemeThroughAppearance(handBackFor(rule)); + colorSchemeObservable.set(null); + }); + + const expected = schemeAfterDirectHandBack[rule]; + expect(colorScheme.get()).toBe(expected); + expect(screen.getByTestId(testID).props.style).toStrictEqual( + expected === "dark" ? RED : BLUE, + ); + }); +}); 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..db1fe7bc --- /dev/null +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -0,0 +1,304 @@ +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"; + +import { colorScheme as colorSchemeObservable } from "../../native/reactivity"; + +// A device applies setColorScheme later and always reports the scheme in force, so the fake holds each write until the test applies it +jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => { + const operatingSystemScheme = "light"; + let appliedScheme: ColorSchemeName = operatingSystemScheme; + let pendingRequest: string | undefined; + const setColorSchemeCalls: string[] = []; + + const resolveRequest = (request: string): ColorSchemeName => + request === "unspecified" + ? operatingSystemScheme + : (request as ColorSchemeName); + + return { + __esModule: true, + default: { + addListener: () => undefined, + getColorScheme: () => appliedScheme, + removeListeners: () => undefined, + setColorScheme: (next: string) => { + setColorSchemeCalls.push(next); + pendingRequest = next; + }, + applyPendingWrite: () => { + if (pendingRequest !== undefined) { + appliedScheme = resolveRequest(pendingRequest); + pendingRequest = undefined; + } + return appliedScheme; + }, + readSetColorSchemeCalls: () => [...setColorSchemeCalls], + writeDeviceScheme: (next: ColorSchemeName) => { + appliedScheme = next; + pendingRequest = undefined; + }, + }, + }; +}); + +interface FakeNativeAppearance { + applyPendingWrite: () => ColorSchemeName; + readSetColorSchemeCalls: () => string[]; + writeDeviceScheme: (next: ColorSchemeName) => void; +} + +const nativeAppearanceModule: { default: FakeNativeAppearance } = + jest.requireMock("react-native/Libraries/Utilities/NativeAppearance"); +const nativeAppearance = nativeAppearanceModule.default; + +const applyAndEchoPlatformWrite = (): void => { + const applied = nativeAppearance.applyPendingWrite(); + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: applied }); +}; + +const emitOperatingSystemChange = (scheme: ColorSchemeName): void => { + nativeAppearance.writeDeviceScheme(scheme); + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme }); +}; + +const subscribeToAppearance = (onStoreChange: () => void): (() => void) => { + const subscription = Appearance.addChangeListener(onStoreChange); + return () => { + subscription.remove(); + }; +}; + +const readAppearanceColorScheme = (): ReturnType< + typeof Appearance.getColorScheme +> => Appearance.getColorScheme(); + +// The store react-native's useColorScheme builds, which the jest preset replaces with a constant +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(); + }, + }; +}; + +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 BLUE = { color: "#00f" } as const; +const RED = { color: "#f00" } as const; + +beforeEach(() => { + act(() => { + emitOperatingSystemChange("light"); + }); +}); + +test("colorScheme.set writes through to Appearance", () => { + act(() => { + colorScheme.set("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 announces the requested scheme before the platform applies it", () => { + const { heard, stop } = recordChangeEvents(); + + act(() => { + colorScheme.set("dark"); + }); + + expect(heard).toStrictEqual(["dark"]); + expect(Appearance.getColorScheme()).toBe("dark"); + expect(colorScheme.get()).toBe("dark"); + + stop(); +}); + +test("the platform echo that follows repeats the scheme and settles there", () => { + const { heard, stop } = recordChangeEvents(); + + act(() => { + colorScheme.set("dark"); + }); + act(() => { + applyAndEchoPlatformWrite(); + }); + + expect(heard).toStrictEqual(["dark", "dark"]); + expect(Appearance.getColorScheme()).toBe("dark"); + expect(colorScheme.get()).toBe("dark"); + + stop(); +}); + +test("a colorScheme.set to the scheme already in force announces nothing", () => { + const { heard, stop } = recordChangeEvents(); + + act(() => { + colorScheme.set("light"); + }); + + expect(Appearance.getColorScheme()).toBe("light"); + expect(heard).toStrictEqual([]); + + stop(); +}); + +test("a repeated colorScheme.set announces nothing the second time", () => { + act(() => { + colorScheme.set("dark"); + }); + + const { heard, stop } = recordChangeEvents(); + + act(() => { + colorScheme.set("dark"); + }); + + expect(heard).toStrictEqual([]); + + stop(); +}); + +test("a reader subscribed the way useColorScheme is moves with colorScheme.set", () => { + render(); + expect(readSubscribedColorScheme()).toBe("light"); + + act(() => { + colorScheme.set("dark"); + }); + + 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"); + }); + + 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", () => { + registerCSS(TRI_STATE_CSS); + render(); + + act(() => { + colorScheme.set(null); + }); + + expect(colorScheme.get()).toBe("light"); + expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); +}); + +test.each([null, "unspecified"] as const)( + "set(%p) hands the scheme back without announcing a scheme of its own", + (handBack) => { + act(() => { + colorScheme.set("dark"); + }); + + const { heard, stop } = recordChangeEvents(); + + act(() => { + colorScheme.set(handBack); + }); + + expect(nativeAppearance.readSetColorSchemeCalls().at(-1)).toBe( + "unspecified", + ); + expect(heard).toStrictEqual([]); + + act(() => { + applyAndEchoPlatformWrite(); + }); + + expect(heard).toStrictEqual(["light"]); + expect(colorScheme.get()).toBe("light"); + + stop(); + }, +); + +test("a held follow-the-system request renders the scheme Appearance reports on both layers", () => { + registerCSS(TRI_STATE_CSS); + render(); + + act(() => { + colorSchemeObservable.set("unspecified"); + }); + + expect(colorScheme.get()).toBe("light"); + expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); +}); + +test("an OS change event repaints a mounted element", () => { + registerCSS(TRI_STATE_CSS); + + render(); + expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); + + act(() => { + emitOperatingSystemChange("dark"); + }); + + expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); +}); diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..def494c4 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -1,6 +1,6 @@ /* eslint-disable */ import { useContext, useEffect, useState, type ComponentType } from "react"; -import { Appearance, type ViewStyle } from "react-native"; +import { Appearance, DeviceEventEmitter, type ViewStyle } from "react-native"; import type { StyleDescriptor } from "react-native-css/compiler"; import { VariableContext } from "react-native-css/native-internal"; @@ -18,6 +18,8 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, + holdsRequestNotScheme, + resolveColorScheme, VAR_SYMBOL, type Effect, type Getter, @@ -69,10 +71,23 @@ export const styled: Styled = ( export const colorScheme: ColorScheme = { get() { - return colorSchemeObs.get() ?? Appearance.getColorScheme() ?? "light"; + return resolveColorScheme(colorSchemeObs.get()); }, set(value) { - return colorSchemeObs.set(value === "unspecified" ? null : value); + const previous = Appearance.getColorScheme(); + // Resolved before the write, since react-native 0.82.0-0.84.1 caches a follow-the-system request verbatim + const inForce = resolveColorScheme(colorSchemeObs.get()); + + const request = value ?? "unspecified"; + Appearance.setColorScheme(request); + colorSchemeObs.set(request === "unspecified" ? null : request); + + // Appearance.setColorScheme notifies no listener, so the scheme is announced on the event Appearance itself handles + if ((value === "dark" || value === "light") && value !== previous) { + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: value }); + } else if (holdsRequestNotScheme(Appearance.getColorScheme())) { + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: inForce }); + } }, }; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..9c44d4e3 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,7 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "platform": return value === "native" || value === Platform.OS; case "prefers-color-scheme": { - return value === get(colorScheme); + 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 296988a6..09306c46 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -228,11 +228,31 @@ Dimensions.addEventListener("change", ({ window }) => { /** Color Scheme **************************************************************/ -export const colorScheme = observable( +export type HeldColorScheme = ColorSchemeName | null | undefined; + +export const colorScheme = observable( Appearance.getColorScheme(), ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +// A held value that is not a scheme is a follow-the-system request, so the reported scheme answers, then light (MQ5 §12.5.1) +export function resolveColorScheme(held: HeldColorScheme): "light" | "dark" { + if (held === "light" || held === "dark") { + return held; + } + const reported = Appearance.getColorScheme(); + return reported === "light" || reported === "dark" ? reported : "light"; +} + +// The native module always answers a scheme, so only react-native 0.82.0-0.84.1 leaves a setColorScheme request in the cache +export function holdsRequestNotScheme(held: HeldColorScheme): boolean { + return held !== null && held !== undefined && !isScheme(held); +} + +function isScheme(held: HeldColorScheme): held is "light" | "dark" { + return held === "light" || held === "dark"; +} + /** Containers ****************************************************************/ export type ContainerContextValue = Record< From f6daffeb5a2026f7c2b2f3c63d7ccd14c61c7e51 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 16:21:12 +0300 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20cite=20MQ5=20=C2=A712.5,=20which=20?= =?UTF-8?q?defines=20light=20as=20the=20scheme=20of=20no=20preference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../native/color-scheme-appearance-cache-rules.test.tsx | 2 +- src/native/reactivity.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx b/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx index eab761f3..856420c9 100644 --- a/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx +++ b/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx @@ -199,7 +199,7 @@ const { setColorScheme: setColorSchemeThroughAppearance } = jest.requireMock<{ setColorScheme: (requested: unknown) => void; }>("react-native/Libraries/Utilities/Appearance"); -// Only the band that caches a request leaves JS without the OS scheme, so it falls back to light (MQ5 §12.5.1) +// Only the band that caches a request leaves JS without the OS scheme, so it falls back to light (MQ5 §12.5) const schemeAfterDirectHandBack: Readonly> = { "<=0.81.5": "dark", "0.82.0-0.84.1": "light", diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 09306c46..39f4bb0f 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -235,7 +235,7 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); -// A held value that is not a scheme is a follow-the-system request, so the reported scheme answers, then light (MQ5 §12.5.1) +// A held value that is not a scheme is a follow-the-system request, so the reported scheme answers, then light (MQ5 §12.5) export function resolveColorScheme(held: HeldColorScheme): "light" | "dark" { if (held === "light" || held === "dark") { return held; From 937c57bb84a9509c640c7f5a7a7c51e7f6ff31d0 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 19:42:44 +0300 Subject: [PATCH 3/7] fix(native): follow the scheme Appearance reports, and hold one only without its module --- ...lor-scheme-appearance-cache-rules.test.tsx | 283 ------------ .../native/color-scheme-appearance.test.tsx | 407 ++++++++++-------- .../native/color-scheme-jest-reset.test.tsx | 32 ++ ...-scheme-without-appearance-module.test.tsx | 50 +++ src/jest/index.ts | 2 +- src/native/api.tsx | 23 +- src/native/conditions/media-query.ts | 9 +- src/native/reactivity.ts | 22 +- src/runtime.types.ts | 8 +- src/utilities/color-scheme.ts | 10 + src/utilities/index.ts | 1 + src/web/api.tsx | 3 +- 12 files changed, 337 insertions(+), 513 deletions(-) delete mode 100644 src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx create mode 100644 src/__tests__/native/color-scheme-jest-reset.test.tsx create mode 100644 src/__tests__/native/color-scheme-without-appearance-module.test.tsx create mode 100644 src/utilities/color-scheme.ts diff --git a/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx b/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx deleted file mode 100644 index 856420c9..00000000 --- a/src/__tests__/native/color-scheme-appearance-cache-rules.test.tsx +++ /dev/null @@ -1,283 +0,0 @@ -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"; - -import { colorScheme as colorSchemeObservable } from "../../native/reactivity"; - -// Declared here, since babel's jest.mock hoist check reads a parameter name in an inline constructor type as a variable -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; -} - -// Each band of the react-native >= 0.81 peer range writes Appearance's cache by its own rule; the device under all of them applies a write later, reports the scheme in force, and echoes only a resolved change -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, - getColorScheme: (): string | null => appliedScheme, - removeListeners: () => undefined, - setColorScheme: (next: string) => { - pendingRequest = next; - }, - }; - - const cacheWriteRules = new Map([ - [ - "<=0.81.5", - { - handBack: null, - setColorScheme: (requested: unknown) => { - nativeAppearance.setColorScheme( - (requested as string | null) ?? "unspecified", - ); - return nativeAppearance.getColorScheme(); - }, - }, - ], - [ - "0.82.0-0.84.1", - { - handBack: "unspecified", - setColorScheme: (requested: unknown) => { - nativeAppearance.setColorScheme(requested as string); - return requested; - }, - }, - ], - [ - ">=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), - }; - }, - - cacheWriteRuleNames: () => [...cacheWriteRules.keys()], - handBackFor: (rule: string) => readRule(rule).handBack, - useCacheWriteRule: (rule: string) => { - activeRule = rule; - }, - resetDeviceTo: (scheme: string) => { - operatingSystemScheme = scheme; - appliedScheme = scheme; - pendingRequest = undefined; - appearance = { colorScheme: scheme }; - }, - 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 | null; - 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", -); - -const emitAppearanceChanged = (scheme: string): void => { - const { DeviceEventEmitter } = jest.requireActual<{ - DeviceEventEmitter: { emit: (event: string, payload: unknown) => void }; - }>("react-native"); - - DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme }); -}; - -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 BLUE = { color: "#00f" } as const; -const RED = { color: "#f00" } as const; - -const { setColorScheme: setColorSchemeThroughAppearance } = jest.requireMock<{ - setColorScheme: (requested: unknown) => void; -}>("react-native/Libraries/Utilities/Appearance"); - -// Only the band that caches a request leaves JS without the OS scheme, so it falls back to light (MQ5 §12.5) -const schemeAfterDirectHandBack: Readonly> = { - "<=0.81.5": "dark", - "0.82.0-0.84.1": "light", - ">=0.85.3": "dark", -}; - -const ruleNames = cacheWriteRuleNames(); - -test("the cache-write census is not empty", () => { - 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(() => { - colorScheme.set(handBackFor(rule)); - }); - - 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(() => { - colorScheme.set(handBackFor(rule)); - }); - - expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); - }); - - test("a follow-the-system request leaves Appearance answering a scheme", () => { - act(() => { - colorScheme.set(handBackFor(rule)); - }); - - expect(["light", "dark"]).toContain(Appearance.getColorScheme()); - }); - - test("no platform echo arrives to repair it, because the resolved scheme never changed", () => { - act(() => { - colorScheme.set(handBackFor(rule)); - }); - - act(() => { - const { applied, emits } = applyPendingWrite(); - - expect(applied).toBe("dark"); - expect(emits).toBe(false); - }); - - expect(colorScheme.get()).toBe("dark"); - }); - - test("a follow-the-system request made through Appearance leaves both layers on one scheme", () => { - registerCSS(DARK_CONDITIONAL_CSS); - render(); - - act(() => { - setColorSchemeThroughAppearance(handBackFor(rule)); - colorSchemeObservable.set(null); - }); - - const expected = schemeAfterDirectHandBack[rule]; - expect(colorScheme.get()).toBe(expected); - expect(screen.getByTestId(testID).props.style).toStrictEqual( - expected === "dark" ? RED : BLUE, - ); - }); -}); diff --git a/src/__tests__/native/color-scheme-appearance.test.tsx b/src/__tests__/native/color-scheme-appearance.test.tsx index db1fe7bc..7925009a 100644 --- a/src/__tests__/native/color-scheme-appearance.test.tsx +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -13,62 +13,84 @@ import { colorScheme } from "react-native-css/runtime"; import { colorScheme as colorSchemeObservable } from "../../native/reactivity"; -// A device applies setColorScheme later and always reports the scheme in force, so the fake holds each write until the test applies it -jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => { - const operatingSystemScheme = "light"; - let appliedScheme: ColorSchemeName = operatingSystemScheme; - let pendingRequest: string | undefined; - const setColorSchemeCalls: string[] = []; +type Scheme = "light" | "dark"; +type Platform = "ios" | "android"; - const resolveRequest = (request: string): ColorSchemeName => - request === "unspecified" - ? operatingSystemScheme - : (request as ColorSchemeName); +// A device applies an override after the call returns and sends appearanceChanged only when its scheme moves, as RCTAppearance and AppearanceModule do +jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => { + let platform = "ios"; + let systemScheme = "light"; + let override: string | null = null; + let sentScheme = "light"; + let pendingOverrides: string[] = []; + let setColorSchemeCalls: string[] = []; + + const schemeInForce = () => override ?? systemScheme; + const sendIfMoved = (sent: string[]) => { + if (schemeInForce() !== sentScheme) { + sentScheme = schemeInForce(); + sent.push(sentScheme); + } + }; return { __esModule: true, default: { addListener: () => undefined, - getColorScheme: () => appliedScheme, removeListeners: () => undefined, + // RCTAppearance answers the scheme it last sent; AppearanceModule reads the configuration in force + getColorScheme: () => (platform === "ios" ? sentScheme : schemeInForce()), setColorScheme: (next: string) => { setColorSchemeCalls.push(next); - pendingRequest = next; + pendingOverrides.push(next); + }, + startDevice: (nextPlatform: string, scheme: string) => { + platform = nextPlatform; + systemScheme = scheme; + override = null; + sentScheme = scheme; + pendingOverrides = []; + setColorSchemeCalls = []; }, - applyPendingWrite: () => { - if (pendingRequest !== undefined) { - appliedScheme = resolveRequest(pendingRequest); - pendingRequest = undefined; + // iOS applies the overrides of one main-queue turn together; Android applies each posted override in turn + applyPendingOverrides: (): string[] => { + const applied = + platform === "ios" ? pendingOverrides.slice(-1) : pendingOverrides; + pendingOverrides = []; + const sent: string[] = []; + for (const next of applied) { + override = next === "unspecified" || next === "auto" ? null : next; + sendIfMoved(sent); } - return appliedScheme; + return sent; }, - readSetColorSchemeCalls: () => [...setColorSchemeCalls], - writeDeviceScheme: (next: ColorSchemeName) => { - appliedScheme = next; - pendingRequest = undefined; + changeSystemScheme: (scheme: string): string[] => { + systemScheme = scheme; + const sent: string[] = []; + sendIfMoved(sent); + return sent; }, + readSetColorSchemeCalls: () => [...setColorSchemeCalls], }, }; }); -interface FakeNativeAppearance { - applyPendingWrite: () => ColorSchemeName; +interface DeviceDouble { + startDevice: (platform: Platform, scheme: Scheme) => void; + applyPendingOverrides: () => Scheme[]; + changeSystemScheme: (scheme: Scheme) => Scheme[]; readSetColorSchemeCalls: () => string[]; - writeDeviceScheme: (next: ColorSchemeName) => void; } -const nativeAppearanceModule: { default: FakeNativeAppearance } = - jest.requireMock("react-native/Libraries/Utilities/NativeAppearance"); -const nativeAppearance = nativeAppearanceModule.default; - -const applyAndEchoPlatformWrite = (): void => { - const applied = nativeAppearance.applyPendingWrite(); - DeviceEventEmitter.emit("appearanceChanged", { colorScheme: applied }); -}; +const device = jest.requireMock<{ default: DeviceDouble }>( + "react-native/Libraries/Utilities/NativeAppearance", +).default; -const emitOperatingSystemChange = (scheme: ColorSchemeName): void => { - nativeAppearance.writeDeviceScheme(scheme); - DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme }); +const deliver = (sent: readonly Scheme[]): readonly Scheme[] => { + for (const scheme of sent) { + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme }); + } + return sent; }; const subscribeToAppearance = (onStoreChange: () => void): (() => void) => { @@ -78,40 +100,16 @@ const subscribeToAppearance = (onStoreChange: () => void): (() => void) => { }; }; -const readAppearanceColorScheme = (): ReturnType< - typeof Appearance.getColorScheme -> => Appearance.getColorScheme(); +const readAppearance = (): ColorSchemeName | null | undefined => + Appearance.getColorScheme(); // The store react-native's useColorScheme builds, which the jest preset replaces with a constant const SubscribedColorScheme = () => { - const scheme = useSyncExternalStore( - subscribeToAppearance, - readAppearanceColorScheme, - ); + const scheme = useSyncExternalStore(subscribeToAppearance, readAppearance); 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(); - }, - }; -}; - const TRI_STATE_CSS = ` .my-class { color: green; } @@ -123,182 +121,227 @@ const TRI_STATE_CSS = ` .my-class { color: red; } }`; -const BLUE = { color: "#00f" } as const; -const RED = { color: "#f00" } as const; - -beforeEach(() => { - act(() => { - emitOperatingSystemChange("light"); - }); -}); - -test("colorScheme.set writes through to Appearance", () => { - act(() => { - colorScheme.set("dark"); - }); +const STYLE_FOR: Readonly> = { + light: { color: "#00f" }, + dark: { color: "#f00" }, +}; - expect(nativeAppearance.readSetColorSchemeCalls().at(-1)).toBe("dark"); - expect(Appearance.getColorScheme()).toBe("dark"); +const LATE_TEST_ID = "late-element"; - act(() => { - colorScheme.set("light"); - }); +const readEveryLayer = () => ({ + classLayer: screen.getByTestId(testID).props.style, + colorSchemeGet: colorScheme.get(), + subscribed: screen.getByTestId("subscribed-color-scheme").props.children, +}); - expect(nativeAppearance.readSetColorSchemeCalls().at(-1)).toBe("light"); - expect(Appearance.getColorScheme()).toBe("light"); +const everyLayerOn = (scheme: Scheme) => ({ + classLayer: STYLE_FOR[scheme], + colorSchemeGet: scheme, + subscribed: scheme, }); -test("colorScheme.set announces the requested scheme before the platform applies it", () => { - const { heard, stop } = recordChangeEvents(); +const opposite = (scheme: Scheme): Scheme => + scheme === "light" ? "dark" : "light"; - act(() => { - colorScheme.set("dark"); - }); +const expectEveryLayerToAgree = (): void => { + expect(readEveryLayer()).toStrictEqual(everyLayerOn(colorScheme.get())); +}; - expect(heard).toStrictEqual(["dark"]); - expect(Appearance.getColorScheme()).toBe("dark"); - expect(colorScheme.get()).toBe("dark"); +const recordAppearanceEvents = (): { + sentByCaller: () => readonly unknown[]; + stop: () => void; +} => { + const emit = jest.spyOn(DeviceEventEmitter, "emit"); - stop(); -}); + return { + sentByCaller: () => + emit.mock.calls.filter(([event]) => event === "appearanceChanged"), + stop: () => { + emit.mockRestore(); + }, + }; +}; -test("the platform echo that follows repeats the scheme and settles there", () => { - const { heard, stop } = recordChangeEvents(); +describe.each([ + ["ios", "light"], + ["ios", "dark"], + ["android", "light"], + ["android", "dark"], +] as const)("on %s with a %s system scheme", (platform, system) => { + const other = opposite(system); - act(() => { - colorScheme.set("dark"); - }); - act(() => { - applyAndEchoPlatformWrite(); + beforeEach(() => { + device.startDevice(platform, system); + act(() => { + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: system }); + }); + registerCSS(TRI_STATE_CSS); + render( + <> + + + , + ); }); - expect(heard).toStrictEqual(["dark", "dark"]); - expect(Appearance.getColorScheme()).toBe("dark"); - expect(colorScheme.get()).toBe("dark"); + test("colorScheme.set hands each request to Appearance and sends no event of its own", () => { + const { sentByCaller, stop } = recordAppearanceEvents(); - stop(); -}); + act(() => { + colorScheme.set(other); + colorScheme.set(null); + colorScheme.set("unspecified"); + }); -test("a colorScheme.set to the scheme already in force announces nothing", () => { - const { heard, stop } = recordChangeEvents(); + expect(device.readSetColorSchemeCalls().slice(-3)).toStrictEqual([ + other, + "unspecified", + "unspecified", + ]); + expect(sentByCaller()).toStrictEqual([]); - act(() => { - colorScheme.set("light"); + stop(); }); - expect(Appearance.getColorScheme()).toBe("light"); - expect(heard).toStrictEqual([]); + test("every layer agrees until the platform applies a request, then takes it", () => { + act(() => { + colorScheme.set(other); + }); - stop(); -}); + expectEveryLayerToAgree(); -test("a repeated colorScheme.set announces nothing the second time", () => { - act(() => { - colorScheme.set("dark"); - }); - - const { heard, stop } = recordChangeEvents(); + act(() => { + deliver(device.applyPendingOverrides()); + }); - act(() => { - colorScheme.set("dark"); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); }); - expect(heard).toStrictEqual([]); + test.each([null, "unspecified"] as const)( + "set(%p) returns every layer to the system scheme", + (handBack) => { + act(() => { + colorScheme.set(other); + }); + act(() => { + deliver(device.applyPendingOverrides()); + }); + act(() => { + colorScheme.set(handBack); + }); + act(() => { + deliver(device.applyPendingOverrides()); + }); + + expect(readEveryLayer()).toStrictEqual(everyLayerOn(system)); + }, + ); - stop(); -}); + test("a request and its hand-back made before the platform applies either leave every layer on the system scheme", () => { + act(() => { + colorScheme.set(other); + colorScheme.set(null); + }); -test("a reader subscribed the way useColorScheme is moves with colorScheme.set", () => { - render(); - expect(readSubscribedColorScheme()).toBe("light"); + expectEveryLayerToAgree(); - act(() => { - colorScheme.set("dark"); + let sent: readonly Scheme[] = []; + act(() => { + sent = deliver(device.applyPendingOverrides()); + }); + + expect(sent).toStrictEqual(platform === "ios" ? [] : [other, system]); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(system)); }); - expect(readSubscribedColorScheme()).toBe("dark"); -}); + test("each scheme the platform applies moves every layer, ending on the last request", () => { + act(() => { + colorScheme.set(other); + }); + act(() => { + colorScheme.set(system); + }); -test("the class layer and a subscribed reader agree after one colorScheme.set", () => { - registerCSS(TRI_STATE_CSS); - render( - <> - - - , - ); + expectEveryLayerToAgree(); - act(() => { - colorScheme.set("dark"); + let sent: readonly Scheme[] = []; + act(() => { + sent = deliver(device.applyPendingOverrides()); + }); + + expect(sent).toStrictEqual(platform === "ios" ? [] : [other, system]); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(system)); }); - expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); - expect(readSubscribedColorScheme()).toBe("dark"); - expect(colorScheme.get()).toBe("dark"); -}); + test("a request for the scheme in force sends nothing and moves no layer", () => { + act(() => { + colorScheme.set(system); + }); -test("the class layer resolves the scheme the same way colorScheme.get() does", () => { - registerCSS(TRI_STATE_CSS); - render(); + let sent: readonly Scheme[] = []; + act(() => { + sent = deliver(device.applyPendingOverrides()); + }); - act(() => { - colorScheme.set(null); + expect(sent).toStrictEqual([]); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(system)); }); - expect(colorScheme.get()).toBe("light"); - expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); -}); - -test.each([null, "unspecified"] as const)( - "set(%p) hands the scheme back without announcing a scheme of its own", - (handBack) => { + test("a scheme set through Appearance itself moves every layer once the platform applies it", () => { act(() => { - colorScheme.set("dark"); + Appearance.setColorScheme(other); + }); + act(() => { + deliver(device.applyPendingOverrides()); }); - const { heard, stop } = recordChangeEvents(); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); + }); + test("an element mounted before the platform applies a request renders what one mounted earlier renders", () => { act(() => { - colorScheme.set(handBack); + colorScheme.set(null); + Appearance.setColorScheme(other); }); + const earlierStyle: unknown = screen.getByTestId(testID).props.style; + render(); - expect(nativeAppearance.readSetColorSchemeCalls().at(-1)).toBe( - "unspecified", + expect(earlierStyle).toStrictEqual(STYLE_FOR[colorScheme.get()]); + expect(screen.getByTestId(LATE_TEST_ID).props.style).toStrictEqual( + earlierStyle, ); - expect(heard).toStrictEqual([]); + }); + test("an OS change moves every layer, and none while a request overrides it", () => { act(() => { - applyAndEchoPlatformWrite(); + deliver(device.changeSystemScheme(other)); }); - expect(heard).toStrictEqual(["light"]); - expect(colorScheme.get()).toBe("light"); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); - stop(); - }, -); - -test("a held follow-the-system request renders the scheme Appearance reports on both layers", () => { - registerCSS(TRI_STATE_CSS); - render(); + act(() => { + colorScheme.set(other); + }); + act(() => { + deliver(device.applyPendingOverrides()); + }); + act(() => { + deliver(device.changeSystemScheme(system)); + }); - act(() => { - colorSchemeObservable.set("unspecified"); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); }); - - expect(colorScheme.get()).toBe("light"); - expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); }); -test("an OS change event repaints a mounted element", () => { +test("a value Appearance reports that names no scheme resolves to light on both layers", () => { + device.startDevice("ios", "dark"); registerCSS(TRI_STATE_CSS); - render(); - expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); act(() => { - emitOperatingSystemChange("dark"); + colorSchemeObservable.set("unspecified"); }); - expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); + expect(colorScheme.get()).toBe("light"); + expect(screen.getByTestId(testID).props.style).toStrictEqual(STYLE_FOR.light); }); diff --git a/src/__tests__/native/color-scheme-jest-reset.test.tsx b/src/__tests__/native/color-scheme-jest-reset.test.tsx new file mode 100644 index 00000000..433b7ee4 --- /dev/null +++ b/src/__tests__/native/color-scheme-jest-reset.test.tsx @@ -0,0 +1,32 @@ +import { Appearance } from "react-native"; + +import { 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"; + +jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => ({ + __esModule: true, + default: { + addListener: () => undefined, + removeListeners: () => undefined, + getColorScheme: () => "dark", + setColorScheme: () => undefined, + }, +})); + +test("each test starts from the scheme Appearance reports", () => { + registerCSS(` +.my-class { color: green; } + +@media (prefers-color-scheme: dark) { + .my-class { color: red; } +}`); + render(); + + expect(Appearance.getColorScheme()).toBe("dark"); + expect(colorScheme.get()).toBe("dark"); + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + color: "#f00", + }); +}); diff --git a/src/__tests__/native/color-scheme-without-appearance-module.test.tsx b/src/__tests__/native/color-scheme-without-appearance-module.test.tsx new file mode 100644 index 00000000..a61d8ef3 --- /dev/null +++ b/src/__tests__/native/color-scheme-without-appearance-module.test.tsx @@ -0,0 +1,50 @@ +import { Appearance } 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"; + +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 BLUE = { color: "#00f" } as const; +const RED = { color: "#f00" } as const; + +beforeEach(() => { + registerCSS(TRI_STATE_CSS); + render(); +}); + +test("without an Appearance module, colorScheme.set holds the scheme for both layers", () => { + act(() => { + colorScheme.set("dark"); + }); + + expect(Appearance.getColorScheme()).toBeNull(); + expect(colorScheme.get()).toBe("dark"); + expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); +}); + +test.each([null, "unspecified"] as const)( + "without an Appearance module, set(%p) resolves light on both layers", + (handBack) => { + act(() => { + colorScheme.set("dark"); + }); + act(() => { + colorScheme.set(handBack); + }); + + expect(colorScheme.get()).toBe("light"); + expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); + }, +); diff --git a/src/jest/index.ts b/src/jest/index.ts index a8d249f5..799033e7 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -22,7 +22,7 @@ beforeEach(() => { StyleCollection.styles.clear(); dimensions.set(Dimensions.get("window")); Appearance.setColorScheme("unspecified"); - colorScheme.set(null); + colorScheme.set(Appearance.getColorScheme()); }); const debugDefault = Boolean( diff --git a/src/native/api.tsx b/src/native/api.tsx index def494c4..41875556 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -1,9 +1,10 @@ /* eslint-disable */ import { useContext, useEffect, useState, type ComponentType } from "react"; -import { Appearance, DeviceEventEmitter, type ViewStyle } from "react-native"; +import { Appearance, type ViewStyle } from "react-native"; import type { StyleDescriptor } from "react-native-css/compiler"; import { VariableContext } from "react-native-css/native-internal"; +import { resolveColorScheme } from "react-native-css/utilities"; import type { ColorScheme, @@ -18,8 +19,6 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, - holdsRequestNotScheme, - resolveColorScheme, VAR_SYMBOL, type Effect, type Getter, @@ -74,19 +73,11 @@ export const colorScheme: ColorScheme = { return resolveColorScheme(colorSchemeObs.get()); }, set(value) { - const previous = Appearance.getColorScheme(); - // Resolved before the write, since react-native 0.82.0-0.84.1 caches a follow-the-system request verbatim - const inForce = resolveColorScheme(colorSchemeObs.get()); - - const request = value ?? "unspecified"; - Appearance.setColorScheme(request); - colorSchemeObs.set(request === "unspecified" ? null : request); - - // Appearance.setColorScheme notifies no listener, so the scheme is announced on the event Appearance itself handles - if ((value === "dark" || value === "light") && value !== previous) { - DeviceEventEmitter.emit("appearanceChanged", { colorScheme: value }); - } else if (holdsRequestNotScheme(Appearance.getColorScheme())) { - DeviceEventEmitter.emit("appearanceChanged", { colorScheme: inForce }); + // Appearance answers null only without its native module, where nothing but this library holds a scheme + if (Appearance.getColorScheme() === null) { + colorSchemeObs.set(value); + } else { + Appearance.setColorScheme(value ?? "unspecified"); } }, }; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 9c44d4e3..55ac143a 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -2,14 +2,9 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; +import { resolveColorScheme } from "react-native-css/utilities"; -import { - colorScheme, - resolveColorScheme, - vh, - vw, - type Getter, -} from "../reactivity"; +import { colorScheme, vh, vw, type Getter } from "../reactivity"; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 39f4bb0f..296988a6 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -228,31 +228,11 @@ Dimensions.addEventListener("change", ({ window }) => { /** Color Scheme **************************************************************/ -export type HeldColorScheme = ColorSchemeName | null | undefined; - -export const colorScheme = observable( +export const colorScheme = observable( Appearance.getColorScheme(), ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); -// A held value that is not a scheme is a follow-the-system request, so the reported scheme answers, then light (MQ5 §12.5) -export function resolveColorScheme(held: HeldColorScheme): "light" | "dark" { - if (held === "light" || held === "dark") { - return held; - } - const reported = Appearance.getColorScheme(); - return reported === "light" || reported === "dark" ? reported : "light"; -} - -// The native module always answers a scheme, so only react-native 0.82.0-0.84.1 leaves a setColorScheme request in the cache -export function holdsRequestNotScheme(held: HeldColorScheme): boolean { - return held !== null && held !== undefined && !isScheme(held); -} - -function isScheme(held: HeldColorScheme): held is "light" | "dark" { - return held === "light" || held === "dark"; -} - /** Containers ****************************************************************/ export type ContainerContextValue = Record< diff --git a/src/runtime.types.ts b/src/runtime.types.ts index 69cab6ca..a257897f 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -16,7 +16,11 @@ import type { ViewStyle, } from "react-native"; -import type { DotNotation, ResolveDotPath } from "react-native-css/utilities"; +import type { + DotNotation, + ResolvedColorScheme, + ResolveDotPath, +} from "react-native-css/utilities"; /******************************** API ********************************/ @@ -147,6 +151,6 @@ export type RNStyle = ViewStyle & TextStyle & ImageStyle; /******************************** Globals ********************************/ export interface ColorScheme { - get: () => ColorSchemeName | null | undefined; + get: () => ResolvedColorScheme; set: (value: ColorSchemeName | null | undefined) => void; } diff --git a/src/utilities/color-scheme.ts b/src/utilities/color-scheme.ts new file mode 100644 index 00000000..da28a409 --- /dev/null +++ b/src/utilities/color-scheme.ts @@ -0,0 +1,10 @@ +import type { ColorSchemeName } from "react-native"; + +export type ResolvedColorScheme = "light" | "dark"; + +// A value that names no scheme states no preference, which MQ5 §12.5 answers with light +export function resolveColorScheme( + value: ColorSchemeName | null | undefined, +): ResolvedColorScheme { + return value === "light" || value === "dark" ? value : "light"; +} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 0c95da63..6acb51ba 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,3 +1,4 @@ +export * from "./color-scheme"; export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; diff --git a/src/web/api.tsx b/src/web/api.tsx index 28e09ab8..a5c150c6 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -12,6 +12,7 @@ import type { StyledConfiguration, StyledOptions, } from "react-native-css"; +import { resolveColorScheme } from "react-native-css/utilities"; import type { ReactComponent, Styled } from "../runtime.types"; import { assignStyle } from "./assign-style"; @@ -65,7 +66,7 @@ export const useCssElement = ( export const colorScheme: ColorScheme = { get() { - return Appearance.getColorScheme(); + return resolveColorScheme(Appearance.getColorScheme()); }, set(name) { Appearance.setColorScheme(name ?? "unspecified"); From a2a324d39fec452170cf14ba4d9d69dd4a4523e2 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 19:43:57 +0300 Subject: [PATCH 4/7] test(web): colorScheme.get answers a scheme, light where Appearance names none --- src/__tests__/web/color-scheme.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/__tests__/web/color-scheme.test.ts diff --git a/src/__tests__/web/color-scheme.test.ts b/src/__tests__/web/color-scheme.test.ts new file mode 100644 index 00000000..6e144fde --- /dev/null +++ b/src/__tests__/web/color-scheme.test.ts @@ -0,0 +1,21 @@ +import { Appearance } from "react-native"; + +import { colorScheme } from "../../web/api"; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test.each([ + ["dark", "dark"], + ["light", "light"], + ["unspecified", "light"], + [null, "light"], +] as const)( + "web colorScheme.get() answers %p from Appearance as %p", + (reported, expected) => { + jest.spyOn(Appearance, "getColorScheme").mockReturnValue(reported); + + expect(colorScheme.get()).toBe(expected); + }, +); From 34c8f316556628e62c6e203483a787da85d70b23 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 21 Oct 2026 19:47:30 +0300 Subject: [PATCH 5/7] refactor(native): keep the scheme resolution beside the observable it reads --- .../native/color-scheme-appearance.test.tsx | 12 +++++++++-- src/__tests__/web/color-scheme.test.ts | 21 ------------------- src/native/api.tsx | 2 +- src/native/conditions/media-query.ts | 10 ++++++--- src/native/reactivity.ts | 7 +++++++ src/runtime.types.ts | 8 ++----- src/utilities/color-scheme.ts | 10 --------- src/utilities/index.ts | 1 - src/web/api.tsx | 3 +-- 9 files changed, 28 insertions(+), 46 deletions(-) delete mode 100644 src/__tests__/web/color-scheme.test.ts delete mode 100644 src/utilities/color-scheme.ts diff --git a/src/__tests__/native/color-scheme-appearance.test.tsx b/src/__tests__/native/color-scheme-appearance.test.tsx index 7925009a..b07fbd7c 100644 --- a/src/__tests__/native/color-scheme-appearance.test.tsx +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -143,8 +143,16 @@ const everyLayerOn = (scheme: Scheme) => ({ const opposite = (scheme: Scheme): Scheme => scheme === "light" ? "dark" : "light"; +const readColorScheme = (): Scheme => { + const scheme = colorScheme.get(); + if (scheme !== "light" && scheme !== "dark") { + throw new Error(`colorScheme.get() answered ${String(scheme)}`); + } + return scheme; +}; + const expectEveryLayerToAgree = (): void => { - expect(readEveryLayer()).toStrictEqual(everyLayerOn(colorScheme.get())); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(readColorScheme())); }; const recordAppearanceEvents = (): { @@ -306,7 +314,7 @@ describe.each([ const earlierStyle: unknown = screen.getByTestId(testID).props.style; render(); - expect(earlierStyle).toStrictEqual(STYLE_FOR[colorScheme.get()]); + expect(earlierStyle).toStrictEqual(STYLE_FOR[readColorScheme()]); expect(screen.getByTestId(LATE_TEST_ID).props.style).toStrictEqual( earlierStyle, ); diff --git a/src/__tests__/web/color-scheme.test.ts b/src/__tests__/web/color-scheme.test.ts deleted file mode 100644 index 6e144fde..00000000 --- a/src/__tests__/web/color-scheme.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Appearance } from "react-native"; - -import { colorScheme } from "../../web/api"; - -afterEach(() => { - jest.restoreAllMocks(); -}); - -test.each([ - ["dark", "dark"], - ["light", "light"], - ["unspecified", "light"], - [null, "light"], -] as const)( - "web colorScheme.get() answers %p from Appearance as %p", - (reported, expected) => { - jest.spyOn(Appearance, "getColorScheme").mockReturnValue(reported); - - expect(colorScheme.get()).toBe(expected); - }, -); diff --git a/src/native/api.tsx b/src/native/api.tsx index 41875556..d83262c8 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -4,7 +4,6 @@ import { Appearance, type ViewStyle } from "react-native"; import type { StyleDescriptor } from "react-native-css/compiler"; import { VariableContext } from "react-native-css/native-internal"; -import { resolveColorScheme } from "react-native-css/utilities"; import type { ColorScheme, @@ -19,6 +18,7 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, + resolveColorScheme, VAR_SYMBOL, type Effect, type Getter, diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 55ac143a..1fe243f6 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -2,9 +2,13 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; -import { resolveColorScheme } from "react-native-css/utilities"; - -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)); diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..e88913cf 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -233,6 +233,13 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +// A value that names no scheme states no preference, which MQ5 §12.5 answers with light +export function resolveColorScheme( + value: ColorSchemeName | null | undefined, +): "light" | "dark" { + return value === "light" || value === "dark" ? value : "light"; +} + /** Containers ****************************************************************/ export type ContainerContextValue = Record< diff --git a/src/runtime.types.ts b/src/runtime.types.ts index a257897f..69cab6ca 100644 --- a/src/runtime.types.ts +++ b/src/runtime.types.ts @@ -16,11 +16,7 @@ import type { ViewStyle, } from "react-native"; -import type { - DotNotation, - ResolvedColorScheme, - ResolveDotPath, -} from "react-native-css/utilities"; +import type { DotNotation, ResolveDotPath } from "react-native-css/utilities"; /******************************** API ********************************/ @@ -151,6 +147,6 @@ export type RNStyle = ViewStyle & TextStyle & ImageStyle; /******************************** Globals ********************************/ export interface ColorScheme { - get: () => ResolvedColorScheme; + get: () => ColorSchemeName | null | undefined; set: (value: ColorSchemeName | null | undefined) => void; } diff --git a/src/utilities/color-scheme.ts b/src/utilities/color-scheme.ts deleted file mode 100644 index da28a409..00000000 --- a/src/utilities/color-scheme.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ColorSchemeName } from "react-native"; - -export type ResolvedColorScheme = "light" | "dark"; - -// A value that names no scheme states no preference, which MQ5 §12.5 answers with light -export function resolveColorScheme( - value: ColorSchemeName | null | undefined, -): ResolvedColorScheme { - return value === "light" || value === "dark" ? value : "light"; -} diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 6acb51ba..0c95da63 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,4 +1,3 @@ -export * from "./color-scheme"; export * from "./specificity"; export * from "./style-descriptor"; export * from "./dot-notation.types"; diff --git a/src/web/api.tsx b/src/web/api.tsx index a5c150c6..28e09ab8 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -12,7 +12,6 @@ import type { StyledConfiguration, StyledOptions, } from "react-native-css"; -import { resolveColorScheme } from "react-native-css/utilities"; import type { ReactComponent, Styled } from "../runtime.types"; import { assignStyle } from "./assign-style"; @@ -66,7 +65,7 @@ export const useCssElement = ( export const colorScheme: ColorScheme = { get() { - return resolveColorScheme(Appearance.getColorScheme()); + return Appearance.getColorScheme(); }, set(name) { Appearance.setColorScheme(name ?? "unspecified"); From 53e8fa6362a3c316212fb0a9e79a4af265bef8fe Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 01:22:39 +0300 Subject: [PATCH 6/7] test(native): stop the event recorder in a finally, and type the device double read-only --- .../native/color-scheme-appearance.test.tsx | 36 ++++++++++--------- src/native/conditions/media-query.ts | 1 + 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/__tests__/native/color-scheme-appearance.test.tsx b/src/__tests__/native/color-scheme-appearance.test.tsx index b07fbd7c..8e2ed6c8 100644 --- a/src/__tests__/native/color-scheme-appearance.test.tsx +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -76,10 +76,10 @@ jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => { }); interface DeviceDouble { - startDevice: (platform: Platform, scheme: Scheme) => void; - applyPendingOverrides: () => Scheme[]; - changeSystemScheme: (scheme: Scheme) => Scheme[]; - readSetColorSchemeCalls: () => string[]; + readonly startDevice: (platform: Platform, scheme: Scheme) => void; + readonly applyPendingOverrides: () => readonly Scheme[]; + readonly changeSystemScheme: (scheme: Scheme) => readonly Scheme[]; + readonly readSetColorSchemeCalls: () => readonly string[]; } const device = jest.requireMock<{ default: DeviceDouble }>( @@ -195,20 +195,22 @@ describe.each([ test("colorScheme.set hands each request to Appearance and sends no event of its own", () => { const { sentByCaller, stop } = recordAppearanceEvents(); - act(() => { - colorScheme.set(other); - colorScheme.set(null); - colorScheme.set("unspecified"); - }); - - expect(device.readSetColorSchemeCalls().slice(-3)).toStrictEqual([ - other, - "unspecified", - "unspecified", - ]); - expect(sentByCaller()).toStrictEqual([]); + try { + act(() => { + colorScheme.set(other); + colorScheme.set(null); + colorScheme.set("unspecified"); + }); - stop(); + expect(device.readSetColorSchemeCalls().slice(-3)).toStrictEqual([ + other, + "unspecified", + "unspecified", + ]); + expect(sentByCaller()).toStrictEqual([]); + } finally { + stop(); + } }); test("every layer agrees until the platform applies a request, then takes it", () => { diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 1fe243f6..9c44d4e3 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -2,6 +2,7 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; + import { colorScheme, resolveColorScheme, From 59f737efc0e614909807ad0f60ea21b89bd930c0 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 22 Oct 2026 02:15:48 +0300 Subject: [PATCH 7/7] fix(native): tell Appearance's module apart at import, and give the jest reset a function Appearance answers null at import only without its native module, so a request still reaches the platform after app code hands Appearance back with null, which 0.82 and later cache as it came. The jest reset is resetBetweenTests, which its test calls after changing the scheme, and the scheme tests claim of the library's own layers only what holds before the platform reports. --- .../native/color-scheme-appearance.test.tsx | 34 +++++++++++++++---- .../native/color-scheme-jest-reset.test.tsx | 15 ++++++-- src/jest/index.ts | 11 ++---- src/jest/reset.ts | 13 +++++++ src/native/api.tsx | 9 ++--- src/native/reactivity.ts | 7 +++- 6 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 src/jest/reset.ts diff --git a/src/__tests__/native/color-scheme-appearance.test.tsx b/src/__tests__/native/color-scheme-appearance.test.tsx index 8e2ed6c8..aa0a5271 100644 --- a/src/__tests__/native/color-scheme-appearance.test.tsx +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -151,8 +151,12 @@ const readColorScheme = (): Scheme => { return scheme; }; -const expectEveryLayerToAgree = (): void => { - expect(readEveryLayer()).toStrictEqual(everyLayerOn(readColorScheme())); +// React Native 0.82 to 0.87 cache a setColorScheme request at the call, so an Appearance subscriber that renders before the platform reports reads the request; this library's own layers wait for the report +const expectLibraryLayersToAgree = (): void => { + const scheme = readColorScheme(); + expect(screen.getByTestId(testID).props.style).toStrictEqual( + STYLE_FOR[scheme], + ); }; const recordAppearanceEvents = (): { @@ -213,12 +217,12 @@ describe.each([ } }); - test("every layer agrees until the platform applies a request, then takes it", () => { + test("the class layer and colorScheme.get() keep the scheme in force until the platform applies a request, then every layer takes it", () => { act(() => { colorScheme.set(other); }); - expectEveryLayerToAgree(); + expectLibraryLayersToAgree(); act(() => { deliver(device.applyPendingOverrides()); @@ -247,13 +251,13 @@ describe.each([ }, ); - test("a request and its hand-back made before the platform applies either leave every layer on the system scheme", () => { + test("a request and its hand-back made before the platform applies either leave the library's layers, then every layer, on the system scheme", () => { act(() => { colorScheme.set(other); colorScheme.set(null); }); - expectEveryLayerToAgree(); + expectLibraryLayersToAgree(); let sent: readonly Scheme[] = []; act(() => { @@ -272,7 +276,7 @@ describe.each([ colorScheme.set(system); }); - expectEveryLayerToAgree(); + expectLibraryLayersToAgree(); let sent: readonly Scheme[] = []; act(() => { @@ -297,6 +301,22 @@ describe.each([ expect(readEveryLayer()).toStrictEqual(everyLayerOn(system)); }); + test("a request after app code hands Appearance back with null still reaches the platform", () => { + // React Native 0.81 documented setColorScheme(null) as the hand-back; from 0.82 Appearance caches the null as it came + act(() => { + Reflect.apply(Appearance.setColorScheme, Appearance, [null]); + }); + act(() => { + colorScheme.set(other); + }); + act(() => { + deliver(device.applyPendingOverrides()); + }); + + expect(device.readSetColorSchemeCalls().at(-1)).toBe(other); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); + }); + test("a scheme set through Appearance itself moves every layer once the platform applies it", () => { act(() => { Appearance.setColorScheme(other); diff --git a/src/__tests__/native/color-scheme-jest-reset.test.tsx b/src/__tests__/native/color-scheme-jest-reset.test.tsx index 433b7ee4..6fd56dde 100644 --- a/src/__tests__/native/color-scheme-jest-reset.test.tsx +++ b/src/__tests__/native/color-scheme-jest-reset.test.tsx @@ -1,10 +1,13 @@ import { Appearance } from "react-native"; -import { render, screen } from "@testing-library/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"; +import { resetBetweenTests } from "../../jest/reset"; +import { colorScheme as heldColorScheme } from "../../native/reactivity"; + jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => ({ __esModule: true, default: { @@ -15,7 +18,15 @@ jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => ({ }, })); -test("each test starts from the scheme Appearance reports", () => { +test("the jest helper's reset puts back the scheme Appearance reports after a test changed it", () => { + act(() => { + heldColorScheme.set("light"); + }); + expect(colorScheme.get()).toBe("light"); + + act(() => { + resetBetweenTests(); + }); registerCSS(` .my-class { color: green; } diff --git a/src/jest/index.ts b/src/jest/index.ts index 799033e7..57aa5a3d 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -1,11 +1,9 @@ -import { Appearance, Dimensions } from "react-native"; - import { inspect } from "node:util"; import { compile, type CompilerOptions } from "react-native-css/compiler"; import { StyleCollection } from "react-native-css/native"; -import { colorScheme, dimensions } from "../native/reactivity"; +import { resetBetweenTests } from "./reset"; declare global { /* eslint-disable @typescript-eslint/no-namespace */ @@ -18,12 +16,7 @@ declare global { export const testID = "react-native-css"; -beforeEach(() => { - StyleCollection.styles.clear(); - dimensions.set(Dimensions.get("window")); - Appearance.setColorScheme("unspecified"); - colorScheme.set(Appearance.getColorScheme()); -}); +beforeEach(resetBetweenTests); const debugDefault = Boolean( process.env.REACT_NATIVE_CSS_TEST_DEBUG && diff --git a/src/jest/reset.ts b/src/jest/reset.ts new file mode 100644 index 00000000..2c4530b4 --- /dev/null +++ b/src/jest/reset.ts @@ -0,0 +1,13 @@ +import { Appearance, Dimensions } from "react-native"; + +import { StyleCollection } from "react-native-css/native"; + +import { colorScheme, dimensions } from "../native/reactivity"; + +// Each test starts from the scheme the platform reports once a previous test's request is handed back +export function resetBetweenTests(): void { + StyleCollection.styles.clear(); + dimensions.set(Dimensions.get("window")); + Appearance.setColorScheme("unspecified"); + colorScheme.set(Appearance.getColorScheme()); +} diff --git a/src/native/api.tsx b/src/native/api.tsx index d83262c8..9e6cefec 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -18,6 +18,7 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, + hasAppearanceModule, resolveColorScheme, VAR_SYMBOL, type Effect, @@ -73,11 +74,11 @@ export const colorScheme: ColorScheme = { return resolveColorScheme(colorSchemeObs.get()); }, set(value) { - // Appearance answers null only without its native module, where nothing but this library holds a scheme - if (Appearance.getColorScheme() === null) { - colorSchemeObs.set(value); - } else { + // Without Appearance's native module nothing but this library holds a scheme + if (hasAppearanceModule) { Appearance.setColorScheme(value ?? "unspecified"); + } else { + colorSchemeObs.set(value); } }, }; diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index e88913cf..878120e9 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -228,8 +228,13 @@ Dimensions.addEventListener("change", ({ window }) => { /** Color Scheme **************************************************************/ +const colorSchemeAtImport = Appearance.getColorScheme(); + +// Appearance answers null at import only without its native module, which app code's later setColorScheme(null) cannot fake +export const hasAppearanceModule = colorSchemeAtImport !== null; + export const colorScheme = observable( - Appearance.getColorScheme(), + colorSchemeAtImport, ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));