Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/__tests__/native/color-scheme.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
Appearance,
type ColorSchemeName as ReactNativeColorSchemeName,
} 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 type { ColorSchemeName } from "react-native-css/runtime.types";

import { setAppearanceColorScheme } from "../../color-scheme";

const mockSetColorScheme = jest.fn();

// `Appearance` reads its native module lazily, so mocking the module is enough to make
// the writes observable. Without it `TurboModuleRegistry.get('Appearance')` is null
// under jest and every `setColorScheme` is a silent no-op.
jest.mock("react-native/Libraries/Utilities/NativeAppearance", () => ({
__esModule: true,
default: {
setColorScheme: (scheme: string) => {
mockSetColorScheme(scheme);
},
// `getColorScheme` feeds react-native's own post-write bookkeeping, which asserts
// the value is a resolved scheme or absent.
getColorScheme: () => undefined,
addListener: () => undefined,
removeListeners: () => undefined,
},
}));

/******************************* Compile plane ******************************/

type Assert<T extends true> = T;
type Covers<Actual, Accepted> = [Actual] extends [Accepted] ? true : false;

type AppearanceChangeColorScheme = Parameters<
Parameters<typeof Appearance.addChangeListener>[0]
>[0]["colorScheme"];

/**
* `ColorSchemeName` is react-native-css's own union rather than react-native's, because
* react-native's changes shape inside this package's peer range: up to 0.85 "follow the
* system" is `null | undefined`, from 0.86 it is `"unspecified"`.
*
* These aliases are read off the installed react-native rather than restating either
* spelling, so `yarn typecheck` fails at whichever end of the range the checkout is on
* the moment the package stops covering a value react-native hands it.
*/
export type CoversReactNativeUnion = Assert<
Covers<ReactNativeColorSchemeName, ColorSchemeName>
>;
export type CoversAppearanceRead = Assert<
Covers<ReturnType<typeof Appearance.getColorScheme>, ColorSchemeName>
>;
export type CoversAppearanceEvent = Assert<
Covers<AppearanceChangeColorScheme, ColorSchemeName>
>;

/******************************* Runtime plane ******************************/

beforeEach(() => {
mockSetColorScheme.mockClear();
});

test.each([
["light", "light"],
["dark", "dark"],
["unspecified", "unspecified"],
[null, "unspecified"],
[undefined, "unspecified"],
] satisfies [ColorSchemeName, string][])(
"setAppearanceColorScheme(%p) reaches the native module as %p",
(value, expected) => {
setAppearanceColorScheme(value);

expect(mockSetColorScheme).toHaveBeenCalledTimes(1);
expect(mockSetColorScheme).toHaveBeenCalledWith(expected);
},
);

test("prefers-color-scheme follows the scheme and releases on 'unspecified'", () => {
registerCSS(`
.my-class { color: blue; }

@media (prefers-color-scheme: dark) {
.my-class { color: red; }
}`);

render(<View testID={testID} className="my-class" />);
const component = screen.getByTestId(testID);

expect(component.props.style).toStrictEqual({ color: "#00f" });

act(() => {
colorScheme.set("dark");
});
expect(component.props.style).toStrictEqual({ color: "#f00" });

act(() => {
colorScheme.set("unspecified");
});
expect(component.props.style).toStrictEqual({ color: "#00f" });
});
35 changes: 35 additions & 0 deletions src/color-scheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Appearance } from "react-native";

import type { ColorSchemeName } from "./runtime.types";

/**
* `Appearance.setColorScheme` is not declared the same way across the supported
* `react-native` range. On 0.81 it takes `"light" | "dark" | null | undefined`; from
* 0.82 it takes `"light" | "dark" | "unspecified"` and rejects `null`. The two
* parameter types overlap only on `"light" | "dark"`, so no single call can name the
* "follow the system" value and type-check on both.
*
* `"unspecified"` is the value that is right at runtime on every version: 0.82 and
* later hand the argument straight to the native module, and 0.81 maps `null` to
* `"unspecified"` before doing the same. So only the declaration has to be restated,
* and only for the one value the versions disagree about.
*/
interface AppearanceColorSchemeReset {
setColorScheme: (colorScheme: "unspecified") => void;
}

/**
* Writes a color scheme to `Appearance` in a way that compiles and runs on every
* `react-native` this package supports.
*/
export function setAppearanceColorScheme(value: ColorSchemeName): void {
if (value === "light" || value === "dark") {
// Declared identically by every version in the range.
Appearance.setColorScheme(value);
return;
}

(Appearance as unknown as AppearanceColorSchemeReset).setColorScheme(
"unspecified",
);
}
5 changes: 3 additions & 2 deletions src/jest/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Appearance, Dimensions } from "react-native";
import { 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 { setAppearanceColorScheme } from "../color-scheme";
import { colorScheme, dimensions } from "../native/reactivity";

declare global {
Expand All @@ -21,7 +22,7 @@ export const testID = "react-native-css";
beforeEach(() => {
StyleCollection.styles.clear();
dimensions.set(Dimensions.get("window"));
Appearance.setColorScheme("unspecified");
setAppearanceColorScheme(null);
colorScheme.set(null);
});

Expand Down
9 changes: 3 additions & 6 deletions src/native/reactivity.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
/* eslint-disable */
import { createContext } from "react";
import {
Appearance,
Dimensions,
type ColorSchemeName,
type LayoutRectangle,
} from "react-native";
import { Appearance, Dimensions, type LayoutRectangle } from "react-native";

import type { StyleDescriptor } from "react-native-css/compiler";

import type { ColorSchemeName } from "../runtime.types";

export type Effect = {
observers: Set<Observable<any, any>>;
run(): void;
Expand Down
24 changes: 18 additions & 6 deletions src/runtime.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,7 @@ import type {
FunctionComponent,
ReactElement,
} from "react";
import type {
ColorSchemeName,
ImageStyle,
TextStyle,
ViewStyle,
} from "react-native";
import type { ImageStyle, TextStyle, ViewStyle } from "react-native";

import type { DotNotation, ResolveDotPath } from "react-native-css/utilities";

Expand Down Expand Up @@ -146,6 +141,23 @@ export type RNStyle = ViewStyle & TextStyle & ImageStyle;

/******************************** Globals ********************************/

/**
* A color scheme, plus every spelling of "follow the system" in the supported
* `react-native` range.
*
* This deliberately does not reuse `react-native`'s own `ColorSchemeName`: that type
* is not stable across the `react-native` peer range. On 0.81 it is
* `"light" | "dark" | null | undefined`; from 0.82 it is
* `"light" | "dark" | "unspecified"`. Owning the union keeps `colorScheme` one API
* across the whole range, and accepts whichever spelling the installed version emits.
*/
export type ColorSchemeName =
| "light"
| "dark"
| "unspecified"
| null
| undefined;

export interface ColorScheme {
get: () => ColorSchemeName | null | undefined;
set: (value: ColorSchemeName | null | undefined) => void;
Expand Down
3 changes: 2 additions & 1 deletion src/web/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
StyledOptions,
} from "react-native-css";

import { setAppearanceColorScheme } from "../color-scheme";
import type { ReactComponent, Styled } from "../runtime.types";
import { assignStyle } from "./assign-style";

Expand Down Expand Up @@ -68,7 +69,7 @@ export const colorScheme: ColorScheme = {
return Appearance.getColorScheme();
},
set(name) {
Appearance.setColorScheme(name ?? "unspecified");
setAppearanceColorScheme(name);
},
};

Expand Down