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 new file mode 100644 index 00000000..2b2e47d4 --- /dev/null +++ b/src/__tests__/babel/helpers.test.ts @@ -0,0 +1,220 @@ +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", () => { + // 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. + 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(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"; + + 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", + }, + ]; + + test.each(cases)("$name", ({ source, expected }) => { + expect(withoutDrive(resolveImportSource(filename, source))).toBe(expected); + }); + + 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("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 = ""; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "react-native-css-root-")); + }); + + 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); + }); + + 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 c66f630c..fe961c53 100644 --- a/src/babel/helpers.ts +++ b/src/babel/helpers.ts @@ -1,3 +1,6 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join, resolve, sep } from "path"; + import tBabelTypes, { type CallExpression } from "@babel/types"; export type BabelTypes = typeof tBabelTypes; @@ -10,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( @@ -38,3 +46,80 @@ export function getInteropRequireDefaultSource( return requireArg.value; } + +/** + * Rewrite a host path's separators as POSIX ones. + * + * `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, hostSeparator: string): string { + return hostSeparator === "/" ? path : path.replaceAll("\\", "/"); +} + +/** + * Resolve a relative import source against the file that contains it, in POSIX + * separators. + * + * Two properties of the result are load-bearing, and both belong here rather + * than at the call sites: + * + * - **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 resolveImportSource(filename: string, source: string): string { + return toPosixPath(resolve(dirname(filename), source), sep); +} + +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 1bacf5a0..6262caf1 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 { resolveImportSource } from "./helpers"; type BabelTypes = typeof tBabelTypes; function parseReactNativeWebSource(source: string, filename: string) { if (source.startsWith(".")) { - source = resolve(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 2522a848..ab30c134 100644 --- a/src/babel/react-native.ts +++ b/src/babel/react-native.ts @@ -1,5 +1,3 @@ -import { dirname, 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 { resolveImportSource } from "./helpers"; type BabelTypes = typeof tBabelTypes; function parseReactNativeSource(source: string, filename: string) { if (source.startsWith(".")) { - source = resolve(dirname(filename), source); + source = resolveImportSource(filename, source); const internalPath = source.split("react-native/Libraries/Components/")[1]; if (!internalPath) {