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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,18 @@ This API only allows for setting CSS variables as primitive values. For more com
> [!IMPORTANT]
> By using `VariableContext` you may need to disable the `inlineVariable` optimization

## Color scheme

`colorScheme.get()` returns the scheme in effect, and `colorScheme.set()` forces `"dark"` or `"light"`; `colorScheme.set(null)` follows the system again.

```ts
import { colorScheme } from "react-native-css";

colorScheme.set("dark");
```

On native, the forced scheme answers every `prefers-color-scheme` condition. On web no page can override `prefers-color-scheme`, so `set()` puts a `dark` or `light` class on `<html>` and sets its `color-scheme`. Rules keyed on that class, such as a dark variant declared as `@custom-variant dark (&:where(.dark, .dark *))`, and every `light-dark()` value follow it. A rule inside `@media (prefers-color-scheme: dark)` keeps the system's answer.

## Optimizations

CSS is a dynamic styling language that use highly optimized engines that are not available in React Native. Instead, we optimize the styles to improve performance
Expand Down
8 changes: 8 additions & 0 deletions src/__tests__/compiler/media-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ test("@media (hover: hover)", () => {
],
});
});

test.each(["dark", "light"])("@media (prefers-color-scheme: %s)", (scheme) => {
const rule = compile(
`@media (prefers-color-scheme: ${scheme}) { .my-class { color: red; } }`,
).stylesheet().s?.[0]?.[1][0];

expect(rule?.m).toStrictEqual([["=", "prefers-color-scheme", scheme]]);
});
190 changes: 190 additions & 0 deletions src/__tests__/metro/global-class-name-polyfill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { resolve, sep } from "node:path";

import type { MetroConfig } from "metro-config";
import type {
CustomResolutionContext,
CustomResolver,
Resolution,
} from "metro-resolver";

import { withReactNativeCSS } from "../../metro";

// globalClassNamePolyfill decides whether the resolvers run at all; this covers the dispatch into them

// The resolver reads two fields off the context, so the fixture bridges the rest of ResolutionContext once
function makeContext(
originModulePath: string,
resolveRequest: CustomResolver,
): CustomResolutionContext {
return {
originModulePath,
resolveRequest,
} as unknown as CustomResolutionContext;
}

function makeRecorder(): { calls: string[]; resolver: CustomResolver } {
const calls: string[] = [];

const resolver: CustomResolver = (_context, moduleName): Resolution => {
calls.push(moduleName);
return {
type: "sourceFile",
filePath: resolve("/app/node_modules", moduleName, "index.js"),
};
};

return { calls, resolver };
}

function makeConfig(
options?: Parameters<typeof withReactNativeCSS>[1],
): MetroConfig {
return withReactNativeCSS<MetroConfig>(
{},
{ disableTypeScriptGeneration: true, ...options },
);
}

function resolveThrough(
config: MetroConfig,
moduleName: string,
platform: string | null,
resolver: CustomResolver,
): Resolution {
const resolveRequest = config.resolver?.resolveRequest;

if (!resolveRequest) {
throw new Error("withReactNativeCSS did not install a resolveRequest");
}

return resolveRequest(
makeContext(resolve("/app/index.js"), resolver),
moduleName,
platform,
);
}

describe("globalClassNamePolyfill", () => {
test("is off by default, so react-native resolves untouched", () => {
const { calls, resolver } = makeRecorder();

const resolution = resolveThrough(
makeConfig(),
"react-native",
"ios",
resolver,
);

expect(calls).toStrictEqual(["react-native"]);
expect(resolution).toStrictEqual({
type: "sourceFile",
filePath: resolve("/app/node_modules/react-native/index.js"),
});
});

test("routes react-native to the components barrel when on", () => {
const { calls, resolver } = makeRecorder();

const resolution = resolveThrough(
makeConfig({ globalClassNamePolyfill: true }),
"react-native",
"ios",
resolver,
);

expect(calls).toStrictEqual([
"react-native",
"react-native-css/components",
]);
expect(resolution).toStrictEqual({
type: "sourceFile",
filePath: resolve(
"/app/node_modules/react-native-css/components/index.js",
),
});
});

test("dispatches to the web resolver on the web platform when on", () => {
const { calls, resolver } = makeRecorder();

const resolution = resolveThrough(
makeConfig({ globalClassNamePolyfill: true }),
"react-native-web/dist/exports/View",
"web",
resolver,
);

// Only the web resolver rewrites a resolved react-native-web path; the native one keys on the module name
expect(calls).toStrictEqual([
"react-native-web/dist/exports/View",
"react-native-css/components/View",
]);
expect(resolution).toStrictEqual({
type: "sourceFile",
filePath: resolve(
"/app/node_modules/react-native-css/components/View/index.js",
),
});
});

test("leaves the same web module alone when off", () => {
const { calls, resolver } = makeRecorder();

const resolution = resolveThrough(
makeConfig(),
"react-native-web/dist/exports/View",
"web",
resolver,
);

expect(calls).toStrictEqual(["react-native-web/dist/exports/View"]);
expect(resolution).toStrictEqual({
type: "sourceFile",
filePath: resolve(
"/app/node_modules/react-native-web/dist/exports/View/index.js",
),
});
});

test.each([true, false])(
"short-circuits the metro override without consulting the parent (polyfill: %s)",
(globalClassNamePolyfill) => {
const { calls, resolver } = makeRecorder();

const resolution = resolveThrough(
makeConfig({ globalClassNamePolyfill }),
"react-native-css-metro-override",
"ios",
resolver,
);

expect(calls).toStrictEqual([]);
expect(resolution).toStrictEqual({
type: "sourceFile",
filePath: expect.stringContaining(`${sep}override.`),
});
},
);

test("prefers an existing resolveRequest over the one on the context", () => {
const existing = makeRecorder();
const fromContext = makeRecorder();

const config = withReactNativeCSS<MetroConfig>(
{ resolver: { resolveRequest: existing.resolver } },
{ disableTypeScriptGeneration: true, globalClassNamePolyfill: true },
);

resolveThrough(config, "react-native", "ios", fromContext.resolver);

expect(existing.calls).toStrictEqual([
"react-native",
"react-native-css/components",
]);
expect(fromContext.calls).toStrictEqual([]);
});

test("appends the css source extension regardless of the gate", () => {
expect(makeConfig().resolver?.sourceExts).toStrictEqual(["css"]);
});
});
12 changes: 12 additions & 0 deletions src/__tests__/web/color-scheme-prerender.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Appearance } from "react-native";

