diff --git a/.changeset/short-eyes-fix.md b/.changeset/short-eyes-fix.md new file mode 100644 index 000000000..a90cbf188 --- /dev/null +++ b/.changeset/short-eyes-fix.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/tools-react-native": patch +--- + +Decouple `getAvailablePlatforms()` from `@react-native-community/cli` diff --git a/.changeset/strong-cups-sparkle.md b/.changeset/strong-cups-sparkle.md new file mode 100644 index 000000000..487d29e5e --- /dev/null +++ b/.changeset/strong-cups-sparkle.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/jest-preset": patch +--- + +Support ESM config files diff --git a/package.json b/package.json index d54455d72..a2984bff1 100644 --- a/package.json +++ b/package.json @@ -147,11 +147,6 @@ "tsx" ] }, - "packages/eslint-plugin": { - "ignoreDependencies": [ - "eslint-plugin-react-hooks" - ] - }, "packages/jest-preset": { "ignoreDependencies": [ "react", diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 923ca0eb4..fd055503b 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -54,7 +54,6 @@ }, "devDependencies": { "@rnx-kit/eslint-config": "*", - "@rnx-kit/jest-preset": "*", "@rnx-kit/scripts": "*", "@rnx-kit/tsconfig": "*", "@types/eslint": "^9.0.0", @@ -73,6 +72,6 @@ "node": ">=16.17" }, "jest": { - "preset": "@rnx-kit/jest-preset/private" + "preset": "../jest-preset/private/jest-preset.js" } } diff --git a/packages/jest-preset/package.json b/packages/jest-preset/package.json index 13134a2cd..c38cd124e 100644 --- a/packages/jest-preset/package.json +++ b/packages/jest-preset/package.json @@ -28,6 +28,7 @@ "@babel/core": "^7.0.0", "@babel/preset-env": "^7.0.0", "@babel/preset-typescript": "^7.0.0", + "@rnx-kit/tools-react-native": "^2.0.2", "find-up": "^5.0.0" }, "peerDependencies": { diff --git a/packages/jest-preset/src/index.js b/packages/jest-preset/src/index.js index d2ac8d4e6..954e41925 100644 --- a/packages/jest-preset/src/index.js +++ b/packages/jest-preset/src/index.js @@ -1,5 +1,5 @@ const findUp = require("find-up"); -const path = require("path"); +const path = require("node:path"); /** * @typedef {import("@jest/types").Config.HasteConfig} HasteConfig @@ -9,24 +9,6 @@ const path = require("path"); * @typedef {[string | undefined, string | undefined]} PlatformPath */ -/** - * Resolve the path to a dependency given a chain of dependencies leading up to - * it. - * - * Note: This is a copy of the function in `@rnx-kit/tools-node` to avoid - * circular dependency. - * - * @param {string[]} chain Chain of dependencies leading up to the target dependency. - * @param {string=} startDir Optional starting directory for the search. If not given, the current directory is used. - * @returns Path to the final dependency's directory. - */ -function resolveDependencyChain(chain, startDir = process.cwd()) { - return chain.reduce((startDir, module) => { - const p = require.resolve(`${module}/package.json`, { paths: [startDir] }); - return path.dirname(p); - }, startDir); -} - /** * Returns the current package directory. * @returns {string | undefined} @@ -47,15 +29,26 @@ function getReactNativePlatformPath(rootDir = getPackageDirectory()) { throw new Error("Failed to resolve current package root"); } - const fs = require("fs"); + // This should only throw because we haven't built `tools-react-native` yet, + // which can happen if we're running repo-wide tools (like Knip). + const readReactNativeConfig = (() => { + try { + const { + readReactNativeConfig, + } = require("@rnx-kit/tools-react-native/context"); + return readReactNativeConfig; + } catch (_) { + return () => undefined; + } + })(); - const rnConfigPath = path.join(rootDir, "react-native.config.js"); - if (!fs.existsSync(rnConfigPath)) { + const config = readReactNativeConfig(rootDir); + if (!config) { return [undefined, undefined]; } - const { platforms, reactNativePath } = require(rnConfigPath); - if (reactNativePath) { + const { platforms, reactNativePath } = config; + if (reactNativePath && typeof reactNativePath === "string") { const resolvedPath = /^\.?\.[/\\]/.test(reactNativePath) ? path.resolve(rootDir, reactNativePath) : path.dirname( @@ -68,15 +61,16 @@ function getReactNativePlatformPath(rootDir = getPackageDirectory()) { } } - if (platforms) { - const names = Object.keys(platforms).filter( - (name) => typeof platforms[name].npmPackageName === "string" + if (platforms && typeof platforms === "object") { + const names = Object.entries(platforms).filter( + ([, info]) => typeof info.npmPackageName === "string" ); if (names.length > 1) { - console.warn(`Multiple platforms found; picking the first one: ${names}`); + const found = names.map(([key]) => key).join(", "); + console.warn(`Multiple platforms found; picking the first one: ${found}`); } - return [names[0], rootDir]; + return [names[0][0], rootDir]; } console.warn("No platforms found"); @@ -96,25 +90,13 @@ function getTargetPlatform(defaultPlatform, searchPaths) { return getReactNativePlatformPath(); } - /** @type {(config?: {projectRoot?: string; selectedPlatform?: string; }) => CLIConfig} */ - const loadConfig = (() => { - const rnCliPath = resolveDependencyChain([ - "react-native", - "@react-native-community/cli", - ]); - return ( - require(rnCliPath).loadConfig || - require(`${rnCliPath}/build/tools/config`).default - ); - })(); - - // .length on a function returns the number of formal parameters. - // fixes https://github.com/react-native-community/cli/pull/2379 changing the number of parameters. - const platforms = - loadConfig.length == 1 ? loadConfig({}).platforms : loadConfig().platforms; + const { + getAvailablePlatforms, + } = require("@rnx-kit/tools-react-native/platform"); - const targetPlatformConfig = platforms[defaultPlatform]; - if (!targetPlatformConfig) { + const platforms = getAvailablePlatforms(searchPaths.paths[0]); + const npmPackageName = platforms[defaultPlatform]; + if (typeof npmPackageName !== "string") { const availablePlatforms = Object.keys(platforms).join(", "); throw new Error( `'${defaultPlatform}' was not found among available platforms: ${availablePlatforms}` @@ -122,7 +104,6 @@ function getTargetPlatform(defaultPlatform, searchPaths) { } // `npmPackageName` is unset if target platform is in core. - const { npmPackageName } = targetPlatformConfig; return [ defaultPlatform, npmPackageName diff --git a/packages/tools-react-native/context.d.ts b/packages/tools-react-native/context.d.ts index f4561e20c..b7486c591 100644 --- a/packages/tools-react-native/context.d.ts +++ b/packages/tools-react-native/context.d.ts @@ -1,5 +1,6 @@ export { loadContext, loadContextAsync, + readReactNativeConfig, resolveCommunityCLI, } from "./lib/context"; diff --git a/packages/tools-react-native/src/cache.ts b/packages/tools-react-native/src/cache.ts index a5c074186..bb3585ad7 100644 --- a/packages/tools-react-native/src/cache.ts +++ b/packages/tools-react-native/src/cache.ts @@ -1,8 +1,9 @@ import type { Config } from "@react-native-community/cli-types"; import { findUp } from "@rnx-kit/tools-node/path"; -import * as crypto from "crypto"; -import * as nodefs from "fs"; -import * as path from "path"; +import * as crypto from "node:crypto"; +import * as nodefs from "node:fs"; +import * as path from "node:path"; +import { REACT_NATIVE_CONFIG_FILES } from "./context"; const HASH_ALGO = "sha256"; const UTF8 = { encoding: "utf-8" as const }; @@ -45,7 +46,7 @@ function updateHash( export function getCurrentState(projectRoot: string): string { const sha2 = crypto.createHash(HASH_ALGO); - const configFiles = ["package.json", "react-native.config.js"]; + const configFiles = ["package.json", ...REACT_NATIVE_CONFIG_FILES]; updateHash(sha2, configFiles, projectRoot, "all"); const lockfiles = [ diff --git a/packages/tools-react-native/src/context.ts b/packages/tools-react-native/src/context.ts index d34be3b8d..6257c3ec9 100644 --- a/packages/tools-react-native/src/context.ts +++ b/packages/tools-react-native/src/context.ts @@ -4,6 +4,9 @@ import { findPackageDependencyDir, readPackage, } from "@rnx-kit/tools-node/package"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { getCurrentState, getSavedState, @@ -15,6 +18,13 @@ import { // `react-native`. Consumers have to take a direct dependency on CLI instead. const RN_CLI_DECOUPLED = 76; +export const REACT_NATIVE_CONFIG_FILES = [ + "react-native.config.ts", + "react-native.config.mjs", + "react-native.config.cjs", + "react-native.config.js", +]; + function toNumber(version: string): number { const [major, minor = 0] = version.split("."); return Number(major) * 1000 + Number(minor); @@ -115,3 +125,35 @@ export async function loadContextAsync( saveConfigToCache(projectRoot, state, config); return config; } + +export function readReactNativeConfig( + packageDir: string, + cwd = process.cwd() +): Record | undefined { + for (const configFile of REACT_NATIVE_CONFIG_FILES) { + const configPath = path.join(packageDir, configFile); + if (fs.existsSync(configPath)) { + const url = + process.platform === "win32" + ? `file://${configPath.replaceAll("\\", "/")}` + : configPath; + const args = [ + "--no-warnings", + "--eval", + `import("${url}").then((config) => console.log(JSON.stringify(config.default ?? config)));`, + ]; + + const { stderr, stdout } = spawnSync(process.argv0, args, { cwd }); + + const errors = stderr?.toString().trim(); + if (errors) { + console.error(`${configPath}: ${errors}`); + } + + const json = stdout?.toString().trim(); + return json ? JSON.parse(json) : undefined; + } + } + + return undefined; +} diff --git a/packages/tools-react-native/src/index.ts b/packages/tools-react-native/src/index.ts index 986fc8bd6..6ddb941b7 100644 --- a/packages/tools-react-native/src/index.ts +++ b/packages/tools-react-native/src/index.ts @@ -1,4 +1,9 @@ -export { loadContext, loadContextAsync, resolveCommunityCLI } from "./context"; +export { + loadContext, + loadContextAsync, + readReactNativeConfig, + resolveCommunityCLI, +} from "./context"; export { findMetroPath, getMetroVersion, diff --git a/packages/tools-react-native/src/platform.ts b/packages/tools-react-native/src/platform.ts index a0cb327b2..35b846730 100644 --- a/packages/tools-react-native/src/platform.ts +++ b/packages/tools-react-native/src/platform.ts @@ -1,7 +1,7 @@ import { findPackageDependencyDir } from "@rnx-kit/tools-node/package"; -import * as fs from "fs"; -import * as path from "path"; -import { loadContext } from "./context"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { readReactNativeConfig } from "./context"; /** * List of supported react-native platforms. @@ -31,19 +31,15 @@ export function expandPlatformExtensions( } /** - * Returns a map of available React Native platforms. - * - * Note: This is used only when `@react-native-community/cli-config` is not - * available. This function will be dropped when 0.68 is no longer supported. - * + * Returns a map of available React Native platforms. The result is NOT cached. * @param startDir The directory to look for react-native platforms from * @param platformMap A platform-to-npm-package map of known packages * @returns A platform-to-npm-package map, excluding "core" platforms. */ -function getAvailablePlatformsCompat( +export function getAvailablePlatformsUncached( startDir = process.cwd(), platformMap: Record = { android: "", ios: "" } -) { +): typeof platformMap { const packageJson = path.join(startDir, "package.json"); if (!fs.existsSync(packageJson)) { const parent = path.dirname(startDir); @@ -52,7 +48,6 @@ function getAvailablePlatformsCompat( : getAvailablePlatformsUncached(path.dirname(startDir), platformMap); } - const options = { startDir }; const { dependencies, peerDependencies, devDependencies } = JSON.parse( fs.readFileSync(packageJson, { encoding: "utf-8" }) ); @@ -60,37 +55,47 @@ function getAvailablePlatformsCompat( const packages = new Set( dependencies ? Object.keys(dependencies) : [] ); - if (peerDependencies) { - Object.keys(peerDependencies).forEach((pkg) => packages.add(pkg)); - } - if (devDependencies) { - Object.keys(devDependencies).forEach((pkg) => packages.add(pkg)); + for (const deps of [peerDependencies, devDependencies]) { + if (deps) { + for (const pkg of Object.keys(deps)) { + packages.add(pkg); + } + } } - packages.forEach((pkgName) => { - const pkgPath = findPackageDependencyDir(pkgName, options); + const recordPlatformPackage = (pkgPath: string | undefined) => { if (!pkgPath) { return; } - const configPath = path.join(pkgPath, "react-native.config.js"); - if (fs.existsSync(configPath)) { - try { - const { platforms } = require(configPath); - if (platforms) { - Object.keys(platforms).forEach((platform) => { - if (typeof platformMap[platform] === "undefined") { - const { npmPackageName } = platforms[platform]; - if (npmPackageName) { - platformMap[platform] = npmPackageName; - } - } - }); + const manifest = readReactNativeConfig(pkgPath, startDir); + if (!manifest) { + return; + } + + const { platforms } = manifest; + if (!platforms || typeof platforms !== "object") { + return; + } + + for (const [platform, info] of Object.entries(platforms)) { + if (typeof platformMap[platform] === "undefined") { + const { npmPackageName } = info; + if (npmPackageName) { + platformMap[platform] = npmPackageName; } - } catch (_) { - // ignore } } + }; + + recordPlatformPackage(startDir); + + const options = { startDir }; + packages.forEach((pkgName) => { + const pkgPath = findPackageDependencyDir(pkgName, options); + if (pkgPath) { + recordPlatformPackage(pkgPath); + } }); return platformMap; @@ -102,44 +107,18 @@ function getAvailablePlatformsCompat( * @returns A platform-to-npm-package map, excluding "core" platforms. */ export const getAvailablePlatforms = (() => { + const isTesting = + Boolean(process.env.NODE_TEST_CONTEXT) || process.env.NODE_ENV === "test"; + let platformMap: Record | undefined = undefined; return (startDir = process.cwd()) => { - if (!platformMap) { - try { - platformMap = getAvailablePlatformsUncached(startDir); - } catch (_) { - // This only happens when `@react-native-community/cli-config` is not - // available. This path may be dropped when 0.68 is no longer - // supported. - platformMap = getAvailablePlatformsCompat(startDir); - } + if (!platformMap || isTesting) { + platformMap = getAvailablePlatformsUncached(startDir); } return platformMap; }; })(); -/** - * Returns a map of available React Native platforms. The result is NOT cached. - * @param startDir The directory to look for react-native platforms from - * @param platformMap A platform-to-npm-package map of known packages - * @returns A platform-to-npm-package map, excluding "core" platforms. - */ -export function getAvailablePlatformsUncached( - startDir = process.cwd(), - platformMap: Record = { android: "", ios: "" } -) { - const { platforms } = loadContext(startDir); - if (typeof platforms === "object" && platforms) { - for (const [name, info] of Object.entries(platforms)) { - const { npmPackageName } = info; - if (npmPackageName) { - platformMap[name] = npmPackageName; - } - } - } - return platformMap; -} - /** * Returns file extensions that can be mapped to the target platform. * @param platform The platform to retrieve extensions for diff --git a/packages/tools-react-native/test/platform.test.ts b/packages/tools-react-native/test/platform.test.ts index f1e1cbcb1..848dfc602 100644 --- a/packages/tools-react-native/test/platform.test.ts +++ b/packages/tools-react-native/test/platform.test.ts @@ -46,8 +46,7 @@ describe("React Native > Platform", () => { __dirname, "__fixtures__", "available-platforms", - "node_modules", - "react-native" + "node_modules" ); deepEqual(getAvailablePlatformsUncached(fixture), { android: "", diff --git a/yarn.lock b/yarn.lock index 7bb800707..8c8565128 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3956,7 +3956,6 @@ __metadata: dependencies: "@react-native/eslint-plugin": "npm:^0.75.0" "@rnx-kit/eslint-config": "npm:*" - "@rnx-kit/jest-preset": "npm:*" "@rnx-kit/scripts": "npm:*" "@rnx-kit/tsconfig": "npm:*" "@types/eslint": "npm:^9.0.0" @@ -3997,6 +3996,7 @@ __metadata: "@react-native-community/cli-types": "npm:^14.0.0" "@react-native/babel-preset": "npm:^0.75.0" "@rnx-kit/scripts": "npm:*" + "@rnx-kit/tools-react-native": "npm:^2.0.2" "@rnx-kit/tsconfig": "npm:*" "@types/jest": "npm:^29.2.1" "@types/node": "npm:^20.0.0" @@ -4586,7 +4586,7 @@ __metadata: languageName: unknown linkType: soft -"@rnx-kit/tools-react-native@npm:*, @rnx-kit/tools-react-native@npm:^2.0.0, @rnx-kit/tools-react-native@workspace:packages/tools-react-native": +"@rnx-kit/tools-react-native@npm:*, @rnx-kit/tools-react-native@npm:^2.0.0, @rnx-kit/tools-react-native@npm:^2.0.2, @rnx-kit/tools-react-native@workspace:packages/tools-react-native": version: 0.0.0-use.local resolution: "@rnx-kit/tools-react-native@workspace:packages/tools-react-native" dependencies: