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..aa0a5271 --- /dev/null +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -0,0 +1,377 @@ +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"; + +type Scheme = "light" | "dark"; +type Platform = "ios" | "android"; + +// 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, + 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); + pendingOverrides.push(next); + }, + startDevice: (nextPlatform: string, scheme: string) => { + platform = nextPlatform; + systemScheme = scheme; + override = null; + sentScheme = scheme; + pendingOverrides = []; + setColorSchemeCalls = []; + }, + // 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 sent; + }, + changeSystemScheme: (scheme: string): string[] => { + systemScheme = scheme; + const sent: string[] = []; + sendIfMoved(sent); + return sent; + }, + readSetColorSchemeCalls: () => [...setColorSchemeCalls], + }, + }; +}); + +interface DeviceDouble { + 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 }>( + "react-native/Libraries/Utilities/NativeAppearance", +).default; + +const deliver = (sent: readonly Scheme[]): readonly Scheme[] => { + for (const scheme of sent) { + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: scheme }); + } + return sent; +}; + +const subscribeToAppearance = (onStoreChange: () => void): (() => void) => { + const subscription = Appearance.addChangeListener(onStoreChange); + return () => { + subscription.remove(); + }; +}; + +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, readAppearance); + + return {scheme ?? "unset"}; +}; + +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 STYLE_FOR: Readonly> = { + light: { color: "#00f" }, + dark: { color: "#f00" }, +}; + +const LATE_TEST_ID = "late-element"; + +const readEveryLayer = () => ({ + classLayer: screen.getByTestId(testID).props.style, + colorSchemeGet: colorScheme.get(), + subscribed: screen.getByTestId("subscribed-color-scheme").props.children, +}); + +const everyLayerOn = (scheme: Scheme) => ({ + classLayer: STYLE_FOR[scheme], + colorSchemeGet: scheme, + subscribed: 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; +}; + +// 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 = (): { + sentByCaller: () => readonly unknown[]; + stop: () => void; +} => { + const emit = jest.spyOn(DeviceEventEmitter, "emit"); + + return { + sentByCaller: () => + emit.mock.calls.filter(([event]) => event === "appearanceChanged"), + stop: () => { + emit.mockRestore(); + }, + }; +}; + +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); + + beforeEach(() => { + device.startDevice(platform, system); + act(() => { + DeviceEventEmitter.emit("appearanceChanged", { colorScheme: system }); + }); + registerCSS(TRI_STATE_CSS); + render( + <> + + + , + ); + }); + + test("colorScheme.set hands each request to Appearance and sends no event of its own", () => { + const { sentByCaller, stop } = recordAppearanceEvents(); + + try { + act(() => { + colorScheme.set(other); + colorScheme.set(null); + colorScheme.set("unspecified"); + }); + + expect(device.readSetColorSchemeCalls().slice(-3)).toStrictEqual([ + other, + "unspecified", + "unspecified", + ]); + expect(sentByCaller()).toStrictEqual([]); + } finally { + stop(); + } + }); + + 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); + }); + + expectLibraryLayersToAgree(); + + act(() => { + deliver(device.applyPendingOverrides()); + }); + + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); + }); + + 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)); + }, + ); + + 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); + }); + + expectLibraryLayersToAgree(); + + let sent: readonly Scheme[] = []; + act(() => { + sent = deliver(device.applyPendingOverrides()); + }); + + expect(sent).toStrictEqual(platform === "ios" ? [] : [other, system]); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(system)); + }); + + test("each scheme the platform applies moves every layer, ending on the last request", () => { + act(() => { + colorScheme.set(other); + }); + act(() => { + colorScheme.set(system); + }); + + expectLibraryLayersToAgree(); + + let sent: readonly Scheme[] = []; + act(() => { + sent = deliver(device.applyPendingOverrides()); + }); + + expect(sent).toStrictEqual(platform === "ios" ? [] : [other, system]); + expect(readEveryLayer()).toStrictEqual(everyLayerOn(system)); + }); + + test("a request for the scheme in force sends nothing and moves no layer", () => { + act(() => { + colorScheme.set(system); + }); + + let sent: readonly Scheme[] = []; + act(() => { + sent = deliver(device.applyPendingOverrides()); + }); + + expect(sent).toStrictEqual([]); + 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); + }); + act(() => { + deliver(device.applyPendingOverrides()); + }); + + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); + }); + + test("an element mounted before the platform applies a request renders what one mounted earlier renders", () => { + act(() => { + colorScheme.set(null); + Appearance.setColorScheme(other); + }); + const earlierStyle: unknown = screen.getByTestId(testID).props.style; + render(); + + expect(earlierStyle).toStrictEqual(STYLE_FOR[readColorScheme()]); + expect(screen.getByTestId(LATE_TEST_ID).props.style).toStrictEqual( + earlierStyle, + ); + }); + + test("an OS change moves every layer, and none while a request overrides it", () => { + act(() => { + deliver(device.changeSystemScheme(other)); + }); + + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); + + act(() => { + colorScheme.set(other); + }); + act(() => { + deliver(device.applyPendingOverrides()); + }); + act(() => { + deliver(device.changeSystemScheme(system)); + }); + + expect(readEveryLayer()).toStrictEqual(everyLayerOn(other)); + }); +}); + +test("a value Appearance reports that names no scheme resolves to light on both layers", () => { + device.startDevice("ios", "dark"); + registerCSS(TRI_STATE_CSS); + render(); + + act(() => { + colorSchemeObservable.set("unspecified"); + }); + + 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..6fd56dde --- /dev/null +++ b/src/__tests__/native/color-scheme-jest-reset.test.tsx @@ -0,0 +1,43 @@ +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"; + +import { resetBetweenTests } from "../../jest/reset"; +import { colorScheme as heldColorScheme } from "../../native/reactivity"; + +jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => ({ + __esModule: true, + default: { + addListener: () => undefined, + removeListeners: () => undefined, + getColorScheme: () => "dark", + setColorScheme: () => undefined, + }, +})); + +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; } + +@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..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(null); -}); +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 6736f746..9e6cefec 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -18,6 +18,8 @@ import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, colorScheme as colorSchemeObs, + hasAppearanceModule, + resolveColorScheme, VAR_SYMBOL, type Effect, type Getter, @@ -69,10 +71,15 @@ 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); + // 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/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..878120e9 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -228,11 +228,23 @@ 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)); +// 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<