From 43e9b80586404d2ef8920ea068cdc5f3c242fab3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 27 Oct 2026 02:13:06 +0200 Subject: [PATCH] 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 alone, so useColorScheme() and every Appearance.addChangeListener store kept the old scheme, and after a follow-the-system request the class layer held null and matched neither prefers-color-scheme: light nor dark. set() hands each request to Appearance.setColorScheme, and the class layer follows the scheme the platform reports through Appearance. Until the platform has reported one, what Appearance answers after the latest request stands for it, which without a native module, as in a jest suite, is the request itself. colorScheme.get() and prefers-color-scheme, on a rule and on a :root variable, resolve a value that names no scheme to light (MQ5 §12.5), and the jest reset starts each test from what Appearance answers. --- src/__tests__/compiler/media-query.test.ts | 31 ++ .../native/color-scheme-appearance.test.tsx | 458 ++++++++++++++++++ .../native/color-scheme-jest-reset.test.tsx | 43 ++ ...-scheme-without-appearance-module.test.tsx | 81 ++++ src/jest/index.ts | 11 +- src/jest/reset.ts | 18 + src/native/api.tsx | 9 +- src/native/conditions/media-query.ts | 4 +- src/native/reactivity.ts | 19 + 9 files changed, 660 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/native/color-scheme-appearance.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/jest/reset.ts 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<