From 0f723921229eedf20a6767c65c6bec77d705ddec Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 19:09:06 +0300 Subject: [PATCH 1/8] fix(babel): normalize resolved paths to POSIX so Windows rewrites work The import plugin's relative-import handlers resolve a source against the file being transformed and then match the result against forward-slash literals (`react-native/Libraries/Components/`, `react-native-web/dist`). path.resolve returns backslash-separated paths on Windows, so those splits never match and the import is left un-rewritten. Only relative imports break, and only on Windows -- bare specifiers are plain string matches; the failing cases were green on the Linux/macOS CI. Add a `resolvePosix` helper (resolve + normalize \ -> /) and use it in parseReactNativeSource / parseReactNativeWebSource. Resolve semantics are unchanged; only the separator is normalized. import-plugin's isFromThisModule stays on path.resolve -- it compares two OS-native paths, so it already works. Adds a helpers unit test asserting the POSIX invariant. --- src/__tests__/babel/helpers.test.ts | 20 ++++++++++++++++++++ src/babel/helpers.ts | 16 ++++++++++++++++ src/babel/react-native-web.ts | 5 ++--- src/babel/react-native.ts | 5 +++-- 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/babel/helpers.test.ts diff --git a/src/__tests__/babel/helpers.test.ts b/src/__tests__/babel/helpers.test.ts new file mode 100644 index 00000000..d0520a14 --- /dev/null +++ b/src/__tests__/babel/helpers.test.ts @@ -0,0 +1,20 @@ +import { resolvePosix } from "../../babel/helpers"; + +describe("resolvePosix", () => { + test("resolves to an absolute path with POSIX separators on every platform", () => { + // The invariant the import handlers depend on: no backslashes leak through, + // so their forward-slash `split` / `startsWith` matching works on Windows + // (where path.resolve otherwise yields "\"-separated paths). + const result = resolvePosix(process.cwd(), "a", "b", "c"); + + expect(result).not.toContain("\\"); + expect(result.split("/").slice(-3)).toEqual(["a", "b", "c"]); + }); + + test("collapses '..' segments like path.resolve, keeping POSIX separators", () => { + const result = resolvePosix(process.cwd(), "a", "b", "..", "c"); + + expect(result).not.toContain("\\"); + expect(result.split("/").slice(-2)).toEqual(["a", "c"]); + }); +}); diff --git a/src/babel/helpers.ts b/src/babel/helpers.ts index c66f630c..9a08e5d0 100644 --- a/src/babel/helpers.ts +++ b/src/babel/helpers.ts @@ -1,3 +1,5 @@ +import { resolve } from "path"; + import tBabelTypes, { type CallExpression } from "@babel/types"; export type BabelTypes = typeof tBabelTypes; @@ -38,3 +40,17 @@ export function getInteropRequireDefaultSource( return requireArg.value; } + +/** + * `path.resolve`, normalized to POSIX separators. + * + * The relative-import handlers resolve a source against the file being + * transformed and then match the result against forward-slash literals + * (`react-native/Libraries/Components/`, `react-native-web/dist`, …). On Windows + * `path.resolve` yields backslash separators, so those `split` / `startsWith` + * matches silently miss and the import is left un-rewritten. Normalizing to `/` + * makes the matching platform-independent. + */ +export function resolvePosix(...segments: string[]): string { + return resolve(...segments).replace(/\\/g, "/"); +} diff --git a/src/babel/react-native-web.ts b/src/babel/react-native-web.ts index 1bacf5a0..db860a49 100644 --- a/src/babel/react-native-web.ts +++ b/src/babel/react-native-web.ts @@ -1,5 +1,3 @@ -import { resolve } from "path"; - import { type NodePath } from "@babel/traverse"; import tBabelTypes, { type ImportDeclaration, @@ -9,12 +7,13 @@ import tBabelTypes, { } from "@babel/types"; import { allowedModules } from "./allowedModules"; +import { resolvePosix } from "./helpers"; type BabelTypes = typeof tBabelTypes; function parseReactNativeWebSource(source: string, filename: string) { if (source.startsWith(".")) { - source = resolve(filename, source); + source = resolvePosix(filename, source); const internalPath = source.split("react-native-web/dist")[1]; if (!internalPath) { diff --git a/src/babel/react-native.ts b/src/babel/react-native.ts index 2522a848..ad65c941 100644 --- a/src/babel/react-native.ts +++ b/src/babel/react-native.ts @@ -1,4 +1,4 @@ -import { dirname, resolve } from "path"; +import { dirname } from "path"; import { type NodePath } from "@babel/traverse"; import tBabelTypes, { @@ -9,12 +9,13 @@ import tBabelTypes, { } from "@babel/types"; import { allowedModules } from "./allowedModules"; +import { resolvePosix } from "./helpers"; type BabelTypes = typeof tBabelTypes; function parseReactNativeSource(source: string, filename: string) { if (source.startsWith(".")) { - source = resolve(dirname(filename), source); + source = resolvePosix(dirname(filename), source); const internalPath = source.split("react-native/Libraries/Components/")[1]; if (!internalPath) { From 20a4be1cb26d2d20c886549d46a1a40e4238f096 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 21:56:58 +0300 Subject: [PATCH 2/8] test(babel): make the separator normalization observable on Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolvePosix` fused `path.resolve` into the helper, so every test of it had to go through `resolve` — and on Linux `resolve` never emits a backslash. The normalization had nothing to act on there, so the tests passed with it deleted. CI runs ubuntu-latest and nothing else, which is how the three failing cases sat in main unnoticed. `toPosixPath` is the primitive now and `resolvePosix` composes with it, so a test can feed it a Windows-shaped literal and fail on any host. Deleting the normalization turns 8 tests red across 3 suites, three of them host-independent. Left unconditional rather than gated on `sep`. Gating is tempting — a POSIX filename may legally contain a backslash — but it makes the function an identity on Linux and puts the guard back out of CI's reach. The hazard it would close needs a directory literally named `react-native\Libraries\Components\` on a POSIX host, and the failure mode is a missed rewrite rather than wrong output. --- src/__tests__/babel/helpers.test.ts | 45 +++++++++++++++++++++++++---- src/babel/helpers.ts | 16 +++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/__tests__/babel/helpers.test.ts b/src/__tests__/babel/helpers.test.ts index d0520a14..6c66b35b 100644 --- a/src/__tests__/babel/helpers.test.ts +++ b/src/__tests__/babel/helpers.test.ts @@ -1,10 +1,45 @@ -import { resolvePosix } from "../../babel/helpers"; +import { resolvePosix, toPosixPath } from "../../babel/helpers"; + +// These assert against Windows-shaped literals rather than through path.resolve, +// so they fail on every host when the normalization is removed. Driving them +// through resolve() instead would make them inert on Linux — where resolve() +// never emits a backslash — which is the only platform CI runs. +describe("toPosixPath", () => { + test("converts a Windows path to POSIX separators", () => { + expect( + toPosixPath( + "C:\\project\\node_modules\\react-native\\Libraries\\Components\\View\\View", + ), + ).toBe( + "C:/project/node_modules/react-native/Libraries/Components/View/View", + ); + }); + + test("makes the marker the import handlers split on findable", () => { + // The defect itself: the resolved path plainly contains those directories, + // and the forward-slash marker is absent from it until this runs + const resolved = + "C:\\project\\node_modules\\react-native\\Libraries\\Components\\View\\View"; + const marker = "react-native/Libraries/Components/"; + + expect(resolved).not.toContain(marker); + expect(toPosixPath(resolved)).toContain(marker); + expect(toPosixPath(resolved).split(marker)[1]).toBe("View/View"); + }); + + test("leaves an already-POSIX path untouched", () => { + const posix = "/project/node_modules/react-native-web/dist/exports/View"; + + expect(toPosixPath(posix)).toBe(posix); + }); + + test("converts every separator, not just the first", () => { + expect(toPosixPath("a\\b\\c\\d")).toBe("a/b/c/d"); + }); +}); describe("resolvePosix", () => { - test("resolves to an absolute path with POSIX separators on every platform", () => { - // The invariant the import handlers depend on: no backslashes leak through, - // so their forward-slash `split` / `startsWith` matching works on Windows - // (where path.resolve otherwise yields "\"-separated paths). + test("resolves to an absolute path carrying no backslash", () => { const result = resolvePosix(process.cwd(), "a", "b", "c"); expect(result).not.toContain("\\"); diff --git a/src/babel/helpers.ts b/src/babel/helpers.ts index 9a08e5d0..ed850983 100644 --- a/src/babel/helpers.ts +++ b/src/babel/helpers.ts @@ -42,15 +42,23 @@ export function getInteropRequireDefaultSource( } /** - * `path.resolve`, normalized to POSIX separators. + * A path in POSIX separators, whatever the host uses. * * The relative-import handlers resolve a source against the file being * transformed and then match the result against forward-slash literals * (`react-native/Libraries/Components/`, `react-native-web/dist`, …). On Windows * `path.resolve` yields backslash separators, so those `split` / `startsWith` - * matches silently miss and the import is left un-rewritten. Normalizing to `/` - * makes the matching platform-independent. + * matches silently miss and the import is left un-rewritten. + * + * Unconditional rather than gated on `sep`, so it is the same function on every + * host and a test can feed it a Windows-shaped literal. Gating it would make the + * normalization unobservable on Linux, which is the only platform CI runs. */ +export function toPosixPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +/** `path.resolve`, normalized to POSIX separators. */ export function resolvePosix(...segments: string[]): string { - return resolve(...segments).replace(/\\/g, "/"); + return toPosixPath(resolve(...segments)); } From 8c49e824df3a10506c861cf4ae3674e037c26d81 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 14 Aug 2026 22:08:46 +0300 Subject: [PATCH 3/8] fix(babel): keep the separator rewrite off POSIX hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backslash is a legal filename character on POSIX, so rewriting one there corrupts a path that was already correct. The gate belongs at the boundary where a host path enters — resolvePosix — rather than inside toPosixPath, which stays a pure transform so a test can feed it a Windows-shaped literal and observe the result on any host. --- src/__tests__/babel/helpers.test.ts | 13 +++++++++---- src/babel/helpers.ts | 30 ++++++++++++++++++----------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/__tests__/babel/helpers.test.ts b/src/__tests__/babel/helpers.test.ts index 6c66b35b..53fd8b76 100644 --- a/src/__tests__/babel/helpers.test.ts +++ b/src/__tests__/babel/helpers.test.ts @@ -1,9 +1,14 @@ import { resolvePosix, toPosixPath } from "../../babel/helpers"; -// These assert against Windows-shaped literals rather than through path.resolve, -// so they fail on every host when the normalization is removed. Driving them -// through resolve() instead would make them inert on Linux — where resolve() -// never emits a backslash — which is the only platform CI runs. +// toPosixPath is asserted against Windows-shaped literals rather than through +// path.resolve, so it fails on every host when the normalization is removed. +// Driving it through resolve() instead would make it inert on Linux — where +// resolve() never emits a backslash — which is the only platform CI runs. +// +// resolvePosix's platform gate is not directly observable: on POSIX its effect is +// to leave the path alone, which is also what would happen without it. What is +// pinned below is the contract either way — the result carries no separator the +// import handlers cannot match, and the segments survive. describe("toPosixPath", () => { test("converts a Windows path to POSIX separators", () => { expect( diff --git a/src/babel/helpers.ts b/src/babel/helpers.ts index ed850983..d4d5236c 100644 --- a/src/babel/helpers.ts +++ b/src/babel/helpers.ts @@ -1,4 +1,4 @@ -import { resolve } from "path"; +import { resolve, sep } from "path"; import tBabelTypes, { type CallExpression } from "@babel/types"; @@ -42,7 +42,18 @@ export function getInteropRequireDefaultSource( } /** - * A path in POSIX separators, whatever the host uses. + * Rewrite Windows separators as POSIX ones. + * + * A pure string transform with no platform check of its own, so a test can feed + * it a Windows-shaped literal and observe the result on any host. Only call it + * on a path known to use Windows separators — `resolvePosix` is that caller. + */ +export function toPosixPath(path: string): string { + return path.replaceAll("\\", "/"); +} + +/** + * `path.resolve`, in POSIX separators. * * The relative-import handlers resolve a source against the file being * transformed and then match the result against forward-slash literals @@ -50,15 +61,12 @@ export function getInteropRequireDefaultSource( * `path.resolve` yields backslash separators, so those `split` / `startsWith` * matches silently miss and the import is left un-rewritten. * - * Unconditional rather than gated on `sep`, so it is the same function on every - * host and a test can feed it a Windows-shaped literal. Gating it would make the - * normalization unobservable on Linux, which is the only platform CI runs. + * The platform check lives here rather than in `toPosixPath` because this is + * where a host path enters. On POSIX a backslash is a legal filename character, + * so rewriting one there would corrupt a path that was already correct. */ -export function toPosixPath(path: string): string { - return path.replaceAll("\\", "/"); -} - -/** `path.resolve`, normalized to POSIX separators. */ export function resolvePosix(...segments: string[]): string { - return toPosixPath(resolve(...segments)); + const resolved = resolve(...segments); + + return sep === "/" ? resolved : toPosixPath(resolved); } From 7f16078342c1e81389af4d4d5f098d20524dcd74 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sat, 15 Aug 2026 00:25:37 +0300 Subject: [PATCH 4/8] fix(babel): resolve relative imports correctly and revive two dead guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows separator fix stands: `resolveImportSource` normalizes `path.resolve`'s output to POSIX before the handlers match it against forward-slash literals. The host separator is now an argument to `toPosixPath` rather than a module-level `sep` read, so both branches are reachable from any host — CI runs only ubuntu-latest and one macos-15, and an assertion driven through `path.resolve` is vacuous there. Auditing that path turned up three more defects in the same few lines. `react-native-web.ts` resolved a relative source against the filename where it meant the filename's directory, consuming one `..` too few and moving the package boundary by a directory; `react-native.ts` already used `dirname`. Both now call one helper whose signature gives the caller no base to get wrong. `processed.has(path)` could never be true: only `Statement` nodes are added to that set and a `NodePath` is not one. Throw-injecting it leaves the whole suite green, while the same probe on `path.node` trips on nearly every rewrite — that sibling is the live re-entry guard. The dead disjunct is gone and the set is typed `WeakSet`, so re-adding it is a compile error. `isFromThisModule` derived the package root as `../../../` from `__dirname`, which names it in the built layout and points outside the package when the plugin runs from `src/`. It also read `.startsWith` off `state.filename`, which babel types `string | undefined` and leaves undefined when a caller passes none — that threw before any rewrite was considered. The root now comes from the nearest `package.json` declaring a `name` (builder-bob writes a bare `{ "type": ... }` manifest into each output directory), each shipped directory is compared with a trailing separator, and the filename is narrowed once per visitor. That guard being live is why three existing suites change: babel-plugin-tester infers `filepath` from the test file's own path, and a file under `src/__tests__/` genuinely is one of this package's sources. Their `babelOptions.filename` never reached babel at all. Each now sets `filepath` to an application path, which is what those cases always meant. Tests: the plugin end-to-end through `transformSync`, this package's own sources in both layouts, the package-boundary cases, and the first coverage of the metro resolver plane — pinned against the babel plane over the census they share, since the two are alternatives selected by `globalClassNamePolyfill` and must agree. `plugin.test.mts` is deleted. Jest 29 collects neither the `.mts` extension nor that testMatch shape, its first case carries `only: true`, and that case expects output the plugin does not emit. The two shapes no collected suite covered are ported over with the expectations the plugin actually produces. --- src/__tests__/_transform.ts | 32 ++ src/__tests__/babel/helpers.test.ts | 232 +++++++++++-- src/__tests__/babel/import-plugin.test.ts | 148 ++++++++ src/__tests__/babel/own-sources.test.ts | 131 ++++++++ src/__tests__/babel/plugin.test.mts | 45 --- src/__tests__/babel/react-native-web.test.ts | 6 + src/__tests__/babel/react-native.test.ts | 7 +- src/__tests__/babel/smoke.test.ts | 7 +- src/__tests__/metro/resolver.test.ts | 336 +++++++++++++++++++ src/babel/helpers.ts | 93 +++-- src/babel/import-plugin.ts | 68 ++-- src/babel/react-native-web.ts | 4 +- src/babel/react-native.ts | 6 +- 13 files changed, 986 insertions(+), 129 deletions(-) create mode 100644 src/__tests__/_transform.ts create mode 100644 src/__tests__/babel/import-plugin.test.ts create mode 100644 src/__tests__/babel/own-sources.test.ts delete mode 100644 src/__tests__/babel/plugin.test.mts create mode 100644 src/__tests__/metro/resolver.test.ts diff --git a/src/__tests__/_transform.ts b/src/__tests__/_transform.ts new file mode 100644 index 00000000..add8e492 --- /dev/null +++ b/src/__tests__/_transform.ts @@ -0,0 +1,32 @@ +import { transformSync } from "@babel/core"; + +import plugin from "../babel/import-plugin"; + +/** + * Drives the real babel plugin through `@babel/core`, so `state.filename` is + * populated by babel itself rather than by a test double. `configFile` / + * `babelrc` are off so the result is this plugin's output and nothing else. + * + * Underscore-prefixed, so jest's `testPathIgnorePatterns` treats it as a fixture + * rather than a suite. + */ +export function transformWithBabelPlugin( + code: string, + filename?: string, + options: { cwd?: string } = {}, +): string { + const result = transformSync(code, { + filename, + cwd: options.cwd, + configFile: false, + babelrc: false, + plugins: [plugin], + }); + + const output = result?.code; + if (typeof output !== "string") { + throw new Error(`babel produced no output for ${filename ?? ""}`); + } + + return output; +} diff --git a/src/__tests__/babel/helpers.test.ts b/src/__tests__/babel/helpers.test.ts index 53fd8b76..2b2e47d4 100644 --- a/src/__tests__/babel/helpers.test.ts +++ b/src/__tests__/babel/helpers.test.ts @@ -1,60 +1,220 @@ -import { resolvePosix, toPosixPath } from "../../babel/helpers"; - -// toPosixPath is asserted against Windows-shaped literals rather than through -// path.resolve, so it fails on every host when the normalization is removed. -// Driving it through resolve() instead would make it inert on Linux — where -// resolve() never emits a backslash — which is the only platform CI runs. -// -// resolvePosix's platform gate is not directly observable: on POSIX its effect is -// to leave the path alone, which is also what would happen without it. What is -// pinned below is the contract either way — the result carries no separator the -// import handlers cannot match, and the segments survive. +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join, resolve, sep } from "path"; + +import { + findPackageRoot, + resolveImportSource, + toPosixPath, +} from "../../babel/helpers"; + +/** + * `path.resolve` prepends the cwd's drive on Windows and nothing on POSIX. The + * drive is not part of what any case here pins, so it is dropped before + * comparing and one literal expectation serves both hosts. + */ +function withoutDrive(path: string): string { + return path.replace(/^[A-Za-z]:/, ""); +} + +const WINDOWS_SEPARATOR = "\\"; +const POSIX_SEPARATOR = "/"; + describe("toPosixPath", () => { - test("converts a Windows path to POSIX separators", () => { - expect( - toPosixPath( - "C:\\project\\node_modules\\react-native\\Libraries\\Components\\View\\View", - ), - ).toBe( - "C:/project/node_modules/react-native/Libraries/Components/View/View", - ); + // Every case supplies the host separator, so both branches are exercised on + // any host — including ubuntu-latest, the only platform CI runs. Inputs are + // Windows-shaped literals rather than `path.resolve` output for the same + // reason: `resolve()` on Linux never emits a backslash, so a table driven + // through it would assert nothing there. + const cases: { + name: string; + input: string; + hostSeparator: string; + expected: string; + }[] = [ + { + name: "an absolute Windows path", + input: "C:\\project\\node_modules\\react-native-web\\dist\\exports\\View", + hostSeparator: WINDOWS_SEPARATOR, + expected: "C:/project/node_modules/react-native-web/dist/exports/View", + }, + { + name: "an already-POSIX path, unchanged", + input: "/project/node_modules/react-native-web/dist/exports/View", + hostSeparator: POSIX_SEPARATOR, + expected: "/project/node_modules/react-native-web/dist/exports/View", + }, + { + name: "mixed separators, every one of them", + input: "C:/project\\node_modules/react-native\\Libraries", + hostSeparator: WINDOWS_SEPARATOR, + expected: "C:/project/node_modules/react-native/Libraries", + }, + { + name: "a UNC-style prefix, both leading separators", + input: "\\\\build-server\\share\\project\\index.js", + hostSeparator: WINDOWS_SEPARATOR, + expected: "//build-server/share/project/index.js", + }, + { + name: "a POSIX filename whose own name contains a backslash, left intact", + // The gate's reason to exist: on POSIX this is one file called + // `weird\name.js`, and splitting it would name a path that does not exist. + input: "/project/weird\\name.js", + hostSeparator: POSIX_SEPARATOR, + expected: "/project/weird\\name.js", + }, + { + name: "the same characters on a Windows host, split into segments", + // Same input, opposite verdict — so what decides is the host separator, + // not anything about the string. + input: "/project/weird\\name.js", + hostSeparator: WINDOWS_SEPARATOR, + expected: "/project/weird/name.js", + }, + { + name: "a relative path", + input: "..\\View\\View.js", + hostSeparator: WINDOWS_SEPARATOR, + expected: "../View/View.js", + }, + { + name: "a trailing separator, preserved as a POSIX one", + input: "C:\\project\\dist\\", + hostSeparator: WINDOWS_SEPARATOR, + expected: "C:/project/dist/", + }, + { + name: "the empty string on a Windows host", + input: "", + hostSeparator: WINDOWS_SEPARATOR, + expected: "", + }, + { + name: "the empty string on a POSIX host", + input: "", + hostSeparator: POSIX_SEPARATOR, + expected: "", + }, + ]; + + test.each(cases)("$name", ({ input, hostSeparator, expected }) => { + expect(toPosixPath(input, hostSeparator)).toBe(expected); }); test("makes the marker the import handlers split on findable", () => { // The defect itself: the resolved path plainly contains those directories, - // and the forward-slash marker is absent from it until this runs + // and the forward-slash marker is absent from it until this runs. const resolved = "C:\\project\\node_modules\\react-native\\Libraries\\Components\\View\\View"; const marker = "react-native/Libraries/Components/"; + const posix = toPosixPath(resolved, WINDOWS_SEPARATOR); expect(resolved).not.toContain(marker); - expect(toPosixPath(resolved)).toContain(marker); - expect(toPosixPath(resolved).split(marker)[1]).toBe("View/View"); + expect(posix).toContain(marker); + expect(posix.split(marker)[1]).toBe("View/View"); }); +}); + +describe("resolveImportSource", () => { + // The base is `dirname(filename)`, and every case below observes that by + // counting `..` segments — falsifiable on any host. + const filename = + "/project/node_modules/react-native-web/dist/exports/View/index.js"; - test("leaves an already-POSIX path untouched", () => { - const posix = "/project/node_modules/react-native-web/dist/exports/View"; + const cases: { name: string; source: string; expected: string }[] = [ + { + name: "a sibling of the file", + source: "./types", + expected: + "/project/node_modules/react-native-web/dist/exports/View/types", + }, + { + name: "a sibling of the file's directory", + source: "../Text", + expected: "/project/node_modules/react-native-web/dist/exports/Text", + }, + { + name: "the file's own directory", + source: ".", + expected: "/project/node_modules/react-native-web/dist/exports/View", + }, + { + name: "the parent of the file's directory", + source: "..", + expected: "/project/node_modules/react-native-web/dist/exports", + }, + { + name: "a climb that leaves the package's dist directory", + source: "../../../View", + expected: "/project/node_modules/react-native-web/View", + }, + ]; - expect(toPosixPath(posix)).toBe(posix); + test.each(cases)("$name", ({ source, expected }) => { + expect(withoutDrive(resolveImportSource(filename, source))).toBe(expected); }); - test("converts every separator, not just the first", () => { - expect(toPosixPath("a\\b\\c\\d")).toBe("a/b/c/d"); + test("hands path.resolve's output to the host's normalization", () => { + // Pins the composition: `resolve` over the file's directory, then + // `toPosixPath` with the real `path.sep`. On POSIX the normalization is the + // identity and this reduces to `resolve` — which is the one thing here that + // cannot fail on ubuntu-latest, the only platform CI runs. The branch it + // reduces away is held instead by the `toPosixPath` table above, which + // supplies the separator and so needs no Windows host. + const source = "../Text"; + + expect(resolveImportSource(filename, source)).toBe( + toPosixPath(resolve(dirname(filename), source), sep), + ); }); }); -describe("resolvePosix", () => { - test("resolves to an absolute path carrying no backslash", () => { - const result = resolvePosix(process.cwd(), "a", "b", "c"); +describe("findPackageRoot", () => { + // Built into a temporary directory rather than asserted against this + // repository, because the wrinkle under test only exists in the BUILT layout: + // react-native-builder-bob writes a bare `{ "type": … }` package.json into + // each output directory (`react-native-builder-bob/lib/src/utils/compile.js`), + // and stopping at one of those names `/dist/commonjs` as the package. + // Running from source, the walk never meets one. + let root = ""; - expect(result).not.toContain("\\"); - expect(result.split("/").slice(-3)).toEqual(["a", "b", "c"]); + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "react-native-css-root-")); }); - test("collapses '..' segments like path.resolve, keeping POSIX separators", () => { - const result = resolvePosix(process.cwd(), "a", "b", "..", "c"); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function write(relativePath: string, contents: string): void { + const target = join(root, relativePath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + } + + test("walks past a manifest that only declares a module type", () => { + write("package.json", JSON.stringify({ name: "react-native-css" })); + write("dist/commonjs/package.json", JSON.stringify({ type: "commonjs" })); + mkdirSync(join(root, "dist", "commonjs", "babel"), { recursive: true }); + + expect(findPackageRoot(join(root, "dist", "commonjs", "babel"))).toBe(root); + }); - expect(result).not.toContain("\\"); - expect(result.split("/").slice(-2)).toEqual(["a", "c"]); + test("finds the root from the source layout too", () => { + write("package.json", JSON.stringify({ name: "react-native-css" })); + mkdirSync(join(root, "src", "babel"), { recursive: true }); + + expect(findPackageRoot(join(root, "src", "babel"))).toBe(root); + }); + + test("stops at the nearest named manifest, not the outermost", () => { + write("package.json", JSON.stringify({ name: "outer" })); + write("packages/inner/package.json", JSON.stringify({ name: "inner" })); + mkdirSync(join(root, "packages", "inner", "src"), { recursive: true }); + + expect(findPackageRoot(join(root, "packages", "inner", "src"))).toBe( + join(root, "packages", "inner"), + ); }); }); diff --git a/src/__tests__/babel/import-plugin.test.ts b/src/__tests__/babel/import-plugin.test.ts new file mode 100644 index 00000000..233e1b0a --- /dev/null +++ b/src/__tests__/babel/import-plugin.test.ts @@ -0,0 +1,148 @@ +import { transformWithBabelPlugin as transform } from "../_transform"; + +// Absolute POSIX filenames. `path.resolve` prepends the cwd's drive letter on +// Windows, which changes the prefix of the resolved path but not the two things +// the handlers read from it — whether the package marker is present, and the +// last segment — so every expectation below holds on either host. +const REACT_NATIVE_WEB_DIST = "/project/node_modules/react-native-web/dist"; +const REACT_NATIVE_LIBRARIES = "/project/node_modules/react-native/Libraries"; + +describe("relative imports resolve against the file's directory", () => { + // `state.filename` is babel's path of the FILE being transformed + // (`PluginPass.filename` is `file.opts.filename`, and babel derives + // `sourceFileName` from `basename(filenameRelative)`), so a relative source + // resolves against `dirname(filename)`. Resolving against the filename itself + // consumes one `..` too few, which moves the package boundary by one directory. + + test("react-native-web: an import that leaves dist is not a dist internal", () => { + // `../../../View` from `dist/exports/View/index.js` lands on + // `react-native-web/View` — outside `dist`, so not a component this plugin + // owns. Resolved against the filename it lands on `dist/View` instead, and + // the plugin swaps a module the author never asked for. + const code = transform( + `import View from "../../../View";`, + `${REACT_NATIVE_WEB_DIST}/exports/View/index.js`, + ); + + expect(code).toBe(`import View from "../../../View";`); + }); + + test("react-native-web: a sibling importing its own directory index is rewritten", () => { + // `.` from `dist/exports/View/types.js` is the directory `dist/exports/View`, + // whose index IS react-native-web's View. Resolved against the filename it is + // `types.js`, which is in no component census, so the rewrite is missed. + const code = transform( + `import View from ".";`, + `${REACT_NATIVE_WEB_DIST}/exports/View/types.js`, + ); + + expect(code).toBe( + `import { View } from "react-native-css/components/View";`, + ); + }); + + test("react-native: an import that leaves Libraries/Components is not a component", () => { + // `../../View` from `Libraries/Components/View/View.js` lands on + // `react-native/Libraries/View`, which is not under `Components/`. + const code = transform( + `import View from "../../View";`, + `${REACT_NATIVE_LIBRARIES}/Components/View/View.js`, + ); + + expect(code).toBe(`import View from "../../View";`); + }); + + test("both handlers place the package boundary at the same depth", () => { + // The two handlers resolve the same way or they do not; this pins that they + // do, at the one depth where an off-by-one base is observable. Each source + // climbs exactly out of its package's marker directory. + const web = transform( + `import View from "../../../View";`, + `${REACT_NATIVE_WEB_DIST}/exports/View/index.js`, + ); + const native = transform( + `import View from "../../View";`, + `${REACT_NATIVE_LIBRARIES}/Components/View/View.js`, + ); + + expect(web).toBe(`import View from "../../../View";`); + expect(native).toBe(`import View from "../../View";`); + }); +}); + +describe("relative imports inside a package are rewritten", () => { + test("react-native-web: a sibling component", () => { + const code = transform( + `import View from "../View";`, + `${REACT_NATIVE_WEB_DIST}/exports/ScrollView/index.js`, + ); + + expect(code).toBe( + `import { View } from "react-native-css/components/View";`, + ); + }); + + test("react-native-web: a require() of a sibling component", () => { + const code = transform( + `const View = _interopRequireDefault(require("../View"));`, + `${REACT_NATIVE_WEB_DIST}/exports/ScrollView/index.js`, + ); + + expect(code).toBe( + `const {\n View\n} = require("react-native-css/components/View");`, + ); + }); + + test("react-native: a sibling component", () => { + const code = transform( + `import View from "../View/View";`, + `${REACT_NATIVE_LIBRARIES}/Components/ScrollView/ScrollView.js`, + ); + + expect(code).toBe( + `import { View } from "react-native-css/components/View";`, + ); + }); + + test("a module outside either package is left alone", () => { + const code = transform( + `import View from "../View";`, + `/project/src/screens/Home.js`, + ); + + expect(code).toBe(`import View from "../View";`); + }); +}); + +describe("package-level specifiers", () => { + const APP_FILE = "/project/src/screens/Home.js"; + + test("react-native-web: only the specifiers with a component are moved", () => { + const code = transform( + `import { View, Text, StyleSheet, Dimensions } from "react-native-web";`, + APP_FILE, + ); + + expect(code).toBe( + [ + `import { View } from "react-native-css/components/View";`, + `import { Text } from "react-native-css/components/Text";`, + `import { StyleSheet } from "react-native-web";`, + `import { Dimensions } from "react-native-web";`, + ].join("\n"), + ); + }); + + test("react-native: a deep path outside Libraries/Components still names its component", () => { + // Not a relative source, so no resolution happens — the last segment of the + // specifier is the component name. + const code = transform( + `import { View } from "react-native/lib/components/View";`, + APP_FILE, + ); + + expect(code).toBe( + `import { View } from "react-native-css/components/View";`, + ); + }); +}); diff --git a/src/__tests__/babel/own-sources.test.ts b/src/__tests__/babel/own-sources.test.ts new file mode 100644 index 00000000..cca78636 --- /dev/null +++ b/src/__tests__/babel/own-sources.test.ts @@ -0,0 +1,131 @@ +import { join, resolve } from "path"; + +import { transformSync, type PluginObj } from "@babel/core"; + +import { findPackageRoot } from "../../babel/helpers"; +import { transformWithBabelPlugin as transform } from "../_transform"; + +/** + * The plugin must never rewrite this package's own components. They import the + * primitive they wrap — `src/components/View.tsx` opens with + * `import { View as RNView } from "react-native"` — so a rewrite turns each of + * them into an import of itself. + * + * Metro decides which files those are through two values it supplies to babel + * (`metro/src/DeltaBundler/Transformer.js` hands the worker + * `path.relative(projectRoot, filePath)`, and `metro-babel-transformer` sets + * `cwd: options.projectRoot`): the filename is PROJECT-RELATIVE and the cwd is + * the project root. Comparing the relative name against an absolute prefix + * matches nothing, whatever the host. + */ +describe("this package's own sources", () => { + const packageRoot = findPackageRoot(__dirname); + + test("the package root is this repository", () => { + // Derived independently of the walk under test: this file sits at + // /src/__tests__/babel/. + expect(packageRoot).toBe(resolve(__dirname, "..", "..", "..")); + }); + + test("babel hands a plugin an absolute filename, whatever it was given", () => { + // The premise the guard rests on. `@babel/core/lib/config/partial.js` stores + // `path.resolve(cwd, opts.filename)`, so metro's project-relative name is + // already absolute by the time a visitor runs and the guard needs no + // resolution of its own. Should that ever change, this fails and says so. + let seen: string | undefined = undefined; + const capture = (): PluginObj => ({ + name: "capture-filename", + visitor: { + Program(_path, state) { + seen = state.filename; + }, + }, + }); + + transformSync("", { + filename: join("src", "components", "View.tsx"), + cwd: packageRoot, + configFile: false, + babelrc: false, + plugins: [capture], + }); + + expect(seen).toBe(join(packageRoot, "src", "components", "View.tsx")); + }); + + test("a component of this package keeps its react-native import", () => { + const code = transform( + `import { View as RNView } from "react-native";`, + join("src", "components", "View.tsx"), + { cwd: packageRoot }, + ); + + expect(code).toBe(`import { View as RNView } from "react-native";`); + }); + + test("a built component of this package keeps its react-native import", () => { + // What a consumer's metro actually transforms: this package under their + // node_modules, named relative to their project root. + const consumerProjectRoot = resolve(packageRoot, "..", ".."); + const relativeToConsumer = join( + ...packageRoot.slice(consumerProjectRoot.length + 1).split(/[\\/]/), + "dist", + "commonjs", + "components", + "View.js", + ); + + const code = transform( + `import { View as RNView } from "react-native";`, + relativeToConsumer, + { cwd: consumerProjectRoot }, + ); + + expect(code).toBe(`import { View as RNView } from "react-native";`); + }); + + test("an application file of the same name is still rewritten", () => { + // The guard is scoped to this package's own directories, not to a filename: + // an app with its own `components/View.tsx` must keep working. + const code = transform( + `import { View as RNView } from "react-native";`, + join("src", "components", "View.tsx"), + { cwd: join(packageRoot, "example") }, + ); + + expect(code).toBe( + `import { View as RNView } from "react-native-css/components/View";`, + ); + }); + + test("a sibling directory that merely shares a prefix is not this package", () => { + // `/src` must not swallow `/src-extra`. + const code = transform( + `import { View as RNView } from "react-native";`, + join("src-extra", "View.tsx"), + { cwd: packageRoot }, + ); + + expect(code).toBe( + `import { View as RNView } from "react-native-css/components/View";`, + ); + }); +}); + +describe("without a filename", () => { + test("babel can transform at all", () => { + // `PluginPass.filename` is `string | undefined` — babel populates it from + // `opts.filename`, which a direct `transformSync` caller need not pass. + // Reading `.startsWith` off it unconditionally throws before any rewrite is + // even considered. + expect(() => + transform(`import { View } from "react-native";`), + ).not.toThrow(); + }); + + test("nothing is rewritten, because no file can be resolved against", () => { + expect(transform(`import { View } from "react-native";`)).toBe( + `import { View } from "react-native";`, + ); + }); +}); diff --git a/src/__tests__/babel/plugin.test.mts b/src/__tests__/babel/plugin.test.mts deleted file mode 100644 index 04144446..00000000 --- a/src/__tests__/babel/plugin.test.mts +++ /dev/null @@ -1,45 +0,0 @@ -import { pluginTester } from "babel-plugin-tester"; - -import plugin from "../../babel/import-plugin"; - -pluginTester({ - plugin, - title: "plugin", - babelOptions: { - plugins: ["@babel/plugin-syntax-jsx"], - filename: "/someFile.js", - }, - tests: { - "rewrite imports from within React Native": { - only: true, - code: `import View from '../View/View';`, - output: `import { View } from "react-native-css/dist/module/components/View";`, - babelOptions: { - filename: - "node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js", - }, - }, - "rewrite react-native imports": { - code: `import { View, Text, StyleSheet, Dimensions } from "react-native";`, - output: `import { View } from "react-native-css/dist/module/components/View"; -import { Text } from "react-native-css/dist/module/components/Text"; -import { StyleSheet } from "react-native"; -import { Dimensions } from "react-native";`, - }, - "rewrite react-native deep imports": { - code: `import { View } from "react-native/lib/components/View";`, - output: `import { View } from "react-native-css/dist/module/components/View";`, - }, - "rewrite react-native-web imports": { - code: `import { View, Text, StyleSheet, Dimensions } from "react-native-web";`, - output: `import { View } from "react-native-css/dist/module/components/View"; -import { Text } from "react-native-css/dist/module/components/Text"; -import { StyleSheet } from "react-native-web"; -import { Dimensions } from "react-native-web";`, - }, - "rewrite react-native-web deep imports": { - code: `import { View } from "react-native-web/lib/components/View";`, - output: `import { View } from "react-native-css/dist/module/components/View";`, - }, - }, -}); diff --git a/src/__tests__/babel/react-native-web.test.ts b/src/__tests__/babel/react-native-web.test.ts index 5d54a4da..0b0e4562 100644 --- a/src/__tests__/babel/react-native-web.test.ts +++ b/src/__tests__/babel/react-native-web.test.ts @@ -10,6 +10,12 @@ describe("react-native-web", () => { pluginTester({ plugin, title: "plugin", + // An application file, not one of this package's own sources: the plugin skips + // everything under `/src` and `/dist`, and a test + // file IS under `src`. babel-plugin-tester feeds `filepath` to babel as + // `filename`, inferring this test file's own path when it is not set, so + // `babelOptions.filename` alone never reaches the plugin. + filepath: "/project/src/App.js", babelOptions: { plugins: ["@babel/plugin-syntax-jsx"], }, diff --git a/src/__tests__/babel/react-native.test.ts b/src/__tests__/babel/react-native.test.ts index a0472936..1637ac25 100644 --- a/src/__tests__/babel/react-native.test.ts +++ b/src/__tests__/babel/react-native.test.ts @@ -10,9 +10,14 @@ describe("react-native", () => { pluginTester({ plugin, title: "plugin", + // An application file, not one of this package's own sources: the plugin skips + // everything under `/src` and `/dist`, and a test + // file IS under `src`. babel-plugin-tester feeds `filepath` to babel as + // `filename`, inferring this test file's own path when it is not set, so + // `babelOptions.filename` alone never reaches the plugin. + filepath: "/project/src/App.js", babelOptions: { plugins: ["@babel/plugin-syntax-jsx"], - filename: "/someFile.js", }, tests: appendTitles([ { diff --git a/src/__tests__/babel/smoke.test.ts b/src/__tests__/babel/smoke.test.ts index ad5c202c..9c2da9f1 100644 --- a/src/__tests__/babel/smoke.test.ts +++ b/src/__tests__/babel/smoke.test.ts @@ -10,9 +10,14 @@ describe("plugin smoke tests", () => { pluginTester({ plugin, title: "plugin", + // An application file, not one of this package's own sources: the plugin skips + // everything under `/src` and `/dist`, and a test + // file IS under `src`. babel-plugin-tester feeds `filepath` to babel as + // `filename`, inferring this test file's own path when it is not set, so + // `babelOptions.filename` alone never reaches the plugin. + filepath: "/project/src/App.js", babelOptions: { plugins: ["@babel/plugin-syntax-jsx"], - filename: "/someFile.js", }, tests: appendTitles([ { diff --git a/src/__tests__/metro/resolver.test.ts b/src/__tests__/metro/resolver.test.ts new file mode 100644 index 00000000..052d8ca5 --- /dev/null +++ b/src/__tests__/metro/resolver.test.ts @@ -0,0 +1,336 @@ +import { join } from "path"; + +import type { + CustomResolutionContext, + CustomResolver, + Resolution, +} from "metro-resolver"; + +import { transformWithBabelPlugin } from "../_transform"; +import { allowedModules } from "../../babel/allowedModules"; +import { nativeResolver, webResolver } from "../../metro/resolver"; + +/** + * A complete `CustomResolutionContext`. The resolvers read `originModulePath` + * and forward the rest untouched, but the type is honoured in full so the + * fixture needs no cast. + */ +function createResolutionContext( + originModulePath: string, + resolveRequest: CustomResolver, +): CustomResolutionContext { + return { + allowHaste: false, + assetExts: [], + customResolverOptions: {}, + disableHierarchicalLookup: false, + doesFileExist: () => false, + fileSystemLookup: () => ({ exists: false }), + getPackage: () => null, + getPackageForModule: () => null, + mainFields: [], + nodeModulesPaths: [], + originModulePath, + preferNativePlatform: false, + redirectModulePath: (modulePath: string) => modulePath, + resolveAsset: () => undefined, + resolveHasteModule: () => undefined, + resolveHastePackage: () => undefined, + resolveRequest, + sourceExts: [], + unstable_conditionNames: [], + unstable_conditionsByPlatform: {}, + unstable_enablePackageExports: false, + unstable_logWarning: () => undefined, + }; +} + +interface ResolverRun { + /** Every module name the parent resolver was asked for, in order. */ + readonly requests: string[]; + readonly resolution: Resolution; +} + +/** + * Runs one of the two resolvers against a parent that reports `filePath` for the + * first request and echoes the module name for any re-resolution. The module + * name of the LAST request is the plane's answer — the metro-side equivalent of + * the specifier the babel plane emits. + */ +function runResolver( + resolver: typeof nativeResolver, + options: { + readonly originModulePath: string; + readonly moduleName: string; + readonly filePath: string; + readonly platform: string | null; + }, +): ResolverRun { + const requests: string[] = []; + let isFirst = true; + + const parent: CustomResolver = (_context, moduleName) => { + requests.push(moduleName); + const resolved = isFirst ? options.filePath : moduleName; + isFirst = false; + return { type: "sourceFile", filePath: resolved }; + }; + + const context = createResolutionContext(options.originModulePath, parent); + const resolution = resolver( + parent, + context, + options.moduleName, + options.platform, + ); + + return { requests, resolution }; +} + +const APP_FILE = join("/project", "src", "screens", "Home.js"); + +function reactNativeLibrariesPath(component: string): string { + return join( + "/project", + "node_modules", + "react-native", + "Libraries", + "Components", + component, + `${component}.js`, + ); +} + +function reactNativeWebExportPath(component: string): string { + return join( + "/project", + "node_modules", + "react-native-web", + "dist", + "exports", + component, + "index.js", + ); +} + +/** + * The component census both planes read. Filtered to the members that are also + * JavaScript identifiers, because the babel plane can only be reached through an + * import specifier — `src/components/react-native-safe-area-context.native.tsx` + * contributes a census entry that no `import { … }` can name. + */ +const componentNames = [...allowedModules] + .filter((name) => /^[A-Z][A-Za-z0-9]*$/.test(name)) + .sort(); + +describe("the component census", () => { + test("is not empty", () => { + // Every table below is generated from this census, so an empty one would + // turn each of them into a silent no-op rather than a failure. + expect(componentNames.length).toBeGreaterThan(0); + }); +}); + +describe("nativeResolver", () => { + test("routes the react-native barrel to the components barrel", () => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: "react-native", + filePath: join("/project", "node_modules", "react-native", "index.js"), + platform: "ios", + }); + + expect(requests.at(-1)).toBe("react-native-css/components"); + }); + + test.each(componentNames)( + "routes a resolved Libraries/Components file for %s", + (component) => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: `./${component}`, + filePath: reactNativeLibrariesPath(component), + platform: "ios", + }); + + expect(requests.at(-1)).toBe(`react-native-css/components/${component}`); + }, + ); + + test("leaves react-native's own index alone", () => { + const { requests } = runResolver(nativeResolver, { + originModulePath: join( + "/project", + "node_modules", + "react-native", + "index.js", + ), + moduleName: "react-native", + filePath: join("/project", "node_modules", "react-native", "index.js"), + platform: "ios", + }); + + expect(requests).toEqual(["react-native"]); + }); + + test("leaves a file outside react-native alone", () => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: "./Button", + filePath: join("/project", "src", "components", "Button.js"), + platform: "ios", + }); + + expect(requests).toEqual(["./Button"]); + }); +}); + +describe("webResolver", () => { + test.each(componentNames.filter((name) => name !== "VirtualizedList"))( + "routes a resolved react-native-web export for %s", + (component) => { + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: `./${component}`, + filePath: reactNativeWebExportPath(component), + platform: "web", + }); + + expect(requests.at(-1)).toBe(`react-native-css/components/${component}`); + }, + ); + + test("leaves react-native-web's vendored copies alone", () => { + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: "./View", + filePath: join( + "/project", + "node_modules", + "react-native-web", + "dist", + "vendor", + "react-native", + "View", + "index.js", + ), + platform: "web", + }); + + expect(requests).toEqual(["./View"]); + }); + + test("leaves a non-index file inside an export directory alone", () => { + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: "./View/types", + filePath: join( + "/project", + "node_modules", + "react-native-web", + "dist", + "exports", + "View", + "types.js", + ), + platform: "web", + }); + + expect(requests).toEqual(["./View/types"]); + }); +}); + +describe("the metro and babel planes agree", () => { + // They are alternatives, not layers: metro's `resolveRequest` rewrites when + // `globalClassNamePolyfill` is false, and the babel plugin rewrites when it is + // true (`src/metro/index.ts`). A user flipping that flag must land on the same + // component either way, so the two implementations are pinned against each + // other over the one census they both read. + + test.each(componentNames)("react-native's %s", (component) => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: `./${component}`, + filePath: reactNativeLibrariesPath(component), + platform: "ios", + }); + + const babel = transformWithBabelPlugin( + `import { ${component} } from "react-native";`, + APP_FILE, + ); + + expect(requests.at(-1)).toBe(`react-native-css/components/${component}`); + expect(babel).toBe( + `import { ${component} } from "react-native-css/components/${component}";`, + ); + }); + + test.each(componentNames.filter((name) => name !== "VirtualizedList"))( + "react-native-web's %s", + (component) => { + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: `./${component}`, + filePath: reactNativeWebExportPath(component), + platform: "web", + }); + + const babel = transformWithBabelPlugin( + `import { ${component} } from "react-native-web";`, + APP_FILE, + ); + + expect(requests.at(-1)).toBe(`react-native-css/components/${component}`); + expect(babel).toBe( + `import { ${component} } from "react-native-css/components/${component}";`, + ); + }, + ); + + test("except for VirtualizedList on web, which only the babel plane rewrites", () => { + // `webResolver` excludes it by name; the babel plane has no such exclusion. + // Pinned so the asymmetry is a decision on record rather than a surprise. + const { requests } = runResolver(webResolver, { + originModulePath: APP_FILE, + moduleName: "./VirtualizedList", + filePath: reactNativeWebExportPath("VirtualizedList"), + platform: "web", + }); + + expect(requests).toEqual(["./VirtualizedList"]); + expect( + transformWithBabelPlugin( + `import { VirtualizedList } from "react-native-web";`, + APP_FILE, + ), + ).toBe( + `import { VirtualizedList } from "react-native-css/components/VirtualizedList";`, + ); + }); + + test("except for react-native-safe-area-context, which only the metro plane rewrites", () => { + const { requests } = runResolver(nativeResolver, { + originModulePath: APP_FILE, + moduleName: "react-native-safe-area-context", + filePath: join( + "/project", + "node_modules", + "react-native-safe-area-context", + "src", + "index.tsx", + ), + platform: "ios", + }); + + expect(requests.at(-1)).toBe( + "react-native-css/components/react-native-safe-area-context", + ); + expect( + transformWithBabelPlugin( + `import { SafeAreaView } from "react-native-safe-area-context";`, + APP_FILE, + ), + ).toBe(`import { SafeAreaView } from "react-native-safe-area-context";`); + }); +}); diff --git a/src/babel/helpers.ts b/src/babel/helpers.ts index d4d5236c..fe961c53 100644 --- a/src/babel/helpers.ts +++ b/src/babel/helpers.ts @@ -1,4 +1,5 @@ -import { resolve, sep } from "path"; +import { existsSync, readFileSync } from "fs"; +import { dirname, join, resolve, sep } from "path"; import tBabelTypes, { type CallExpression } from "@babel/types"; @@ -12,7 +13,12 @@ export interface PluginOpts { export interface PluginState { opts?: PluginOpts; - filename: string; + /** + * Babel's `PluginPass.filename` is `string | undefined`: absolute when + * `opts.filename` was given (babel resolves it against `cwd`), and `undefined` + * when a `transformSync` caller passed none. + */ + filename: string | undefined; } export function getInteropRequireDefaultSource( @@ -42,31 +48,78 @@ export function getInteropRequireDefaultSource( } /** - * Rewrite Windows separators as POSIX ones. + * Rewrite a host path's separators as POSIX ones. * - * A pure string transform with no platform check of its own, so a test can feed - * it a Windows-shaped literal and observe the result on any host. Only call it - * on a path known to use Windows separators — `resolvePosix` is that caller. + * `hostSeparator` is `path.sep`, taken as an argument rather than read from the + * module. It is the entire decision this function makes, and a test that cannot + * supply it can only ever observe the branch its own host happens to take — CI + * runs ubuntu-latest plus one macos-15, so the Windows branch would be exercised + * nowhere. + * + * The gate is not cosmetic. On POSIX a backslash is a legal filename character, + * so `/project/weird\name.js` is one file and rewriting it would name a + * different, non-existent path. */ -export function toPosixPath(path: string): string { - return path.replaceAll("\\", "/"); +export function toPosixPath(path: string, hostSeparator: string): string { + return hostSeparator === "/" ? path : path.replaceAll("\\", "/"); } /** - * `path.resolve`, in POSIX separators. + * Resolve a relative import source against the file that contains it, in POSIX + * separators. * - * The relative-import handlers resolve a source against the file being - * transformed and then match the result against forward-slash literals - * (`react-native/Libraries/Components/`, `react-native-web/dist`, …). On Windows - * `path.resolve` yields backslash separators, so those `split` / `startsWith` - * matches silently miss and the import is left un-rewritten. + * Two properties of the result are load-bearing, and both belong here rather + * than at the call sites: * - * The platform check lives here rather than in `toPosixPath` because this is - * where a host path enters. On POSIX a backslash is a legal filename character, - * so rewriting one there would corrupt a path that was already correct. + * - **The base is the file's directory.** `filename` is babel's path of the FILE + * being transformed (`PluginPass.filename` is `file.opts.filename`), so + * `./x` beside it is `dirname(filename)/x`. Resolving against the filename + * itself consumes one `..` too few and moves the package boundary by one + * directory. Taking the base is part of the operation, which is why this + * signature is `(filename, source)` and not a variadic resolve: the caller is + * given no base to get wrong. + * - **The separators are POSIX.** Callers match the result against forward-slash + * literals (`react-native/Libraries/Components/`, `react-native-web/dist`) and + * re-emit its tail as a module specifier, which is forward-slash by + * definition. On Windows `path.resolve` yields backslashes, so those matches + * silently miss and the import is left un-rewritten. */ -export function resolvePosix(...segments: string[]): string { - const resolved = resolve(...segments); +export function resolveImportSource(filename: string, source: string): string { + return toPosixPath(resolve(dirname(filename), source), sep); +} - return sep === "/" ? resolved : toPosixPath(resolved); +function declaresName(manifestPath: string): boolean { + const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8")); + + return typeof parsed === "object" && parsed !== null && "name" in parsed; +} + +/** + * The directory of the package `from` belongs to. + * + * The babel plugin sits at `/src/babel/` in the tree and at + * `/dist//babel/` once built, so no fixed number of `..` names the + * root in both — a constant written for one layout is silently wrong in the + * other. The manifest names it, with one wrinkle: react-native-builder-bob + * writes a bare `{ "type": … }` package.json into each output directory + * (`react-native-builder-bob/lib/src/utils/compile.js`), so the walk looks for + * the nearest manifest that declares a `name`. + */ +export function findPackageRoot(from: string): string { + let directory = from; + + for (;;) { + const manifest = join(directory, "package.json"); + + if (existsSync(manifest) && declaresName(manifest)) { + return directory; + } + + const parent = dirname(directory); + if (parent === directory) { + throw new Error(`No named package.json above ${from}`); + } + + directory = parent; + } } diff --git a/src/babel/import-plugin.ts b/src/babel/import-plugin.ts index 7c7c0ac0..053fd62d 100644 --- a/src/babel/import-plugin.ts +++ b/src/babel/import-plugin.ts @@ -1,9 +1,10 @@ -import { resolve } from "path"; +import { join, sep } from "path"; import { type PluginObj } from "@babel/core"; import type { Statement } from "@babel/types"; import { + findPackageRoot, getInteropRequireDefaultSource, type BabelTypes, type PluginState, @@ -24,32 +25,57 @@ export default function ({ }: { types: BabelTypes; }): PluginObj { - const processed = new WeakSet(); - - const thisModuleDist = resolve(__dirname, "../../../dist"); - const thisModuleSrc = resolve(__dirname, "../../../src"); - + // Nodes this plugin generated. `replaceWithMultiple` requeues its replacements, + // so a rewrite that reproduces its own input — `const { Platform } = + // require("react-native")` — would otherwise be visited and rewritten forever. + // The set holds `Statement` nodes, never the `NodePath`s wrapping them, and the + // element type says so: `processed.has(path)` is a compile error, not a + // disjunct that is quietly always false. + const processed = new WeakSet(); + + // This package's own components import the primitive they wrap, so rewriting + // one turns it into an import of itself. These are the two directories it + // ships (`package.json`'s `files`), each with a trailing separator so a + // sibling like `/src-extra` is not swallowed by the prefix. + const packageRoot = findPackageRoot(__dirname); + const ownDirectories = [ + join(packageRoot, "dist") + sep, + join(packageRoot, "src") + sep, + ]; + + /** + * `filename` is already absolute: babel stores `path.resolve(cwd, opts.filename)` + * (`@babel/core/lib/config/partial.js`), so metro handing it a project-relative + * name (`metro/src/DeltaBundler/Transformer.js` passes + * `path.relative(projectRoot, filePath)`) still arrives here resolved against + * `cwd`, which `metro-babel-transformer` sets to the project root. Both sides of + * the comparison are OS-native absolute paths, so no separator normalization + * belongs here. + */ function isFromThisModule(filename: string): boolean { - return ( - filename.startsWith(thisModuleDist) || filename.startsWith(thisModuleSrc) - ); + return ownDirectories.some((directory) => filename.startsWith(directory)); } return { name: "Rewrite react-native to react-native-css", visitor: { ImportDeclaration(path, state): void { + const { filename } = state; + + // Without a filename nothing can be resolved against, and the guard below + // has nothing to compare. `PluginPass.filename` is `string | undefined` + // precisely because a direct `transformSync` caller need not supply one. if ( - processed.has(path) || + filename === undefined || processed.has(path.node) || - isFromThisModule(state.filename) + isFromThisModule(filename) ) { return; } const statements = - handleReactNativeImport(path.node, t, state.filename) ?? - handleReactNativeWebImport(path.node, t, state.filename); + handleReactNativeImport(path.node, t, filename) ?? + handleReactNativeWebImport(path.node, t, filename); if (!statements) { return; @@ -62,10 +88,12 @@ export default function ({ path.replaceWithMultiple(statements); }, VariableDeclaration(path, state): void { + const { filename } = state; + if ( - processed.has(path) || + filename === undefined || processed.has(path.node) || - isFromThisModule(state.filename) + isFromThisModule(filename) ) { return; } @@ -114,14 +142,14 @@ export default function ({ t, id.name, initArg.value, - state.filename, + filename, ) ?? handleReactNativeWebIdentifierRequire( path, t, id.name, initArg.value, - state.filename, + filename, ); } else if ( t.isObjectPattern(id) && @@ -134,14 +162,14 @@ export default function ({ t, id, initArg.value, - state.filename, + filename, ) ?? handleReactNativeWebObjectPatternRequire( path, t, id, initArg.value, - state.filename, + filename, ); } else if ( t.isIdentifier(id) && @@ -157,7 +185,7 @@ export default function ({ t, id.name, source, - state.filename, + filename, ); } diff --git a/src/babel/react-native-web.ts b/src/babel/react-native-web.ts index db860a49..6262caf1 100644 --- a/src/babel/react-native-web.ts +++ b/src/babel/react-native-web.ts @@ -7,13 +7,13 @@ import tBabelTypes, { } from "@babel/types"; import { allowedModules } from "./allowedModules"; -import { resolvePosix } from "./helpers"; +import { resolveImportSource } from "./helpers"; type BabelTypes = typeof tBabelTypes; function parseReactNativeWebSource(source: string, filename: string) { if (source.startsWith(".")) { - source = resolvePosix(filename, source); + source = resolveImportSource(filename, source); const internalPath = source.split("react-native-web/dist")[1]; if (!internalPath) { diff --git a/src/babel/react-native.ts b/src/babel/react-native.ts index ad65c941..ab30c134 100644 --- a/src/babel/react-native.ts +++ b/src/babel/react-native.ts @@ -1,5 +1,3 @@ -import { dirname } from "path"; - import { type NodePath } from "@babel/traverse"; import tBabelTypes, { type ImportDeclaration, @@ -9,13 +7,13 @@ import tBabelTypes, { } from "@babel/types"; import { allowedModules } from "./allowedModules"; -import { resolvePosix } from "./helpers"; +import { resolveImportSource } from "./helpers"; type BabelTypes = typeof tBabelTypes; function parseReactNativeSource(source: string, filename: string) { if (source.startsWith(".")) { - source = resolvePosix(dirname(filename), source); + source = resolveImportSource(filename, source); const internalPath = source.split("react-native/Libraries/Components/")[1]; if (!internalPath) { From b8284ed603457a1c6f1e98d11394b509e48f764a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 02:05:31 +0300 Subject: [PATCH 5/8] refactor(compiler): name the shape compile().warnings() returns `getWarnings` declared its return type inline, so every consumer of the public `compile(css).warnings()` result had to restate the three channels or reach for `ReturnType<...>`. `CompilerWarnings` is that shape, exported from the compiler's public types beside `CompilerOptions`. No behaviour changes: the object built and returned is identical. --- src/compiler/compiler.types.ts | 22 ++++++++++++++++++++++ src/compiler/stylesheet.ts | 9 +++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 00e08785..07689a9c 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -24,6 +24,28 @@ export interface InlineVariableOptions { exclude?: `--${string}`[]; } +/** + * The advisory diagnostics a compile recorded, as returned by + * `compile(css).warnings()`. + * + * Every channel is absent rather than empty when nothing was recorded, so an + * object with no keys is the "nothing was dropped" answer. + */ +export interface CompilerWarnings { + /** Properties with no React Native equivalent. The declaration is dropped. */ + properties?: string[]; + /** + * Values that could not be translated, keyed by the property that held them. + * + * The recorded value is whatever the compiler had in hand at the point it + * gave up — a string for most declarations, a lightningcss node for some — + * so consumers must render it defensively. + */ + values?: Record; + /** CSS functions with no React Native equivalent. */ + functions?: string[]; +} + /** * A `react-native-css` StyleSheet */ diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index a77fdf89..28039746 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -9,6 +9,7 @@ import type { AnimationKeyframes, AnimationRecord, CompilerOptions, + CompilerWarnings, ContainerQuery, MediaCondition, ReactNativeCssStyleSheet, @@ -227,12 +228,8 @@ export class StylesheetBuilder { } } - getWarnings() { - const result: { - properties?: string[]; - values?: Record; - functions?: string[]; - } = {}; + getWarnings(): CompilerWarnings { + const result: CompilerWarnings = {}; if (this.shared.warningProperties.length) { result.properties = this.shared.warningProperties; From cca059ab49caa78e820bdc310733c0b456c0d58b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 02:06:00 +0300 Subject: [PATCH 6/8] feat(metro): surface compiler warnings during a build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiler records every declaration it cannot translate — 52 `addWarning` call sites in `declarations.ts` — and `compile(css).warnings()` hands them back. Nothing in a real build ever asked: `metro-transformer.ts` called `.stylesheet()` and dropped the compile result, so the only consumer was `src/jest/index.ts`, behind `REACT_NATIVE_CSS_TEST_DEBUG` + `--inspect`. An unsupported declaration therefore did nothing, silently, and the compiler knew and said so into a void. The transformer now reads them and writes one block per stylesheet: react-native-css: src/global.css - 3 declarations dropped, no React Native equivalent properties: columns, float values: z-index: auto The noise model is the part worth reviewing. Metro re-transforms a file whenever its contents change, and a Tailwind build rewrites its CSS output on nearly every source save, so printing unconditionally would reprint the same block on every keystroke. A block is keyed by the file and the message, so it prints when a file's set of warnings CHANGES — the first compile, and thereafter only when a fix removes an entry or a new declaration adds one. The map is bounded by the number of CSS files rather than by the number of transforms. Volume is capped for the same reason. Compiling the Tailwind corpus in `src/__tests__/vendor/tailwind` warns on 87 distinct properties across 389 of its cases, so a `summary` lists ten entries per channel, five values per property, 60 characters per value, and counts the rest. `warnings: "verbose"` lifts every cap; `warnings: "none"` prints nothing. `console.warn` is what makes it reachable: Metro pipes each transform worker's stderr to the parent and its reporter prints the chunk, and with `maxWorkers: 1` the worker is required in-band. Neither path fails a build — these are advisory, and a dropped `float` is not a reason to refuse to bundle. The block carries no ANSI escapes because a piped worker has no TTY, so the vendored picocolors would disable itself in exactly the environment this runs in. Native only. Web returns to the stock Expo transformer before the compiler is reached, so a web bundle produces no warnings to surface; the test asserts that the CSS was still processed whole rather than that nothing happened. Tested on both planes. `warnings.test.ts` drives the formatter and the reporter, taking its fixtures from real `compile()` calls so a compiler that stopped warning would fail them. `metro-transformer.test.ts` runs the real transformer against the real Expo transform worker and asserts the block lands on `console.warn` — the assertion that has never been true. Closes #424 --- README.md | 22 ++ src/__tests__/metro/metro-transformer.test.ts | 210 +++++++++++ src/__tests__/metro/warnings.test.ts | 346 ++++++++++++++++++ src/metro/index.ts | 10 + src/metro/metro-transformer.ts | 25 +- src/metro/warnings.ts | 289 +++++++++++++++ 6 files changed, 898 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/metro/metro-transformer.test.ts create mode 100644 src/__tests__/metro/warnings.test.ts create mode 100644 src/metro/warnings.ts diff --git a/README.md b/README.md index bafb2302..36992f9b 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,28 @@ 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 +## Compiler warnings + +Not every CSS declaration has a React Native equivalent. When the compiler cannot translate one it drops that declaration, and Metro prints a summary for the stylesheet it came from: + +``` +react-native-css: src/global.css - 3 declarations dropped, no React Native equivalent + properties: columns, float + values: z-index: auto +``` + +A block is printed the first time a file is compiled, and after that only when the file's set of warnings changes — an incremental rebuild that changes nothing about them stays quiet. Warnings are advisory and never fail a build. + +Use the `warnings` option to change how much is printed: + +```tsx +export default withReactNativeCSS(defaultConfig, { + warnings: "verbose", // "summary" (the default) | "verbose" | "none" +}); +``` + +`summary` lists the first ten entries of each channel, `verbose` lists every entry, and `none` prints nothing. A web bundle never runs the compiler, so this only affects native builds. + ## 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 diff --git a/src/__tests__/metro/metro-transformer.test.ts b/src/__tests__/metro/metro-transformer.test.ts new file mode 100644 index 00000000..5cb12b16 --- /dev/null +++ b/src/__tests__/metro/metro-transformer.test.ts @@ -0,0 +1,210 @@ +import { join } from "path"; + +import type { + JsTransformerConfig, + JsTransformOptions, + TransformResponse, +} from "metro-transform-worker"; + +import type { CompilerOptions } from "../../compiler"; +import { transform } from "../../metro/metro-transformer"; +import type { WarningLevel } from "../../metro/warnings"; + +/** + * The transformer under test is the boundary a real build crosses: Metro hands + * it a `.css` file and it hands back JS. Nothing here is faked — the stock + * Expo transform worker runs, lightningcss runs, and `compile()` runs — so a + * warning observed on `console.warn` is a warning a developer running + * `expo start` would see. + */ + +const PROJECT_ROOT = process.cwd(); + +const CONFIG: JsTransformerConfig & { + reactNativeCSS?: (CompilerOptions & { warnings?: WarningLevel }) | undefined; +} = { + allowOptionalDependencies: true, + assetPlugins: [], + assetRegistryPath: "react-native/Libraries/Image/AssetRegistry", + asyncRequireModulePath: "metro-runtime/src/modules/asyncRequire", + babelTransformerPath: require.resolve( + "@expo/metro-config/build/babel-transformer", + ), + dynamicDepsInPackages: "reject", + enableBabelRCLookup: false, + enableBabelRuntime: true, + globalPrefix: "", + hermesParser: false, + minifierConfig: {}, + minifierPath: "metro-minify-terser", + optimizationSizeLimit: 150 * 1024, + publicPath: "/assets", + unstable_allowRequireContext: false, + unstable_collectDependenciesPath: require.resolve( + "metro/private/ModuleGraph/worker/collectDependencies", + ), + unstable_compactOutput: false, + unstable_disableModuleWrapping: false, + unstable_disableNormalizePseudoGlobals: false, +}; + +const OPTIONS: JsTransformOptions = { + customTransformOptions: {}, + dev: true, + experimentalImportSupport: false, + hot: false, + inlinePlatform: true, + inlineRequires: false, + minify: false, + platform: "ios", + type: "module", + unstable_transformProfile: "default", +}; + +const written: string[] = []; + +beforeEach(() => { + written.length = 0; + jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + written.push(args.map((arg) => String(arg)).join(" ")); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +/** + * Metro's own dependencies write to `console.warn` during a transform + * (browserslist announces the age of its data, for one), so the assertion is + * over this package's lines rather than over every line. + */ +function reported(): string[] { + return written.filter((message) => message.startsWith("react-native-css:")); +} + +function runTransform(options: { + css: string; + name: string; + platform?: string; + warnings?: WarningLevel; +}): Promise { + return transform( + options.warnings === undefined + ? CONFIG + : { ...CONFIG, reactNativeCSS: { warnings: options.warnings } }, + PROJECT_ROOT, + join(PROJECT_ROOT, "src", options.name), + Buffer.from(options.css), + { ...OPTIONS, platform: options.platform ?? OPTIONS.platform }, + ); +} + +test("a dropped declaration reaches the terminal running the bundler", async () => { + const output = await runTransform({ + css: `.a { float: left; z-index: auto; color: red; }`, + name: "surfaced.css", + }); + + expect(reported()).toStrictEqual([ + [ + `react-native-css: ${join("src", "surfaced.css")} - 2 declarations dropped, no React Native equivalent`, + " properties: float", + " values: z-index: auto", + ].join("\n"), + ]); + + // The declaration the compiler DID understand still ships, so the warning is + // advisory rather than a refusal to build. + expect(JSON.stringify(output.output[0])).toContain("color"); +}); + +test("a stylesheet the compiler fully understood says nothing", async () => { + await runTransform({ + css: `.a { color: red; }`, + name: "clean.css", + }); + + expect(reported()).toStrictEqual([]); +}); + +test('"none" silences a stylesheet that would otherwise warn', async () => { + await runTransform({ + css: `.a { float: left; }`, + name: "silenced.css", + warnings: "none", + }); + + expect(reported()).toStrictEqual([]); +}); + +test('"verbose" lifts the cap', async () => { + await runTransform({ + css: `.a { border-style: hidden; } +.b { border-style: double; } +.c { border-style: groove; } +.d { border-style: ridge; } +.e { border-style: inset; } +.f { border-style: outset; } +.g { border-style: none; }`, + name: "verbose.css", + warnings: "verbose", + }); + + expect(reported()[0]).toContain( + "values: border-style: double, groove, hidden, inset, none, outset, ridge", + ); +}); + +test("re-transforming the same file with the same warnings reports once", async () => { + const css = `.a { float: left; }`; + + await runTransform({ css, name: "rebuilt.css" }); + await runTransform({ css, name: "rebuilt.css" }); + await runTransform({ css: `${css} .b { color: red; }`, name: "rebuilt.css" }); + + expect(reported()).toHaveLength(1); +}); + +test("re-transforming the same file with new warnings reports again", async () => { + await runTransform({ css: `.a { float: left; }`, name: "edited.css" }); + await runTransform({ + css: `.a { float: left; z-index: auto; }`, + name: "edited.css", + }); + + const calls = reported(); + expect(calls).toHaveLength(2); + expect(calls[1]).toContain("values: z-index: auto"); +}); + +test("web reports nothing, because web never reaches the compiler", async () => { + const output = await runTransform({ + css: `.a { float: left; z-index: auto; }`, + name: "web.css", + platform: "web", + }); + + expect(reported()).toStrictEqual([]); + + // Not silence from having done nothing: the stock Expo transformer handled + // the file and emitted the CSS whole, `float` included. There is no compile + // on this platform, so this branch produces no warnings to surface; the + // native branch above is the only one that produces them. + const web = output as TransformResponse & { + output: [{ data: { css: { code: Buffer } } }]; + }; + expect(web.output[0].data.css.code.toString()).toContain("float"); +}); + +test("a file that is not CSS is passed through untouched", async () => { + await transform( + CONFIG, + PROJECT_ROOT, + join(PROJECT_ROOT, "src", "component.tsx"), + Buffer.from(`export const value = 1;`), + OPTIONS, + ); + + expect(reported()).toStrictEqual([]); +}); diff --git a/src/__tests__/metro/warnings.test.ts b/src/__tests__/metro/warnings.test.ts new file mode 100644 index 00000000..aef9f032 --- /dev/null +++ b/src/__tests__/metro/warnings.test.ts @@ -0,0 +1,346 @@ +import { join } from "path"; + +import { compile } from "../../compiler"; +import { + formatCompilerWarnings, + reportCompilerWarnings, +} from "../../metro/warnings"; + +/** + * Every fixture below is a real `compile()` result rather than a hand-written + * `CompilerWarnings` literal. A literal would agree with whatever the compiler + * happens to do — including with the compiler no longer warning at all, which + * is the failure this feature exists to make visible. + * + * The one literal in the file is the `functions` channel, and it says why. + */ +function warningsFor(css: string) { + return compile(css).warnings(); +} + +const PROJECT_ROOT = join("/project"); + +const FIFTEEN_UNSUPPORTED_PROPERTIES = `.a { + mix-blend-mode: multiply; + background-blend-mode: screen; + touch-action: none; + transform-origin: top left; + font-variant-numeric: ordinal; + border-spacing: 2px; + background-position: center; + backdrop-filter: blur(2px); + break-before: page; + break-after: page; + white-space: nowrap; + resize: both; + will-change: transform; + clear: both; + order: 2; +}`; + +const SEVEN_UNSUPPORTED_BORDER_STYLES = `.a { border-style: hidden; } +.b { border-style: double; } +.c { border-style: groove; } +.d { border-style: ridge; } +.e { border-style: inset; } +.f { border-style: outset; } +.g { border-style: none; }`; + +const written: string[] = []; + +beforeEach(() => { + written.length = 0; + jest.spyOn(console, "warn").mockImplementation((...args: unknown[]) => { + written.push(args.map((arg) => String(arg)).join(" ")); + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +/** + * Metro's own dependencies write to `console.warn` during a transform + * (browserslist announces its age, for one), so the assertion is over this + * package's lines rather than over every line. + */ +function reported(): string[] { + return written.filter((message) => message.startsWith("react-native-css:")); +} + +describe("formatCompilerWarnings", () => { + test("names the file, the count and the dropped property", () => { + expect( + formatCompilerWarnings(warningsFor(`.a { float: left; }`), { + displayPath: "src/global.css", + }), + ).toBe( + [ + "react-native-css: src/global.css - 1 declaration dropped, no React Native equivalent", + " properties: float", + ].join("\n"), + ); + }); + + test("a compile that dropped nothing formats to nothing", () => { + expect( + formatCompilerWarnings(warningsFor(`.a { color: red; }`), { + displayPath: "src/global.css", + }), + ).toBeUndefined(); + }); + + test("a repeated property is listed once and counted every time", () => { + expect( + formatCompilerWarnings( + warningsFor( + `.a { float: left; } .b { float: right; } .c { float: none; }`, + ), + { displayPath: "src/global.css" }, + ), + ).toBe( + [ + "react-native-css: src/global.css - 3 declarations dropped, no React Native equivalent", + " properties: float", + ].join("\n"), + ); + }); + + test("both channels are reported together", () => { + expect( + formatCompilerWarnings( + warningsFor(`.a { float: left; z-index: auto; }`), + { + displayPath: "src/global.css", + }, + ), + ).toBe( + [ + "react-native-css: src/global.css - 2 declarations dropped, no React Native equivalent", + " properties: float", + " values: z-index: auto", + ].join("\n"), + ); + }); + + test("a value the compiler recorded as a number renders as text", () => { + // `line-height: 50%` is the live producer of a non-string entry: it reaches + // `addWarning("style", "line-height", 0.5)`. That is why the channel is + // typed `unknown[]`, and why rendering it is total rather than a cast. + expect( + formatCompilerWarnings(warningsFor(`.a { line-height: 50%; }`), { + displayPath: "src/global.css", + }), + ).toBe( + [ + "react-native-css: src/global.css - 1 declaration dropped, no React Native equivalent", + " values: line-height: 0.5", + ].join("\n"), + ); + }); + + test("a value too long for a line is cut short, and verbose keeps it whole", () => { + // An unresolved `calc()` reaches the channel as a serialized lightningcss + // node — the longest value any real stylesheet produces. + const warnings = warningsFor(`.a { line-height: calc(1px + 2%); }`); + + expect( + formatCompilerWarnings(warnings, { displayPath: "src/global.css" }), + ).toBe( + [ + "react-native-css: src/global.css - 1 declaration dropped, no React Native equivalent", + ` values: line-height: {"type":"function","value":{"type":"calc","value":{"type":"s...`, + ].join("\n"), + ); + + expect( + formatCompilerWarnings(warnings, { + displayPath: "src/global.css", + verbose: true, + }), + ).toContain(`"unit":"px"`); + }); + + test("the functions channel is rendered when it carries anything", () => { + // A literal, deliberately. `getWarnings()` declares this channel and + // `compile()` returns it, but no `addWarning` call site writes to it today, + // so no CSS can produce one. A formatter that quietly ignored it would make + // the first producer as unreachable as the whole channel is today. + expect( + formatCompilerWarnings( + { functions: ["env()", "attr()", "env()"] }, + { displayPath: "src/global.css" }, + ), + ).toBe( + [ + "react-native-css: src/global.css - 3 declarations dropped, no React Native equivalent", + " functions: attr(), env()", + ].join("\n"), + ); + }); + + test("a summary sorts, caps at ten and says how to see the rest", () => { + expect( + formatCompilerWarnings(warningsFor(FIFTEEN_UNSUPPORTED_PROPERTIES), { + displayPath: "src/global.css", + }), + ).toBe( + [ + "react-native-css: src/global.css - 15 declarations dropped, no React Native equivalent", + " properties: backdrop-filter, background-blend-mode, background-position, border-spacing, break-after, break-before, clear, font-variant-numeric, mix-blend-mode, order (+5 more)", + ` Set reactNativeCSS.warnings to "verbose" for the full list, or "none" to silence this.`, + ].join("\n"), + ); + }); + + test("verbose lists every entry and drops the hint", () => { + expect( + formatCompilerWarnings(warningsFor(FIFTEEN_UNSUPPORTED_PROPERTIES), { + displayPath: "src/global.css", + verbose: true, + }), + ).toBe( + [ + "react-native-css: src/global.css - 15 declarations dropped, no React Native equivalent", + " properties: backdrop-filter, background-blend-mode, background-position, border-spacing, break-after, break-before, clear, font-variant-numeric, mix-blend-mode, order, resize, touch-action, transform-origin, white-space, will-change", + ].join("\n"), + ); + }); + + test("one property's values cap at five so it cannot crowd out the rest", () => { + expect( + formatCompilerWarnings(warningsFor(SEVEN_UNSUPPORTED_BORDER_STYLES), { + displayPath: "src/global.css", + }), + ).toBe( + [ + "react-native-css: src/global.css - 7 declarations dropped, no React Native equivalent", + " values: border-style: double, groove, hidden, inset, none (+2 more)", + ` Set reactNativeCSS.warnings to "verbose" for the full list, or "none" to silence this.`, + ].join("\n"), + ); + }); + + test("verbose lists every value of a property too", () => { + expect( + formatCompilerWarnings(warningsFor(SEVEN_UNSUPPORTED_BORDER_STYLES), { + displayPath: "src/global.css", + verbose: true, + }), + ).toBe( + [ + "react-native-css: src/global.css - 7 declarations dropped, no React Native equivalent", + " values: border-style: double, groove, hidden, inset, none, outset, ridge", + ].join("\n"), + ); + }); +}); + +describe("reportCompilerWarnings", () => { + test("writes the block to console.warn, pathed from the project root", () => { + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename: join(PROJECT_ROOT, "src", "reports.css"), + projectRoot: PROJECT_ROOT, + }); + + expect(reported()).toStrictEqual([ + [ + `react-native-css: ${join("src", "reports.css")} - 1 declaration dropped, no React Native equivalent`, + " properties: float", + ].join("\n"), + ]); + }); + + test('"none" writes nothing at all', () => { + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename: join(PROJECT_ROOT, "src", "silenced.css"), + projectRoot: PROJECT_ROOT, + level: "none", + }); + + expect(reported()).toStrictEqual([]); + }); + + test('"verbose" writes the uncapped block', () => { + reportCompilerWarnings(warningsFor(SEVEN_UNSUPPORTED_BORDER_STYLES), { + filename: join(PROJECT_ROOT, "src", "verbose.css"), + projectRoot: PROJECT_ROOT, + level: "verbose", + }); + + expect(reported()[0]).toContain( + "values: border-style: double, groove, hidden, inset, none, outset, ridge", + ); + }); + + test("a file whose warnings have not changed is not reported twice", () => { + const filename = join(PROJECT_ROOT, "src", "unchanged.css"); + + for (let index = 0; index < 3; index += 1) { + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + } + + expect(reported()).toHaveLength(1); + }); + + test("a file whose warnings changed is reported again", () => { + const filename = join(PROJECT_ROOT, "src", "changed.css"); + + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + reportCompilerWarnings(warningsFor(`.a { float: left; z-index: auto; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + + const calls = reported(); + expect(calls).toHaveLength(2); + expect(calls[1]).toContain("values: z-index: auto"); + }); + + test("a file that stops warning can warn again later", () => { + const filename = join(PROJECT_ROOT, "src", "fixed-then-broken.css"); + + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + reportCompilerWarnings(warningsFor(`.a { color: red; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + + expect(reported()).toHaveLength(2); + }); + + test("each file reports on its own, not once per process", () => { + for (const name of ["first.css", "second.css"]) { + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename: join(PROJECT_ROOT, "src", name), + projectRoot: PROJECT_ROOT, + }); + } + + expect(reported()).toHaveLength(2); + }); + + test("a file outside the project root keeps its absolute path", () => { + const filename = join("/elsewhere", "vendor", "theme.css"); + + reportCompilerWarnings(warningsFor(`.a { float: left; }`), { + filename, + projectRoot: PROJECT_ROOT, + }); + + expect(reported()[0]).toContain(`react-native-css: ${filename} -`); + }); +}); diff --git a/src/metro/index.ts b/src/metro/index.ts index 73c041c7..642894aa 100644 --- a/src/metro/index.ts +++ b/src/metro/index.ts @@ -6,6 +6,7 @@ import type { MetroConfig } from "metro-config"; import { type CompilerOptions } from "../compiler"; import { nativeResolver, webResolver } from "./resolver"; import { setupTypeScript } from "./typescript"; +import type { WarningLevel } from "./warnings"; export interface WithReactNativeCSSOptions extends CompilerOptions { /* Specify the path to the TypeScript environment file. Defaults types-env.d.ts */ @@ -15,8 +16,17 @@ export interface WithReactNativeCSSOptions extends CompilerOptions { /** Add className to all React Native primitives. Defaults false */ globalClassNamePolyfill?: boolean; hexColors?: boolean; + /** + * How the compiler's warnings are surfaced while bundling. Defaults + * "summary": one deduplicated, capped block per CSS file, printed only when + * that file's set of warnings changes. "verbose" lists every entry, "none" + * prints nothing. Native only — a web bundle never runs the compiler. + */ + warnings?: WarningLevel; } +export type { WarningLevel } from "./warnings"; + const metroOverrideResolution = { type: "sourceFile", filePath: require.resolve("./override"), diff --git a/src/metro/metro-transformer.ts b/src/metro/metro-transformer.ts index e56e1ca7..2bbf84fa 100644 --- a/src/metro/metro-transformer.ts +++ b/src/metro/metro-transformer.ts @@ -7,6 +7,7 @@ import type { import { compile, type CompilerOptions } from "../compiler"; import { getNativeInjectionCode } from "./injection-code"; +import { reportCompilerWarnings, type WarningLevel } from "./warnings"; const worker = // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -14,7 +15,9 @@ const worker = export async function transform( config: JsTransformerConfig & { - reactNativeCSS?: CompilerOptions | undefined; + reactNativeCSS?: + | (CompilerOptions & { warnings?: WarningLevel | undefined }) + | undefined; }, projectRoot: string, filePath: string, @@ -36,11 +39,25 @@ export async function transform( const css = cssFile.output[0].data.css.code.toString(); - const productionJS = compile(css, { - ...config.reactNativeCSS, + const { warnings: warningLevel, ...compilerOptions } = + config.reactNativeCSS ?? {}; + + const compiled = compile(css, { + ...compilerOptions, filename: filePath, projectRoot: projectRoot, - }).stylesheet(); + }); + + const productionJS = compiled.stylesheet(); + + // The compiler records every declaration it could not translate. This is the + // only place a real build can read them — nothing downstream of the + // transformer ever sees the compile result again. + reportCompilerWarnings(compiled.warnings(), { + filename: filePath, + projectRoot, + level: warningLevel, + }); data = Buffer.from(getNativeInjectionCode([], [productionJS])); diff --git a/src/metro/warnings.ts b/src/metro/warnings.ts new file mode 100644 index 00000000..7374d567 --- /dev/null +++ b/src/metro/warnings.ts @@ -0,0 +1,289 @@ +import { relative } from "path"; + +import type { CompilerWarnings } from "../compiler/compiler.types"; + +/** + * How a Metro build surfaces the compiler's advisory warnings. + * + * - `summary` (the default) prints one deduplicated, capped block per CSS file. + * - `verbose` prints the same block with every entry listed. + * - `none` prints nothing. + */ +export type WarningLevel = "none" | "summary" | "verbose"; + +/** + * The number of distinct entries a `summary` block lists per channel before it + * collapses the rest into a `(+N more)` count. + * + * Ten is a measured figure rather than a round one. Compiling the Tailwind + * corpus in `src/__tests__/vendor/tailwind` warns on 87 distinct properties, so + * an uncapped block would be a screen of output on the most common setup this + * package has; ten fits a terminal line and still names the properties a reader + * is most likely to recognise. + */ +const SUMMARY_ENTRY_LIMIT = 10; + +/** + * The number of distinct values a `summary` block lists for a single property. + * + * Separate from the entry limit because the two truncate different things: a + * property with six unusable values is one line, not six. + */ +const SUMMARY_VALUE_LIMIT = 5; + +/** The characters a `summary` block prints of any single value. */ +const SUMMARY_VALUE_LENGTH_LIMIT = 60; + +const PREFIX = "react-native-css"; + +/** + * The last message emitted for a file, keyed by its absolute path. + * + * This is the noise model, and it is deliberately not "print everything". + * + * Metro transforms a file whenever its contents change, and a Tailwind build + * rewrites its CSS output on nearly every source save — so a transformer that + * printed unconditionally would reprint an identical block on every keystroke + * for the whole session. Keying on the message means a block prints when the + * set of warnings for a file CHANGES, which is the only moment a reader learns + * something: the first compile, and every time a fix removes an entry or a new + * declaration adds one. + * + * The map is bounded by the number of CSS files in the project rather than by + * the number of transforms, and it lives per worker process, so a restarted + * bundler reprints. Nothing here decides correctness — a dropped entry costs a + * repeated line, never a wrong build. + */ +const lastReportedByFile = new Map(); + +/** + * Surface a compile's warnings on the terminal running the bundler. + * + * Writes through `console.warn`, which is what makes it reachable: Metro pipes + * each transform worker's stderr to the parent and its reporter prints the + * chunk (`WorkerFarm` forwards it as `worker_stderr_chunk`; `TerminalReporter` + * logs it). With `maxWorkers: 1` the worker is required in-band and the write + * lands on the bundler's own stderr. Neither path fails the build — these are + * advisory, and a dropped `float` is not a reason to refuse to bundle. + */ +export function reportCompilerWarnings( + warnings: CompilerWarnings, + options: { + filename: string; + projectRoot: string; + level?: WarningLevel | undefined; + }, +): void { + const { filename, projectRoot, level = "summary" } = options; + + if (level === "none") { + return; + } + + const message = formatCompilerWarnings(warnings, { + displayPath: toDisplayPath(filename, projectRoot), + verbose: level === "verbose", + }); + + if (message === undefined) { + // A file that stops warning must be able to warn again later, so the + // absence is recorded by forgetting it rather than by storing an empty + // message. + lastReportedByFile.delete(filename); + return; + } + + if (lastReportedByFile.get(filename) === message) { + return; + } + + lastReportedByFile.set(filename, message); + + console.warn(message); +} + +/** + * Render a compile's warnings as a single block, or `undefined` when the + * compile recorded none. + * + * The block is plain text with no ANSI escapes. Metro runs transformers in + * `jest-worker` children whose stdio is piped, so `process.stdout.isTTY` is + * unset there and the vendored `picocolors` would disable itself anyway — + * colour would appear only in the `maxWorkers: 1` in-band case, which is a + * worse outcome than never colouring at all. + */ +export function formatCompilerWarnings( + warnings: CompilerWarnings, + options: { displayPath: string; verbose?: boolean | undefined }, +): string | undefined { + const { displayPath, verbose = false } = options; + + const droppedCount = + (warnings.properties?.length ?? 0) + + (warnings.functions?.length ?? 0) + + Object.values(warnings.values ?? {}).reduce( + (total, entries) => total + entries.length, + 0, + ); + + if (droppedCount === 0) { + return undefined; + } + + const properties = limit(unique(warnings.properties), verbose); + const functions = limit(unique(warnings.functions), verbose); + const rendered = renderValues(warnings.values, verbose); + const values = limit(rendered.entries, verbose); + + const lines = [ + `${PREFIX}: ${displayPath} - ${droppedCount} ${pluralize(droppedCount, "declaration")} dropped, no React Native equivalent`, + ]; + + if (properties.shown.length > 0) { + lines.push( + ` properties: ${properties.shown.join(", ")}${suffix(properties.hidden)}`, + ); + } + + if (values.shown.length > 0) { + lines.push(` values: ${values.shown.join("; ")}${suffix(values.hidden)}`); + } + + if (functions.shown.length > 0) { + lines.push( + ` functions: ${functions.shown.join(", ")}${suffix(functions.hidden)}`, + ); + } + + if ( + properties.hidden + values.hidden + functions.hidden + rendered.hidden > + 0 + ) { + lines.push( + ` Set reactNativeCSS.warnings to "verbose" for the full list, or "none" to silence this.`, + ); + } + + return lines.join("\n"); +} + +/** + * The path a reader recognises: relative to the project root when the file is + * inside it, absolute when it is not. + * + * Separators are the host's. A Windows developer reads a Windows path, and + * every assertion over this value can build its expectation with `path.join` + * rather than hardcoding one host's shape. + */ +function toDisplayPath(filename: string, projectRoot: string): string { + const relativePath = relative(projectRoot, filename); + + return relativePath === "" || relativePath.startsWith("..") + ? filename + : relativePath; +} + +/** + * One entry per property that carried an unusable value, each listing that + * property's distinct values. + * + * Grouped rather than flattened because the two truncate differently: a + * property with six unusable values is one line worth reading, not six lines + * that push five other properties out of the block. + */ +function renderValues( + values: Record | undefined, + verbose: boolean, +): { entries: string[]; hidden: number } { + let hidden = 0; + const entries: string[] = []; + + for (const [property, recorded] of Object.entries(values ?? {}).sort( + ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0), + )) { + const distinct = unique(recorded.map(stringify)); + + if (distinct.length === 0) { + continue; + } + + const shown = verbose ? distinct : distinct.slice(0, SUMMARY_VALUE_LIMIT); + hidden += distinct.length - shown.length; + + const rendered = shown.map((value) => truncate(value, verbose)); + + entries.push( + `${property}: ${rendered.join(", ")}${suffix(distinct.length - shown.length)}`, + ); + } + + return { entries, hidden }; +} + +function limit(entries: T[], verbose: boolean) { + if (verbose || entries.length <= SUMMARY_ENTRY_LIMIT) { + return { shown: entries, hidden: 0 }; + } + + return { + shown: entries.slice(0, SUMMARY_ENTRY_LIMIT), + hidden: entries.length - SUMMARY_ENTRY_LIMIT, + }; +} + +function suffix(hidden: number): string { + return hidden > 0 ? ` (+${hidden} more)` : ""; +} + +/** + * Distinct entries in code-unit order. + * + * Not `localeCompare`: the order of this list is asserted by tests that run on + * three operating systems, and collation is an ICU-and-locale decision. Code + * units are the same everywhere, and for CSS identifiers they read the same as + * alphabetical. + */ +function unique(entries: string[] | undefined): string[] { + return entries ? [...new Set(entries)].sort() : []; +} + +/** + * `values` is `Record` because the compiler records whatever + * it could not translate: a string for most declarations, a number for + * `line-height`, and a serialized lightningcss node for an unresolved `calc()`. + * Rendering is therefore total rather than a cast. + */ +function stringify(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (value === null || typeof value !== "object") { + return String(value); + } + + try { + return JSON.stringify(value); + } catch { + // A warning must never be the thing that fails a build, and + // `JSON.stringify` throws on a circular value. + return "[unserializable]"; + } +} + +/** + * Keep one value from taking a whole terminal line. + * + * An unresolved `calc()` is recorded as its serialized lightningcss node, which + * runs to a few hundred characters — long enough to bury the other entries in + * the block it appears in. `verbose` prints it whole. + */ +function truncate(value: string, verbose: boolean): string { + return verbose || value.length <= SUMMARY_VALUE_LENGTH_LIMIT + ? value + : `${value.slice(0, SUMMARY_VALUE_LENGTH_LIMIT)}...`; +} + +function pluralize(count: number, noun: string): string { + return count === 1 ? noun : `${noun}s`; +} From 66827aab5444f37891933bb77ff28e894f8891dd Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Sun, 16 Aug 2026 20:21:13 +0300 Subject: [PATCH 7/8] feat(compiler): report the parse diagnostics lightningcss already produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request surfaces what the compiler knows it could not translate. It was silent about a fourth thing it also knows: input lightningcss could not parse. lightningcss has two classes of malformed CSS. One throws, and a throw is already loud. The other is recovered and reported through `result.warnings` with no flag needed — and both `lightningcss()` calls here discarded that return, the first by destructuring only `code` and the second by ignoring it entirely. The consequence is worse than a missing message, because of where the rule is lost. lightningcss passes the malformed sheet through verbatim; it is this package's own visitor that then finds nothing to extract. So the rule silently disappears and lightningcss's warning is the only evidence it ever existed: @unknown-thing { .b { color: blue } } .b vanishes, warnings() {} .a::wat { color: red } .a vanishes, warnings() {} Both are ordinary typos, and an unsupported PROPERTY in the same position does report — so the gap was specifically the syntax class. Only the FIRST pass is read. The second re-parses the first's output and, measured against both triggers, returns the identical message; reading it would add nothing but a duplicate to suppress. Warnings are collected in a `Set`. lightningcss emits one per occurrence and the message carries no line or column, so two copies of one mistake are a string the reader cannot tell apart from itself. Two DISTINCT mistakes still report twice, and the test asserts both halves. `OWN_AT_RULE_WARNINGS` filters the two at-rules this package defines — `@react-native` and `@nativeMapping`, the complete set from `atRules.ts`. lightningcss calls both unknown because they are ours, so reporting them would fire on every stylesheet this compiler is built to read. Tailwind is not a noise source here, measured rather than assumed. Every v4 authoring at-rule warns at the lightningcss layer, but none reach this compiler: `metro-transformer.ts` runs the Expo/Metro CSS worker first and compiles its output, by which point PostCSS has consumed them. Over 24 utility classes plus an inline `@theme` / `@utility` / `@custom-variant` / `@source` block, in both optimisation modes: zero surviving at-rules, zero warnings. Deliberately NOT paired with lightningcss's `errorRecovery`. That converts the throwing class into more of this one, which changes what compiles rather than what is reported — and it drops more than it reports: one probe kept a single warning while silently discarding two further rules. It belongs in its own change, argued on its own terms. --- src/__tests__/metro/warnings.test.ts | 83 ++++++++++++++++++++++++++++ src/compiler/compiler.ts | 44 ++++++++++++++- src/compiler/compiler.types.ts | 8 +++ src/compiler/stylesheet.ts | 17 ++++++ 4 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/__tests__/metro/warnings.test.ts b/src/__tests__/metro/warnings.test.ts index aef9f032..87b11c4e 100644 --- a/src/__tests__/metro/warnings.test.ts +++ b/src/__tests__/metro/warnings.test.ts @@ -344,3 +344,86 @@ describe("reportCompilerWarnings", () => { expect(reported()[0]).toContain(`react-native-css: ${filename} -`); }); }); + +/** + * The syntax channel — a diagnostic lightningcss produced and this compiler + * discarded. + * + * lightningcss has two classes of malformed input. One THROWS, and a throw is + * already loud. The other is recovered and reported through `result.warnings` + * with no `errorRecovery` flag needed, and that return value was dropped at + * both call sites in `compiler.ts` — so an ordinary typo silently deleted a + * rule while the feature whose whole subject is surfacing compiler warnings + * said nothing. + * + * Note where the rule is lost. lightningcss passes the malformed sheet through + * verbatim; it is this package's own visitor that finds nothing to extract. + * So lightningcss's warning is the only signal that anything went wrong. + */ +describe("the syntax channel", () => { + test("an unknown at-rule swallows a rule and now says so", () => { + // `.b` is inside the unknown at-rule and does not survive. The control + // below is what makes that a finding rather than an assumption. + const compiled = compile( + `@unknown-thing { .b { color: blue } }\n.c { color: green }`, + ); + + expect(compiled.stylesheet().s?.map(([name]) => name)).toStrictEqual(["c"]); + expect(compiled.warnings().syntax).toStrictEqual([ + "Unknown at rule: @unknown-thing", + ]); + }); + + test("an unrecognised pseudo-element does the same", () => { + const compiled = compile(`.a::wat { color: red }\n.c { color: green }`); + + expect(compiled.stylesheet().s?.map(([name]) => name)).toStrictEqual(["c"]); + expect(compiled.warnings().syntax?.length).toBeGreaterThan(0); + }); + + test("CONTROL — the same shape, spelled correctly, keeps both rules and warns nothing", () => { + // Without this the two tests above would pass against a compiler that had + // simply stopped emitting `.b`, which is the opposite of the fix. + const compiled = compile( + `@media (min-width: 1px) { .b { color: blue } }\n.c { color: green }`, + ); + + expect(compiled.stylesheet().s?.map(([name]) => name)).toStrictEqual([ + "b", + "c", + ]); + expect(compiled.warnings().syntax).toBeUndefined(); + }); + + test("CONTROL — this package's OWN at-rules are not reported as unknown", () => { + // `@react-native` and `@nativeMapping` are the two at-rules `atRules.ts` + // defines, and lightningcss calls both unknown because they are ours. A + // channel that reported them would fire on every stylesheet this compiler + // is designed to read. + expect(compile(`@react-native { }`).warnings().syntax).toBeUndefined(); + }); + + test("the same mistake made twice is reported once, two distinct ones twice", () => { + // Measured: lightningcss emits one warning per occurrence and the message + // carries no line or column, so a repeat is a second copy of a string the + // reader cannot tell apart from the first. Collapsing them is what keeps + // the channel readable — and the second assertion is what stops that + // collapse from swallowing a genuinely different diagnostic. + expect( + warningsFor( + `@unknown-a { .x { color: red } } +@unknown-a { .y { color: blue } }`, + ).syntax, + ).toStrictEqual(["Unknown at rule: @unknown-a"]); + + expect( + warningsFor( + `@unknown-a { .x { color: red } } +@unknown-b { .y { color: blue } }`, + ).syntax, + ).toStrictEqual([ + "Unknown at rule: @unknown-a", + "Unknown at rule: @unknown-b", + ]); + }); +}); diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 214cd615..f86c5c56 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -11,6 +11,7 @@ import { type PropertyRule, type Rule, type Visitor, + type Warning, } from "lightningcss"; import { maybeMutateReactNativeOptions, parsePropAtRule } from "./atRules"; @@ -46,6 +47,45 @@ const defaultLogger = debug("react-native-css:compiler"); * @param options - Compiler options * @returns A `ReactNativeCssStyleSheet` that can be passed to `StyleSheet.register` or used with a custom runtime */ +/** + * The two at-rules this package defines itself. + * + * lightningcss calls both unknown because they are ours — `@react-native` is a + * registered `customAtRules` entry and `@nativeMapping` is handled as an + * unknown rule (`atRules.ts`). Reporting them would fire the channel on every + * stylesheet this compiler is designed to read. + */ +const OWN_AT_RULE_WARNINGS = /^Unknown at rule: @(nativeMapping|react-native)$/u; + +/** + * Hand lightningcss's own parse diagnostics to the builder. + * + * These are the recovered-and-warned class: input lightningcss could not parse + * but did not throw over. It passes the malformed sheet through verbatim, so + * nothing fails — this package's visitor simply finds nothing to extract and + * the rule disappears. That makes this warning the ONLY signal the author has. + * + * Only the FIRST pass is read. The second re-parses the first's output, and + * measured against both reachable triggers it returns the identical message — + * so reading it would add nothing but a duplicate to suppress. An unknown + * at-rule and an unrecognised pseudo-element each warn once per pass, with the + * same text. + * + * Deliberately NOT paired with lightningcss's `errorRecovery` flag. That + * converts the throwing class into more of this one, which is a change to what + * compiles rather than a diagnostic — and it drops more than it reports. + */ +function reportSyntaxWarnings( + builder: StylesheetBuilder, + warnings: Warning[] | undefined, +): void { + for (const warning of warnings ?? []) { + if (!OWN_AT_RULE_WARNINGS.test(warning.message)) { + builder.addSyntaxWarning(warning.message); + } + } +} + export function compile(code: Buffer | string, options: CompilerOptions = {}) { const { logger = defaultLogger } = options; @@ -136,7 +176,7 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { }; } - const { code: firstPass } = lightningcss({ + const { code: firstPass, warnings: firstPassWarnings } = lightningcss({ code: typeof code === "string" ? new TextEncoder().encode(code) : code, include: Features.DoublePositionGradients | Features.ColorFunction, exclude: Features.VendorPrefixes, @@ -197,6 +237,8 @@ export function compile(code: Buffer | string, options: CompilerOptions = {}) { projectRoot: options.projectRoot ?? process.cwd(), }); + reportSyntaxWarnings(builder, firstPassWarnings); + return { stylesheet: () => builder.getNativeStyleSheet(), warnings: () => builder.getWarnings(), diff --git a/src/compiler/compiler.types.ts b/src/compiler/compiler.types.ts index 07689a9c..2639a99d 100644 --- a/src/compiler/compiler.types.ts +++ b/src/compiler/compiler.types.ts @@ -44,6 +44,14 @@ export interface CompilerWarnings { values?: Record; /** CSS functions with no React Native equivalent. */ functions?: string[]; + /** + * Declarations lightningcss could not parse at all. + * + * Distinct from `properties` and `values`, which are things this compiler + * understood and cannot express: a syntax warning is malformed CSS, so the + * reader's fix is in their stylesheet rather than in this package. + */ + syntax?: string[]; } /** diff --git a/src/compiler/stylesheet.ts b/src/compiler/stylesheet.ts index 28039746..41bdbf69 100644 --- a/src/compiler/stylesheet.ts +++ b/src/compiler/stylesheet.ts @@ -71,6 +71,7 @@ export class StylesheetBuilder { warningProperties: string[]; warningValues: Record; warningFunctions: string[]; + syntaxWarnings: Set; } = { ruleSets: {}, rem: 14, @@ -78,6 +79,7 @@ export class StylesheetBuilder { warningProperties: [], warningValues: {}, warningFunctions: [], + syntaxWarnings: new Set(), }, private selectors: SelectorList = [], ) {} @@ -228,6 +230,17 @@ export class StylesheetBuilder { } } + /** + * A diagnostic lightningcss produced while parsing. + * + * A `Set` rather than an array because the compiler runs lightningcss twice + * and the second pass re-parses the first pass's output, so one authoring + * mistake arrives from both. + */ + addSyntaxWarning(message: string): void { + this.shared.syntaxWarnings.add(message); + } + getWarnings(): CompilerWarnings { const result: CompilerWarnings = {}; @@ -243,6 +256,10 @@ export class StylesheetBuilder { result.functions = this.shared.warningFunctions; } + if (this.shared.syntaxWarnings.size) { + result.syntax = [...this.shared.syntaxWarnings]; + } + return result; } From 01eedddcc06e4602f9eebd49c34a9cb177df090c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Mon, 17 Aug 2026 00:00:15 +0300 Subject: [PATCH 8/8] fix(metro): render the syntax channel, and not as a missing equivalent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit collected syntax warnings end to end and rendered them nowhere. `formatCompilerWarnings` summed only `properties`, `functions` and `values`, so a stylesheet whose ONLY problem was malformed CSS returned `undefined` and the build printed nothing — the exact silence the channel was added to remove. It shipped green because every test for the channel read the PRODUCER (`compiled.warnings().syntax`) and none reached the formatter. A dead renderer under a live producer is invisible to that shape of test, which is why both new cases go through `formatCompilerWarnings`. Syntax is counted and reported SEPARATELY rather than folded into the dropped count, because the two are different claims. A dropped declaration is CSS this package cannot EXPRESS; a syntax warning is CSS lightningcss could not PARSE. They send a reader to different places — one to this package's limits, one to their own stylesheet — so a syntax warning under a header reading "no React Native equivalent" misdirects the fix. The header now carries whichever claims apply: - 1 rule could not be parsed syntax: Unknown at rule: @unknown-thing - 1 declaration dropped, no React Native equivalent; 1 rule could not be parsed syntax: Unknown at rule: @unknown-thing properties: float The overflow total counts the channel too, so `(+N more)` no longer under-reports when syntax entries are capped. Suite 1197 passed / 0 failed. Dropping the syntax line turns both new tests red. --- src/__tests__/metro/warnings.test.ts | 35 +++++++++++++++++++++++++ src/metro/warnings.ts | 39 ++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/__tests__/metro/warnings.test.ts b/src/__tests__/metro/warnings.test.ts index 87b11c4e..0d08d3fc 100644 --- a/src/__tests__/metro/warnings.test.ts +++ b/src/__tests__/metro/warnings.test.ts @@ -82,6 +82,41 @@ describe("formatCompilerWarnings", () => { ); }); + test("a syntax warning reaches the terminal at all", () => { + // The channel was produced end to end and rendered nowhere: `warnings.ts` + // summed only `properties`, `functions` and `values`, so a stylesheet whose + // ONLY problem was malformed CSS returned `undefined` and printed nothing. + // Every test for this channel read the producer, which is why it shipped + // green. + expect( + formatCompilerWarnings( + warningsFor(`@unknown-thing { .b { color: blue } }`), + { + displayPath: "src/global.css", + }, + ), + ).toContain("syntax: Unknown at rule: @unknown-thing"); + }); + + test("a syntax warning is NOT counted as a missing React Native equivalent", () => { + // The two are different claims and lead to different fixes. A dropped + // property is CSS this package cannot express; a syntax warning is CSS + // lightningcss could not parse, so the reader's fix is in their stylesheet. + // Folding one into the other's count puts it under a header that misdirects. + const formatted = formatCompilerWarnings( + warningsFor(`@unknown-thing { .b { color: blue } } +.a { float: left; }`), + { displayPath: "src/global.css" }, + ); + + expect(formatted).toContain( + "1 declaration dropped, no React Native equivalent", + ); + expect(formatted).toContain("properties: float"); + expect(formatted).toContain("could not be parsed"); + expect(formatted).toContain("syntax: Unknown at rule: @unknown-thing"); + }); + test("a compile that dropped nothing formats to nothing", () => { expect( formatCompilerWarnings(warningsFor(`.a { color: red; }`), { diff --git a/src/metro/warnings.ts b/src/metro/warnings.ts index 7374d567..56a1df00 100644 --- a/src/metro/warnings.ts +++ b/src/metro/warnings.ts @@ -126,7 +126,15 @@ export function formatCompilerWarnings( 0, ); - if (droppedCount === 0) { + // Counted apart from the three above, and reported as its own clause, because + // it is a different claim. A dropped declaration is CSS this package cannot + // EXPRESS; a syntax warning is CSS lightningcss could not PARSE. They send a + // reader to different places — one to this package's limits, one to their own + // stylesheet — so folding syntax into `droppedCount` would file it under a + // header that misdirects. + const syntaxCount = warnings.syntax?.length ?? 0; + + if (droppedCount === 0 && syntaxCount === 0) { return undefined; } @@ -134,10 +142,27 @@ export function formatCompilerWarnings( const functions = limit(unique(warnings.functions), verbose); const rendered = renderValues(warnings.values, verbose); const values = limit(rendered.entries, verbose); + const syntax = limit(unique(warnings.syntax), verbose); + + // One header carrying whichever claims apply, so a file with both problems + // reports both rather than the first one found. + const claims: string[] = []; + if (droppedCount > 0) { + claims.push( + `${droppedCount} ${pluralize(droppedCount, "declaration")} dropped, no React Native equivalent`, + ); + } + if (syntaxCount > 0) { + claims.push( + `${syntaxCount} ${pluralize(syntaxCount, "rule")} could not be parsed`, + ); + } + + const lines = [`${PREFIX}: ${displayPath} - ${claims.join("; ")}`]; - const lines = [ - `${PREFIX}: ${displayPath} - ${droppedCount} ${pluralize(droppedCount, "declaration")} dropped, no React Native equivalent`, - ]; + if (syntax.shown.length > 0) { + lines.push(` syntax: ${syntax.shown.join("; ")}${suffix(syntax.hidden)}`); + } if (properties.shown.length > 0) { lines.push( @@ -156,7 +181,11 @@ export function formatCompilerWarnings( } if ( - properties.hidden + values.hidden + functions.hidden + rendered.hidden > + properties.hidden + + values.hidden + + functions.hidden + + rendered.hidden + + syntax.hidden > 0 ) { lines.push(