diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 76994001..ff6c24ae 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -20,6 +20,37 @@ test.each(["android", "ios"])( }, ); +test("@media (prefers-color-scheme) conditions a rule and a :root variable", () => { + const compiled = compile(` + .my-class { color: green; } + @media (prefers-color-scheme: light) { + .my-class { color: blue; } + :root { --scheme-color: #00f; } + } + @media (prefers-color-scheme: dark) { + .my-class { color: red; } + :root { --scheme-color: #f0f; } + } + .scheme-variable { color: var(--scheme-color); } + `); + + const stylesheet = compiled.stylesheet(); + expect(stylesheet.s?.[0]?.[1].map((rule) => [rule.d, rule.m])).toStrictEqual([ + [[{ color: "#008000" }], undefined], + [[{ color: "#00f" }], [["=", "prefers-color-scheme", "light"]]], + [[{ color: "#f00" }], [["=", "prefers-color-scheme", "dark"]]], + ]); + expect(stylesheet.vr).toStrictEqual([ + [ + "scheme-color", + [ + ["#f0f", [["=", "prefers-color-scheme", "dark"]]], + ["#00f", [["=", "prefers-color-scheme", "light"]]], + ], + ], + ]); +}); + test("@media (hover: hover)", () => { const compiled = compile(` @media (hover: hover) { 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..c0c9d4e2 --- /dev/null +++ b/src/__tests__/native/color-scheme-appearance.test.tsx @@ -0,0 +1,458 @@ +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; +}; + +interface LoadedAfterHandBack { + readonly device: DeviceDouble; + readonly reactNative: typeof import("react-native"); + readonly library: typeof import("react-native-css/runtime"); + readonly applyPendingOverrides: () => void; +} + +// A fresh module registry runs app code's hand-back before this library loads, as it can run before a second build of the library loads in one bundle +const loadAfterNullHandBack = ( + platform: Platform, + system: Scheme, + use: (loaded: LoadedAfterHandBack) => void, +): void => { + jest.isolateModules(() => { + const laterDevice = jest.requireMock<{ default: DeviceDouble }>( + "react-native/Libraries/Utilities/NativeAppearance", + ).default; + laterDevice.startDevice(platform, system); + const reactNative = + jest.requireActual("react-native"); + Reflect.apply( + reactNative.Appearance.setColorScheme, + reactNative.Appearance, + [null], + ); + + use({ + device: laterDevice, + reactNative, + library: jest.requireActual( + "react-native-css/runtime", + ), + applyPendingOverrides: () => { + for (const scheme of laterDevice.applyPendingOverrides()) { + reactNative.DeviceEventEmitter.emit("appearanceChanged", { + colorScheme: scheme, + }); + } + }, + }); + }); +}; + +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; +}; + +// From 0.82 React Native caches 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 expectLibraryLayersOn = (scheme: Scheme): void => { + expect(colorScheme.get()).toBe(scheme); + 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); + }); + + expectLibraryLayersOn(system); + + 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); + }); + + expectLibraryLayersOn(system); + + 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); + }); + + expectLibraryLayersOn(system); + + 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 request reaches the platform when this library loads after app code hands Appearance back with null", () => { + loadAfterNullHandBack(platform, system, (loaded) => { + loaded.library.colorScheme.set(other); + loaded.applyPendingOverrides(); + + expect(loaded.device.readSetColorSchemeCalls()).toStrictEqual([ + null, + other, + ]); + expect(loaded.reactNative.Appearance.getColorScheme()).toBe(other); + expect(loaded.library.colorScheme.get()).toBe(other); + }); + }); + + test.each([system, null])( + "set(%p) from this library loaded after app code hands Appearance back with null ends on the scheme in force", + (request) => { + loadAfterNullHandBack(platform, system, (loaded) => { + loaded.library.colorScheme.set(request); + loaded.applyPendingOverrides(); + + expect(loaded.reactNative.Appearance.getColorScheme()).toBe(system); + expect(loaded.library.colorScheme.get()).toBe(system); + }); + }, + ); + + test("requests made together from this library loaded after app code hands Appearance back with null end on the last", () => { + loadAfterNullHandBack(platform, system, (loaded) => { + loaded.library.colorScheme.set(other); + loaded.library.colorScheme.set(system); + loaded.applyPendingOverrides(); + + expect(loaded.reactNative.Appearance.getColorScheme()).toBe(system); + expect(loaded.library.colorScheme.get()).toBe(system); + }); + }); + + 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..e3d711c9 --- /dev/null +++ b/src/__tests__/native/color-scheme-without-appearance-module.test.tsx @@ -0,0 +1,81 @@ +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"; + +const TRI_STATE_CSS = ` +.my-class { color: green; } + +@media (prefers-color-scheme: light) { + .my-class { color: blue; } + :root { --scheme-color: #00f; } +} + +@media (prefers-color-scheme: dark) { + .my-class { color: red; } + :root { --scheme-color: #f0f; } +} + +.scheme-variable { color: var(--scheme-color); }`; + +const VARIABLE_TEST_ID = "scheme-variable"; + +const BLUE = { color: "#00f" } as const; +const RED = { color: "#f00" } as const; +const MAGENTA = { color: "#f0f" } as const; + +beforeEach(() => { + registerCSS(TRI_STATE_CSS); + render( + <> + + + , + ); +}); + +test("without an Appearance module, colorScheme.set holds the scheme for every layer", () => { + act(() => { + colorScheme.set("dark"); + }); + + expect(Appearance.getColorScheme()).toBeNull(); + expect(colorScheme.get()).toBe("dark"); + expect(screen.getByTestId(testID).props.style).toStrictEqual(RED); + expect(screen.getByTestId(VARIABLE_TEST_ID).props.style).toStrictEqual( + MAGENTA, + ); +}); + +test.each([null, "unspecified"] as const)( + "without an Appearance module, set(%p) resolves light on every layer", + (handBack) => { + act(() => { + colorScheme.set("dark"); + }); + act(() => { + colorScheme.set(handBack); + }); + + expect(colorScheme.get()).toBe("light"); + expect(screen.getByTestId(testID).props.style).toStrictEqual(BLUE); + expect(screen.getByTestId(VARIABLE_TEST_ID).props.style).toStrictEqual( + BLUE, + ); + }, +); + +test("the jest helper's reset forgets a scheme this library held without an Appearance module", () => { + act(() => { + colorScheme.set("dark"); + }); + act(() => { + resetBetweenTests(); + }); + + expect(colorScheme.get()).toBe("light"); +}); 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..d962032a --- /dev/null +++ b/src/jest/reset.ts @@ -0,0 +1,18 @@ +import { Appearance, Dimensions } from "react-native"; + +import { StyleCollection } from "react-native-css/native"; + +import { + colorScheme, + dimensions, + requestedColorScheme, +} from "../native/reactivity"; + +// Each test starts from what Appearance answers once a previous test's request is handed back, and from no request of this library's +export function resetBetweenTests(): void { + StyleCollection.styles.clear(); + dimensions.set(Dimensions.get("window")); + Appearance.setColorScheme("unspecified"); + colorScheme.set(Appearance.getColorScheme()); + requestedColorScheme.set(undefined); +} diff --git a/src/native/api.tsx b/src/native/api.tsx index 6736f746..cad5e52c 100644 --- a/src/native/api.tsx +++ b/src/native/api.tsx @@ -17,7 +17,8 @@ import { mappingToConfig, useNativeCss } from "./react/useNativeCss"; import { usePassthrough } from "./react/usePassthrough"; import { cleanupEffect, - colorScheme as colorSchemeObs, + requestedColorScheme, + resolveColorScheme, VAR_SYMBOL, type Effect, type Getter, @@ -69,10 +70,12 @@ export const styled: Styled = ( export const colorScheme: ColorScheme = { get() { - return colorSchemeObs.get() ?? Appearance.getColorScheme() ?? "light"; + return resolveColorScheme((observable) => observable.get()); }, set(value) { - return colorSchemeObs.set(value === "unspecified" ? null : value); + Appearance.setColorScheme(value ?? "unspecified"); + // Appearance answers null only without its native module, where nothing but this library holds the request + requestedColorScheme.set(Appearance.getColorScheme() ?? value); }, }; diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index afd51250..77aabcdb 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -3,7 +3,7 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; -import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { resolveColorScheme, vh, vw, type Getter } from "../reactivity"; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); @@ -45,7 +45,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); } case "display-mode": return value === "native" || Platform.OS === value; diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 296988a6..f88fdd0a 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -233,6 +233,25 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +// What Appearance answers after this library's latest request, which stands for the scheme until the platform reports one +export const requestedColorScheme = observable< + ColorSchemeName | null | undefined +>(undefined); + +const namesColorScheme = ( + value: ColorSchemeName | null | undefined, +): value is "light" | "dark" => value === "light" || value === "dark"; + +// A value that names no scheme states no preference, which MQ5 ยง12.5 answers with light +export function resolveColorScheme(get: Getter): "light" | "dark" { + const reported = get(colorScheme); + if (namesColorScheme(reported)) { + return reported; + } + const requested = get(requestedColorScheme); + return namesColorScheme(requested) ? requested : "light"; +} + /** Containers ****************************************************************/ export type ContainerContextValue = Record<