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
6 changes: 6 additions & 0 deletions .claude/skills/add-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ Verify CSS → JSON compilation output structure.

Test runtime style application on native platform.

> A `:root` custom property with exactly one declaration is inlined by the
> `inlineVariables` compiler pass and never reaches the runtime, so a test
> written that way passes with the runtime registry deleted. Declare it with
> `dynamicRootVariables` from `react-native-css/jest`. See the Testing section
> of `DEVELOPMENT.md`.

## Steps

1. **Identify the feature**: What needs testing? Use `$ARGUMENTS` as the starting point.
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ jobs:
- name: Run unit tests
run: yarn test --maxWorkers=2 --coverage

# The babel plugin, the metro resolver and the compiler all join and compare
# file paths. A separator or drive-letter bug in any of them is invisible to a
# POSIX runner and breaks every Windows contributor, so the unit suite runs
# here too. Coverage is left to the ubuntu job; this one only needs to fail.
test-windows:
runs-on: windows-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Setup
uses: ./.github/actions/setup

- name: Run unit tests
run: yarn test --maxWorkers=2

build-library:
runs-on: ubuntu-latest
steps:
Expand Down
44 changes: 44 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,49 @@ yarn example start:debug # Rebuild + start with debug logging
- **Run specific suites:** `yarn test babel`, `yarn test compiler`
- Ignore `ExperimentalWarning: VM Modules` warnings — expected with ESM support

### Custom properties in tests: use `dynamicRootVariables`

The `inlineVariables` pass (`src/compiler/inline-variables.ts`) inlines a custom
property that has exactly **one declaration**. This:

```css
:root { --my-var: #123456; }
.my-class { color: var(--my-var); }
```

compiles to `color: #123456` with **no root variable entry at all** — the
`var()` is gone before the runtime ever sees it. A test written that way
asserts the inliner, not the runtime, and it keeps passing with the runtime
variable registry deleted.

Reading the property from more rules does not help: the pass counts
declarations, not uses. A second **declaration** is what keeps it dynamic —
which is why real stylesheets rarely hit this (a `.dark` override or a themed
media query is a second declaration) and hand-written test CSS usually does.

When the subject of your test is the runtime, declare the property through the
helper, which emits a second guarded declaration to keep it dynamic:

```ts
import { dynamicRootVariables, registerCSS } from "react-native-css/jest";

registerCSS(`
${dynamicRootVariables({ "--my-var": "10px" })}
.my-class { width: var(--my-var); }
`);
```

Rendering the component is not enough to tell the two apart: the inlined literal
and the resolved variable produce the same style, so both forms render green.
Assert on the compiled stylesheet — a dynamic property has a `vr` entry and a
`var` descriptor in `d` — or verify by deleting the registry and watching the
test go red. `src/__tests__/native/dynamic-root-variables.test.tsx` pins both
the inliner and the helper.

Compiling with `{ inlineVariables: false }` also keeps the property dynamic, but
it turns the pass off for the whole stylesheet and tests a configuration users
do not run. Reach for it only when the inliner itself is the subject.

## Code Conventions

- TypeScript throughout
Expand All @@ -137,5 +180,6 @@ yarn example start:debug # Rebuild + start with debug logging
- **No npm** — this repo uses Yarn workspaces; `npm install` will not work
- **No rebuild watch** — use `yarn example start:build` to rebuild + start in one command
- **Metro transformer / Babel plugin changes require full rebuild** — no fast refresh for these
- **A single `:root` declaration never reaches the runtime** — `inlineVariables` inlines it at compile time; use `dynamicRootVariables` in tests (see [Testing](#custom-properties-in-tests-use-dynamicrootvariables))
- **native-internal exists to break circular deps** — don't import directly from `native/` in CSS file outputs; use `native-internal/`
- **Nested node_modules in example/** — can cause Metro issues; ensure dependency versions match root
71 changes: 63 additions & 8 deletions src/__tests__/compiler/media-query.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { compile } from "react-native-css/compiler";

describe.skip("platform media queries", () => {
describe("platform media queries", () => {
test("android", () => {
const compiled = compile(`
@media android and (min-width: 500px) {
Expand All @@ -14,8 +14,8 @@ describe.skip("platform media queries", () => {
"my-class",
[
{
s: [1, 1],
d: [{ color: "#ff0000" }],
s: [2, 1],
d: [{ color: "#f00" }],
m: [
[
"&",
Expand All @@ -25,6 +25,7 @@ describe.skip("platform media queries", () => {
],
],
],
v: [["__rn-css-color", "#f00"]],
},
],
],
Expand All @@ -45,15 +46,18 @@ describe.skip("platform media queries", () => {
"my-class",
[
{
s: [1, 1],
d: [{ color: "#ff0000" }],
s: [2, 1],
d: [{ color: "#f00" }],
m: [
"&",
[
["=", "platform", "ios"],
[">=", "width", 500],
"&",
[
["=", "platform", "ios"],
[">=", "width", 500],
],
],
],
v: [["__rn-css-color", "#f00"]],
},
],
],
Expand Down Expand Up @@ -85,3 +89,54 @@ test("@media (hover: hover)", () => {
],
});
});

// The runtime resolves this condition against the `colorScheme` observable
// (`src/native/conditions/media-query.ts`). `light-dark()` reaches the same
// condition, but the compiler synthesises it there — this covers the parse.
test("@media (prefers-color-scheme: dark)", () => {
const compiled = compile(`
@media (prefers-color-scheme: dark) {
.my-class { color: red; }
}
`);

expect(compiled.stylesheet()).toStrictEqual({
s: [
[
"my-class",
[
{
s: [2, 1],
d: [{ color: "#f00" }],
m: [["=", "prefers-color-scheme", "dark"]],
v: [["__rn-css-color", "#f00"]],
},
],
],
],
});
});

test("@media (prefers-color-scheme: light)", () => {
const compiled = compile(`
@media (prefers-color-scheme: light) {
.my-class { color: red; }
}
`);

expect(compiled.stylesheet()).toStrictEqual({
s: [
[
"my-class",
[
{
s: [2, 1],
d: [{ color: "#f00" }],
m: [["=", "prefers-color-scheme", "light"]],
v: [["__rn-css-color", "#f00"]],
},
],
],
],
});
});
196 changes: 196 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,196 @@
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` is the gate that decides whether the resolvers run
// at all (`src/metro/index.ts`). The resolvers themselves are covered
// separately; this covers the dispatch into them.

// The resolver reads exactly two fields off the context. Building the rest of
// `ResolutionContext` would be forty fields of metro internals that no code
// path under test touches, so the fixture is bridged once, here.
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,
);

// The parent resolver is asked once, for the module that was requested.
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,
);

// The native resolver keys on the module name and would not have rewritten
// this one; the web resolver keys on the resolved react-native-web path.
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"]);
});
});
Loading