import { colorScheme } from "../../web/api";

test("with no document, as while a route prerenders, set() is a no-op and get() reads the system scheme", () => {
expect(typeof document).toBe("undefined");

expect(() => {
colorScheme.set("dark");
}).not.toThrow();
expect(colorScheme.get()).toBe(Appearance.getColorScheme() ?? "light");
});
69 changes: 69 additions & 0 deletions src/__tests__/web/color-scheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @jest-environment jsdom
*/
import { Appearance } from "react-native";

import { colorScheme } from "../../web/api";

const root = () => document.documentElement;

afterEach(() => {
colorScheme.set(null);
root().removeAttribute("class");
root().removeAttribute("style");
});

test("react-native-web's Appearance has no setter to forward to", () => {
const { Appearance: WebAppearance } =
jest.requireActual<Record<"Appearance", object>>("react-native-web");

expect(WebAppearance).not.toHaveProperty("setColorScheme");
});

test.each(["dark", "light"] as const)(
"set(%s) marks the root with the scheme and its color-scheme",
(scheme) => {
colorScheme.set(scheme);

expect([...root().classList]).toStrictEqual([scheme]);
expect(root().style.getPropertyValue("color-scheme")).toBe(scheme);
expect(colorScheme.get()).toBe(scheme);
},
);

test("a scheme replaces the one set before it", () => {
colorScheme.set("dark");
colorScheme.set("light");

expect([...root().classList]).toStrictEqual(["light"]);
expect(root().style.getPropertyValue("color-scheme")).toBe("light");
});

test.each([null, undefined, "unspecified"] as const)(
"set(%p) returns the root to the system scheme",
(value) => {
colorScheme.set("dark");
colorScheme.set(value);

expect([...root().classList]).toStrictEqual([]);
expect(root().style.getPropertyValue("color-scheme")).toBe("");
expect(colorScheme.get()).toBe(Appearance.getColorScheme() ?? "light");
},
);

test("get() reads a scheme the app put on the root itself", () => {
root().classList.add("dark");

expect(colorScheme.get()).toBe("dark");
});

test("the root keeps its other classes and inline styles", () => {
root().classList.add("app");
root().style.setProperty("font-size", "16px");

colorScheme.set("dark");
colorScheme.set(null);

expect([...root().classList]).toStrictEqual(["app"]);
expect(root().style.getPropertyValue("font-size")).toBe("16px");
});
41 changes: 39 additions & 2 deletions src/web/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,49 @@ export const useCssElement = <C extends ReactComponent>(
return createElement(component, props);
};

interface RootElement {
readonly classList: {
add(...tokens: string[]): void;
contains(token: string): boolean;
remove(...tokens: string[]): void;
};
readonly style: {
removeProperty(property: string): string;
setProperty(property: string, value: string): void;
};
}

// The package compiles without the DOM lib, and the document is absent while a route prerenders
declare const document: { readonly documentElement: RootElement } | undefined;

const EXPLICIT_SCHEMES = ["dark", "light"] as const;

const resolveRootElement = (): RootElement | undefined =>
typeof document === "undefined" ? undefined : document.documentElement;

// No page can override prefers-color-scheme, so an explicit scheme is a class on the root, which Tailwind's class strategy reads, and color-scheme, which light-dark() follows
export const colorScheme: ColorScheme = {
get() {
return Appearance.getColorScheme();
const root = resolveRootElement();
return (
EXPLICIT_SCHEMES.find((scheme) => root?.classList.contains(scheme)) ??
Appearance.getColorScheme() ??
"light"
);
},
set(name) {
Appearance.setColorScheme(name ?? "unspecified");
const root = resolveRootElement();
if (!root) {
return;
}

root.classList.remove(...EXPLICIT_SCHEMES);
if (name === "dark" || name === "light") {
root.classList.add(name);
root.style.setProperty("color-scheme", name);
} else {
root.style.removeProperty("color-scheme");
}
},
};

Expand Down