From 2c3fd4bd00f617fd14b95f0bd2494e1d1bfd1999 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:08:46 +0300 Subject: [PATCH 1/6] fix(web): report that the color scheme cannot be set on web `colorScheme.set()` called `Appearance.setColorScheme`, which does not exist. The web plane imports `Appearance` from "react-native" and the bundler substitutes react-native-web, whose Appearance reads through to `matchMedia("(prefers-color-scheme: dark)")` and exposes only `getColorScheme` and `addChangeListener`. Every call was a TypeError. TypeScript cannot see this: it resolves react-native's `.d.ts` for that import either way, so the substitution is invisible to `yarn typecheck`. The browser owns the color scheme on web and evaluates `@media (prefers-color-scheme)` itself, so there is no observable for an override to drive the way there is on native. Throwing names that constraint; returning silently would leave an in-app theme toggle broken with nothing to find. The test runs the web module against react-native-web, which is the first coverage the web plane has had. --- src/__tests__/web/color-scheme.test.ts | 34 ++++++++++++++++++++++++++ src/web/api.tsx | 13 ++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/web/color-scheme.test.ts diff --git a/src/__tests__/web/color-scheme.test.ts b/src/__tests__/web/color-scheme.test.ts new file mode 100644 index 00000000..21428eed --- /dev/null +++ b/src/__tests__/web/color-scheme.test.ts @@ -0,0 +1,34 @@ +import { Appearance } from "react-native"; + +import { colorScheme } from "../../web/api"; + +// The web plane imports `Appearance` from "react-native", and the bundler +// substitutes react-native-web. TypeScript resolves react-native's `.d.ts` +// either way, so it sees a full `Appearance` and the substitution is invisible +// to `yarn typecheck` — only running the web module against react-native-web +// can observe what the web plane actually gets. Babel hoists this above the +// imports above. +jest.mock("react-native", () => + jest.requireActual>("react-native-web"), +); + +test("react-native-web's Appearance exposes no setColorScheme", () => { + // The upstream constraint the web plane is written against. If + // react-native-web grows a setter, this fails and `colorScheme.set` can + // forward to it. + expect(typeof Appearance.getColorScheme).toBe("function"); + expect(typeof Appearance.addChangeListener).toBe("function"); + expect(Appearance).not.toHaveProperty("setColorScheme"); +}); + +test("colorScheme.get reads the browser preference", () => { + expect(colorScheme.get()).toBe(Appearance.getColorScheme()); +}); + +test("colorScheme.set reports that the web plane cannot override the scheme", () => { + // Not a TypeError from calling through to a member that does not exist, and + // not a silent no-op either. + expect(() => { + colorScheme.set("dark"); + }).toThrow(/not supported on web.*browser owns the color scheme/i); +}); diff --git a/src/web/api.tsx b/src/web/api.tsx index e43f800a..40ea9369 100644 --- a/src/web/api.tsx +++ b/src/web/api.tsx @@ -72,8 +72,17 @@ export const colorScheme: ColorScheme = { get() { return Appearance.getColorScheme(); }, - set(name) { - Appearance.setColorScheme(name); + set() { + // `Appearance` here is react-native-web's, which reads through to + // `matchMedia("(prefers-color-scheme: dark)")` and exposes no setter. The + // browser owns the color scheme on web and evaluates + // `@media (prefers-color-scheme)` itself, so unlike the native runtime + // there is no observable for an override to drive. Reporting that is the + // only honest option: returning silently would leave an in-app theme + // toggle broken with nothing to find. + throw new Error( + "colorScheme.set() is not supported on web: the browser owns the color scheme. Style the two schemes with @media (prefers-color-scheme) and let the browser pick.", + ); }, }; From c5fd4d19516ecf606350152e55f702a4eadc5d9c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:09:11 +0300 Subject: [PATCH 2/6] test: cover prefers-color-scheme, and revive the platform media queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiler had no `@media (prefers-color-scheme: ...)` case. The one assertion of that condition reaches it through `light-dark()`, where the compiler synthesises the condition, so the parse path was unproven. The runtime half had no direct coverage either — `testMediaQuery` was only ever reached through a rendered component, which also exercises the collection and the resolver, so a condition evaluated wrongly could still produce the right style. The platform block was `describe.skip`. It is stale expectations, not a gap: the conditions it asserts are produced correctly today, and the block fails only on drift the compiler has since accumulated — `#ff0000` now shortens to `#f00`, specificity is `[2, 1]`, and a `v` entry carries the inherited color. The ios case also asserted one nesting level too few on `m`, which is an authoring slip rather than drift. Corrected and unskipped. --- src/__tests__/compiler/media-query.test.ts | 71 ++++++++++++++++--- .../native/media-query-condition.test.ts | 41 +++++++++++ 2 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/native/media-query-condition.test.ts diff --git a/src/__tests__/compiler/media-query.test.ts b/src/__tests__/compiler/media-query.test.ts index 760ede29..946cece0 100644 --- a/src/__tests__/compiler/media-query.test.ts +++ b/src/__tests__/compiler/media-query.test.ts @@ -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) { @@ -14,8 +14,8 @@ describe.skip("platform media queries", () => { "my-class", [ { - s: [1, 1], - d: [{ color: "#ff0000" }], + s: [2, 1], + d: [{ color: "#f00" }], m: [ [ "&", @@ -25,6 +25,7 @@ describe.skip("platform media queries", () => { ], ], ], + v: [["__rn-css-color", "#f00"]], }, ], ], @@ -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"]], }, ], ], @@ -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"]], + }, + ], + ], + ], + }); +}); diff --git a/src/__tests__/native/media-query-condition.test.ts b/src/__tests__/native/media-query-condition.test.ts new file mode 100644 index 00000000..f991935e --- /dev/null +++ b/src/__tests__/native/media-query-condition.test.ts @@ -0,0 +1,41 @@ +import type { MediaCondition } from "react-native-css/compiler"; + +import { testMediaQuery } from "../../native/conditions/media-query"; +import { colorScheme, type Getter } from "../../native/reactivity"; + +// `testMediaQuery` is the runtime half of a `@media` rule: the compiler emits +// the condition array, this evaluates it. Reaching it through a rendered +// component also exercises the collection and the style resolver, so a +// condition that is evaluated wrongly can still produce the right style. These +// feed the condition in directly. +const get: Getter = (observable) => observable.get(); + +const prefersDark: MediaCondition = ["=", "prefers-color-scheme", "dark"]; +const prefersLight: MediaCondition = ["=", "prefers-color-scheme", "light"]; + +const dark: MediaCondition[] = [prefersDark]; +const light: MediaCondition[] = [prefersLight]; + +test("prefers-color-scheme matches the current colorScheme", () => { + colorScheme.set("dark"); + expect(testMediaQuery(dark, get)).toBe(true); + expect(testMediaQuery(light, get)).toBe(false); + + colorScheme.set("light"); + expect(testMediaQuery(dark, get)).toBe(false); + expect(testMediaQuery(light, get)).toBe(true); +}); + +test("prefers-color-scheme matches neither value when the scheme is unset", () => { + colorScheme.set(null); + expect(testMediaQuery(dark, get)).toBe(false); + expect(testMediaQuery(light, get)).toBe(false); +}); + +test("prefers-color-scheme negates and combines like any other condition", () => { + colorScheme.set("dark"); + expect(testMediaQuery([["!", prefersDark]], get)).toBe(false); + expect(testMediaQuery([["!", prefersLight]], get)).toBe(true); + expect(testMediaQuery([["&", [prefersDark, prefersLight]]], get)).toBe(false); + expect(testMediaQuery([["|", [prefersDark, prefersLight]]], get)).toBe(true); +}); From 3ef761c9db71ce4fd7313977be4733bb891aa84a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:09:32 +0300 Subject: [PATCH 3/6] test(metro): cover the globalClassNamePolyfill gate `withReactNativeCSS` installs the `resolveRequest` that decides whether the native and web resolvers run at all, and nothing exercised it. The resolvers themselves are covered; the dispatch into them was not, so the gate could be inverted, defaulted the other way, or dropped without a test noticing. Covers both settings on both platforms, the metro-override short-circuit, and the preference for a config's existing `resolveRequest` over the context's. --- .../metro/global-class-name-polyfill.test.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 src/__tests__/metro/global-class-name-polyfill.test.ts diff --git a/src/__tests__/metro/global-class-name-polyfill.test.ts b/src/__tests__/metro/global-class-name-polyfill.test.ts new file mode 100644 index 00000000..7c471972 --- /dev/null +++ b/src/__tests__/metro/global-class-name-polyfill.test.ts @@ -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[1], +): MetroConfig { + return withReactNativeCSS( + {}, + { 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( + { 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"]); + }); +}); From c9bb8441deb3f2bc51ef02bd0c6c270d1a200c62 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:09:59 +0300 Subject: [PATCH 4/6] test: add dynamicRootVariables so a :root test reaches the runtime `inlineVariables` inlines a custom property that has exactly one declaration, so `:root { --my-var: red }` compiles to a literal with no root variable entry at all. A test written that way asserts the inliner and keeps passing with the runtime variable registry deleted, which makes it silently worthless. Use count does not save it: the pass counts declarations, so one declaration read from ten rules is still inlined. Rendering cannot tell the two apart either, because the inlined literal and the resolved variable produce the same style. Only the compiled stylesheet shows the difference. `dynamicRootVariables` emits a second declaration behind a guard that never matches, so the property stays dynamic. Both declarations carry the same value, so the resolved value does not depend on the guard staying unmatched. The tests pin the inliner's behaviour as well as the helper, so if it stops inlining or starts keying on use count the helper can be retired. --- .claude/skills/add-test/SKILL.md | 6 + DEVELOPMENT.md | 44 +++++++ .../native/dynamic-root-variables.test.tsx | 123 ++++++++++++++++++ src/jest/index.ts | 42 ++++++ 4 files changed, 215 insertions(+) create mode 100644 src/__tests__/native/dynamic-root-variables.test.tsx diff --git a/.claude/skills/add-test/SKILL.md b/.claude/skills/add-test/SKILL.md index f8c65e40..64c05607 100644 --- a/.claude/skills/add-test/SKILL.md +++ b/.claude/skills/add-test/SKILL.md @@ -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. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4ce58467..aada4902 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 @@ -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 diff --git a/src/__tests__/native/dynamic-root-variables.test.tsx b/src/__tests__/native/dynamic-root-variables.test.tsx new file mode 100644 index 00000000..25168e71 --- /dev/null +++ b/src/__tests__/native/dynamic-root-variables.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from "@testing-library/react-native"; +import { compile } from "react-native-css/compiler"; +import { View } from "react-native-css/components/View"; +import { + dynamicRootVariables, + registerCSS, + testID, +} from "react-native-css/jest"; + +// `inlineVariables` (`src/compiler/inline-variables.ts`) inlines a custom +// property that has exactly one declaration. A `:root` test written that way +// asserts the inliner and passes with the runtime variable registry deleted, so +// it proves nothing about the runtime. `dynamicRootVariables` emits a second +// declaration to keep the property dynamic. +// +// The first tests pin the inliner's behaviour itself: if it stops inlining, or +// starts keying on use count instead of declaration count, they fail and the +// helper can be retired. + +test("a single :root declaration is inlined before the runtime sees it", () => { + const stylesheet = compile(` + :root { --my-var: #123456; } + .my-class { color: var(--my-var); } + `).stylesheet(); + + // No root-variable entry at all: the runtime registry is never reached. + expect(stylesheet.vr).toBeUndefined(); + expect(stylesheet.s).toStrictEqual([ + [ + "my-class", + [ + { + s: [1, 1], + d: [{ color: "#123456" }], + v: [["__rn-css-color", "#123456"]], + }, + ], + ], + ]); +}); + +test("reading a single declaration many times does not save it", () => { + // The inliner keys on declaration count, not use count, so spreading the + // `var()` across rules is not a way out of the trap. + const stylesheet = compile(` + :root { --my-var: #123456; } + .a { color: var(--my-var); } + .b { color: var(--my-var); } + `).stylesheet(); + + expect(stylesheet.vr).toBeUndefined(); +}); + +test("turning the inliner off is the other way to keep it dynamic", () => { + // Supported, but it applies to the whole stylesheet and is not the + // configuration users compile under, so tests prefer a second declaration. + const stylesheet = compile( + ` + :root { --my-var: #123456; } + .my-class { color: var(--my-var); } + `, + { inlineVariables: false }, + ).stylesheet(); + + expect(stylesheet.vr).toStrictEqual([["my-var", [["#123456"]]]]); +}); + +test("dynamicRootVariables keeps the property dynamic", () => { + const stylesheet = compile(` + ${dynamicRootVariables({ "--my-var": "#123456" })} + .my-class { color: var(--my-var); } + `).stylesheet(); + + // The declaration reaches the runtime registry, and the style is a `var` + // descriptor the runtime has to resolve rather than a folded literal. + expect(stylesheet.vr).toStrictEqual([ + ["my-var", [["#123456", [[">=", "width", 999999]]], ["#123456"]]], + ]); + expect(stylesheet.s?.[0]?.[1]).toStrictEqual([ + { + s: [4, 1], + d: [[[{}, "var", "my-var", 1], "color", 1]], + dv: 1, + v: [["__rn-css-color", [{}, "var", "my-var", 1]]], + }, + ]); +}); + +test("dynamicRootVariables resolves to the declared value at runtime", () => { + registerCSS(` + ${dynamicRootVariables({ "--my-var": "10px" })} + .my-class { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("dynamicRootVariables accepts a name written without the -- prefix", () => { + registerCSS(` + ${dynamicRootVariables({ "my-var": "10px" })} + .my-class { width: var(--my-var); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ width: 10 }); +}); + +test("dynamicRootVariables declares every property it is given", () => { + registerCSS(` + ${dynamicRootVariables({ "--width": "10px", "--height": "20px" })} + .my-class { width: var(--width); height: var(--height); } + `); + + render(); + + expect(screen.getByTestId(testID).props.style).toStrictEqual({ + width: 10, + height: 20, + }); +}); diff --git a/src/jest/index.ts b/src/jest/index.ts index cc125390..ce000130 100644 --- a/src/jest/index.ts +++ b/src/jest/index.ts @@ -55,6 +55,48 @@ export function registerCSS( return compiled; } +// Wide enough that no test viewport matches it. Both declarations carry the +// same value, so the resolved value does not depend on that staying true. +const NEVER_MATCHES = "(min-width: 999999px)"; + +/** + * Declares `:root` custom properties in a form that reaches the runtime + * variable registry. + * + * `inlineVariables` (`src/compiler/inline-variables.ts`) inlines a custom + * property that has exactly one **declaration**, so `:root { --my-var: red }` + * compiles to a literal with no root variable entry at all. Use count does not + * save it — one declaration read from ten rules is still inlined. A test + * written that way asserts the inliner and passes with the runtime registry + * deleted. A second declaration keeps the property dynamic, so `var()` stays a + * descriptor the runtime has to resolve. + * + * Use this whenever a test's subject is the runtime, not the inliner: + * + * ```ts + * registerCSS(` + * ${dynamicRootVariables({ "--my-var": "10px" })} + * .my-class { width: var(--my-var); } + * `); + * ``` + * + * Real stylesheets usually reach this shape on their own — a `.dark` override + * or a themed media query is a second declaration. Compiling with + * `{ inlineVariables: false }` also works, but it turns the pass off for the + * whole stylesheet and tests a configuration users do not run; prefer this. + */ +export function dynamicRootVariables( + variables: Record, +): string { + const declarations = Object.entries(variables) + .map(([name, value]) => { + return `${name.startsWith("--") ? name : `--${name}`}: ${value};`; + }) + .join(" "); + + return `:root { ${declarations} } @media ${NEVER_MATCHES} { :root { ${declarations} } }`; +} + export function compileWithAutoDebug( css: string, { From 4296bdccde97b00540e6d2c5740105b351706a93 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:10:21 +0300 Subject: [PATCH 5/6] ci: run the unit suite on windows Every job runs on ubuntu except one macos builder, so nothing in CI executes this codebase on Windows. The babel plugin, the metro resolver and the compiler all join and compare file paths, which is exactly the class of code a POSIX-only matrix cannot vet. That gap is not theoretical. At this commit, on a Windows host, three of the repo's own tests fail: "7. import View from '../View/View'" in src/__tests__/babel/react-native.test.ts, and "6. import View from '../View'" and "17. const View = _interopRequireDefault(require('../View'))" in src/__tests__/babel/react-native-web.test.ts. Relative imports are not rewritten because the separator comparison assumes forward slashes. Coverage stays on the ubuntu job; this one only needs to be able to fail. Ordering: the fix for those three lives on fix/babel-windows-posix-paths. Merge that branch first, or this job lands red. --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fdb0f9c..6fddac9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: From 7c647649c0915b26fdd6f73e849fc5c0c4d5dc08 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 02:47:30 +0300 Subject: [PATCH 6/6] fix(native): resolve prefers-color-scheme to light when no preference is set `Appearance.getColorScheme()` answers null whenever the OS reports `unspecified` or the native module is absent, so a null scheme is a reachable production state rather than a test-harness artifact. Comparing the queried value straight against it made `@media (prefers-color-scheme: light)` match nothing in that state, so an explicit light rule silently never applied. MQ5 resolves the absence of a preference to `light`, and the rest of the library already assumes that. `light-dark(red, blue)` compiles to a light base rule plus the dark value behind `["=", "prefers-color-scheme", "dark"]`; `colorScheme.get()` on native ends in `?? "light"`; and react-native-web's `getColorScheme()` reads the dark media query and answers "light" when it does not match. Only this comparison disagreed, so a light rule behaved differently on the two platforms. Resolving the scheme before the comparison rather than branching on it keeps an unrecognised value false, which MQ5 also requires. --- .../native/media-query-condition.test.ts | 28 +++++++++++++++++-- src/native/conditions/media-query.ts | 10 ++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/__tests__/native/media-query-condition.test.ts b/src/__tests__/native/media-query-condition.test.ts index f991935e..2ef1f1b5 100644 --- a/src/__tests__/native/media-query-condition.test.ts +++ b/src/__tests__/native/media-query-condition.test.ts @@ -26,10 +26,34 @@ test("prefers-color-scheme matches the current colorScheme", () => { expect(testMediaQuery(light, get)).toBe(true); }); -test("prefers-color-scheme matches neither value when the scheme is unset", () => { +// `Appearance.getColorScheme()` returns null whenever the OS reports +// `unspecified` or the native module is absent, so this is a reachable +// production state and not a test-harness artifact. MQ5 resolves it to `light`: +// "light indicates that the user has expressed the preference for a light +// theme, or has not expressed an active preference". The rest of the library +// already assumes that — `light-dark()` compiles to a light base rule with the +// dark value behind `prefers-color-scheme: dark`, `colorScheme.get()` on native +// ends in `?? "light"`, and react-native-web's `getColorScheme()` reads the +// dark media query and answers "light" when it does not match. +test("prefers-color-scheme is light when no preference is set", () => { colorScheme.set(null); expect(testMediaQuery(dark, get)).toBe(false); - expect(testMediaQuery(light, get)).toBe(false); + expect(testMediaQuery(light, get)).toBe(true); +}); + +// The fallback picks a value for the comparison; it does not turn the condition +// into a two-way branch. MQ5 requires an unrecognised value to be false rather +// than aliasing to the other one. +test("prefers-color-scheme does not match a value outside the two it defines", () => { + const nonsense: MediaCondition[] = [ + ["=", "prefers-color-scheme", "no-preference"], + ]; + + colorScheme.set(null); + expect(testMediaQuery(nonsense, get)).toBe(false); + + colorScheme.set("dark"); + expect(testMediaQuery(nonsense, get)).toBe(false); }); test("prefers-color-scheme negates and combines like any other condition", () => { diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 75cd9006..111bcf81 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -45,7 +45,15 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "platform": return value === "native" || value === Platform.OS; case "prefers-color-scheme": { - return value === get(colorScheme); + // `Appearance.getColorScheme()` answers null when the OS reports + // `unspecified` or the native module is absent. MQ5 resolves the absence + // of a preference to `light`, and the rest of the library already assumes + // that: `light-dark()` compiles to a light base rule with the dark value + // behind `prefers-color-scheme: dark`, and react-native-web reads the + // dark media query and answers "light" when it does not match. Comparing + // against the resolved scheme rather than branching keeps an unrecognised + // value false. + return value === (get(colorScheme) ?? "light"); } case "display-mode": return value === "native" || Platform.OS === value;