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<