From 0f723921229eedf20a6767c65c6bec77d705ddec Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 19:09:06 +0300 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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) {