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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@
"@eslint/js": "^10.0.1",
"@expo/metro-config": "~54.0.5",
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
"@react-native/jest-preset": "^0.85.0",
"@release-it/conventional-changelog": "10.0.1",
"@tailwindcss/postcss": "^4.1.12",
"@testing-library/react-native": "^13.3.3",
Expand Down
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" });
});
21 changes: 21 additions & 0 deletions src/__tests__/native/components.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
Button as RNButton,
ScrollView as RNScrollView,
TextInput as RNTextInput,
type ButtonProps,
type TextInputProps,
Expand All @@ -11,6 +12,7 @@ import { TextInput } from "react-native-css/components/TextInput";
import { registerCSS, testID } from "react-native-css/jest";
import { useCssElement } from "react-native-css/native";
import type {
ComponentPropsDotNotation,
StyledConfiguration,
StyledProps,
} from "react-native-css/runtime.types";
Expand Down Expand Up @@ -114,3 +116,22 @@ test("nativeStyleMapping with boolean true on custom component", () => {
expect(component.props.textAlign).toBe("right");
expect(component.props.style).not.toHaveProperty("textAlign");
});

test("a prop holding a component instance is a dot-notation leaf", () => {
// `ScrollViewProps.scrollViewRef` is a `RefObject<ScrollView>`, and `ScrollView`
// carries `ScrollViewProps` again, so the prop graph is cyclic. Enumerating it
// produces paths that can never be a mapping target, and enough of them that
// `StyledConfiguration<typeof ScrollView>`, and every component built on it, stops
// compiling with TS2590 on react-native >=0.83.
const reachable: ComponentPropsDotNotation<typeof RNScrollView>[] = [
"scrollViewRef",
"scrollViewRef.current",
];

// @ts-expect-error - the instance behind the ref is a leaf, so its props are not paths
const throughInstance: ComponentPropsDotNotation<typeof RNScrollView> =
"scrollViewRef.current.props.style";

expect(reachable).toHaveLength(2);
expect(throughInstance).toBe("scrollViewRef.current.props.style");
});
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(null);
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<Effect>;
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 @@ -8,12 +8,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 @@ -144,6 +139,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;
set: (value: ColorSchemeName) => void;
Expand Down
16 changes: 14 additions & 2 deletions src/utilities/dot-notation.types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* eslint-disable */
import type { Component } from "react";

// ---------- Base Utilities ----------

type Falsy = undefined | null | false | "";
Expand Down Expand Up @@ -51,13 +53,23 @@ type ExtractStyleObject<T> = RemoveRegisteredStyle<
RemoveFalsy<UnwrapRecursiveArray<T extends StyleProp<infer U> ? U : T>>
>;

// Check if something is a non-array plain object
// Check if something is a non-array plain object.
//
// A class component instance is not one. It is reachable only through a ref, it holds
// its own props, and those props hold refs again, so the prop graph is cyclic. Walking
// it yields paths that can never be a mapping target, and the path set grows
// exponentially with depth: `ScrollViewProps["scrollViewRef"]` alone contributes 4,654
// such paths on react-native 0.81, and from 0.83 the host instance surface is wide
// enough that building the union overflows TypeScript's limit. Treating the instance as
// a leaf cuts the cycle where it starts.
type IsPlainObject<T> = T extends object
? T extends Function
? false
: T extends readonly any[]
? false
: true
: T extends Component<any, any>
? false
: true
: false;

// ---------- Resolve Path Type Helpers ----------
Expand Down
3 changes: 2 additions & 1 deletion src/web/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
StyledProps,
} from "react-native-css";

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

Expand Down Expand Up @@ -73,7 +74,7 @@ export const colorScheme: ColorScheme = {
return Appearance.getColorScheme();
},
set(name) {
Appearance.setColorScheme(name);
setAppearanceColorScheme(name);
},
};

Expand Down
Loading