diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index 20a2be8d5fd6..42ac73ae2a62 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -31,9 +31,11 @@ "metro": "^0.80.3", "metro-config": "^0.80.3", "metro-core": "^0.80.3", + "micromatch": "^4.0.5", "node-fetch": "^2.2.0", "querystring": "^0.2.1", - "readline": "^1.3.0" + "readline": "^1.3.0", + "yaml": "^2.3.4" }, "devDependencies": { "metro-resolver": "^0.80.3" diff --git a/packages/community-cli-plugin/src/utils/__tests__/getWorkspaceRoot-test.js b/packages/community-cli-plugin/src/utils/__tests__/getWorkspaceRoot-test.js new file mode 100644 index 000000000000..6d73456d9b2c --- /dev/null +++ b/packages/community-cli-plugin/src/utils/__tests__/getWorkspaceRoot-test.js @@ -0,0 +1,108 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import {getWorkspaceRoot} from '../getWorkspaceRoot'; +import {createTempPackage} from './temporary-package'; +import fs from 'fs'; +import path from 'path'; + +describe('getWorkspaceRoot', () => { + test('returns null if not in a workspace', () => { + const tempPackagePath = createTempPackage({ + name: 'my-app', + }); + expect(getWorkspaceRoot(tempPackagePath)).toBe(null); + }); + + test('supports an npm workspace', () => { + const tempWorkspaceRootPath = createTempPackage({ + name: 'package-root', + workspaces: ['packages/my-app', 'packages/my-lib'], + }); + const tempPackagePath = createTempPackage( + { + name: 'my-app', + }, + path.join(tempWorkspaceRootPath, 'packages', 'my-app'), + ); + expect(getWorkspaceRoot(tempPackagePath)).toBe(tempWorkspaceRootPath); + }); + + test('supports a yarn workspace', () => { + const tempWorkspaceRootPath = createTempPackage({ + name: 'package-root', + workspaces: ['packages/*'], + }); + const tempPackagePath = createTempPackage( + { + name: 'my-app', + }, + path.join(tempWorkspaceRootPath, 'packages', 'my-app'), + ); + expect(getWorkspaceRoot(tempPackagePath)).toBe(tempWorkspaceRootPath); + }); + + test('supports a yarn workspace (object style)', () => { + const tempWorkspaceRootPath = createTempPackage({ + name: 'package-root', + workspaces: { + packages: ['packages/*'], + }, + }); + const tempPackagePath = createTempPackage( + { + name: 'my-app', + }, + path.join(tempWorkspaceRootPath, 'packages', 'my-app'), + ); + expect(getWorkspaceRoot(tempPackagePath)).toBe(tempWorkspaceRootPath); + }); + + test('supports a pnpm workspace', () => { + const tempWorkspaceRootPath = createTempPackage({ + name: 'package-root', + }); + // Create the pnpm workspace configuration (see https://pnpm.io/pnpm-workspace_yaml) + const workspacesConfig = 'packages: ["packages/*"]'; + fs.writeFileSync( + path.join(tempWorkspaceRootPath, 'pnpm-workspace.yaml'), + workspacesConfig, + 'utf8', + ); + const tempPackagePath = createTempPackage( + { + name: 'my-app', + }, + path.join(tempWorkspaceRootPath, 'packages', 'my-app'), + ); + expect(getWorkspaceRoot(tempPackagePath)).toBe(tempWorkspaceRootPath); + }); + + test('supports a pnpm workspace exclusion', () => { + const tempWorkspaceRootPath = createTempPackage({ + name: 'package-root', + }); + // Create the pnpm workspace configuration (see https://pnpm.io/pnpm-workspace_yaml) + const workspacesConfig = 'packages: ["packages/*", "!packages/*-app"]'; + fs.writeFileSync( + path.join(tempWorkspaceRootPath, 'pnpm-workspace.yaml'), + workspacesConfig, + 'utf8', + ); + const tempPackagePath = createTempPackage( + { + name: 'my-app', + }, + path.join(tempWorkspaceRootPath, 'packages', 'my-app'), + ); + expect(getWorkspaceRoot(tempPackagePath)).toBe(null); + }); +}); diff --git a/packages/community-cli-plugin/src/utils/__tests__/loadMetroConfig-test.js b/packages/community-cli-plugin/src/utils/__tests__/loadMetroConfig-test.js new file mode 100644 index 000000000000..4610e874cb51 --- /dev/null +++ b/packages/community-cli-plugin/src/utils/__tests__/loadMetroConfig-test.js @@ -0,0 +1,133 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import loadMetroConfig from '../loadMetroConfig'; +import {createTempPackage} from './temporary-package'; +import fs from 'fs'; +import path from 'path'; + +/** + * Resolves a package by its name and creates a symbolic link in a node_modules directory + */ +function createPackageLink(nodeModulesPath: string, packageName: string) { + // Resolve the packages path on disk + const destinationPath = path.dirname(require.resolve(packageName)); + const packageScope = packageName.includes('/') + ? packageName.split('/')[0] + : undefined; + + // Create a parent directory for a @scoped package + if (typeof packageScope === 'string') { + fs.mkdirSync(path.join(nodeModulesPath, packageScope)); + } + + const sourcePath = path.join(nodeModulesPath, packageName); + fs.symlinkSync(destinationPath, sourcePath); +} + +function createTempConfig(projectRoot: string, metroConfig: {...}) { + const content = ` + const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); + const config = ${JSON.stringify(metroConfig)}; + module.exports = mergeConfig(getDefaultConfig(__dirname), config); + `; + const configPath = path.join(projectRoot, 'metro.config.js'); + fs.writeFileSync(configPath, content, 'utf8'); + + const nodeModulesPath = path.join(projectRoot, 'node_modules'); + fs.mkdirSync(nodeModulesPath); + // Create a symbolic link to the '@react-native/metro-config' package used by the config + createPackageLink(nodeModulesPath, '@react-native/metro-config'); +} + +const configLoadingContext = { + reactNativePath: path.dirname(require.resolve('react-native/package.json')), + platforms: { + ios: {npmPackageName: 'temp-package'}, + android: {npmPackageName: 'temp-package'}, + }, +}; + +describe('loadMetroConfig', () => { + test('loads an empty config', async () => { + const rootPath = createTempPackage({name: 'temp-app'}); + createTempConfig(rootPath, {}); + + const loadedConfig = await loadMetroConfig({ + root: rootPath, + ...configLoadingContext, + }); + expect(loadedConfig.projectRoot).toEqual(rootPath); + expect(loadedConfig.watchFolders).toEqual([rootPath]); + }); + + test('loads watch folders', async () => { + const rootPath = createTempPackage({ + name: 'temp-app', + }); + createTempConfig(rootPath, { + watchFolders: ['somewhere-else'], + }); + + const loadedConfig = await loadMetroConfig({ + root: rootPath, + ...configLoadingContext, + }); + expect(loadedConfig.projectRoot).toEqual(rootPath); + expect(loadedConfig.watchFolders).toEqual([rootPath, 'somewhere-else']); + }); + + test('includes an npm workspace root if no watchFolders are defined', async () => { + const rootPath = createTempPackage({ + name: 'temp-root', + workspaces: ['packages/temp-app'], + }); + // Create a config inside a sub-package + const projectRoot = createTempPackage( + { + name: 'temp-app', + }, + path.join(rootPath, 'packages', 'temp-app'), + ); + createTempConfig(projectRoot, {}); + + const loadedConfig = await loadMetroConfig({ + root: projectRoot, + ...configLoadingContext, + }); + expect(loadedConfig.projectRoot).toEqual(projectRoot); + expect(loadedConfig.watchFolders).toEqual([projectRoot, rootPath]); + }); + + test('does not resolve an npm workspace root if watchFolders are defined', async () => { + const rootPath = createTempPackage({ + name: 'temp-root', + workspaces: ['packages/temp-app'], + }); + // Create a config inside a sub-package + const projectRoot = createTempPackage( + { + name: 'temp-app', + }, + path.join(rootPath, 'packages', 'temp-app'), + ); + createTempConfig(projectRoot, { + watchFolders: [], + }); + + const loadedConfig = await loadMetroConfig({ + root: projectRoot, + ...configLoadingContext, + }); + expect(loadedConfig.projectRoot).toEqual(projectRoot); + expect(loadedConfig.watchFolders).toEqual([projectRoot]); + }); +}); diff --git a/packages/community-cli-plugin/src/utils/__tests__/temporary-package.js b/packages/community-cli-plugin/src/utils/__tests__/temporary-package.js new file mode 100644 index 000000000000..6f2ebf69c884 --- /dev/null +++ b/packages/community-cli-plugin/src/utils/__tests__/temporary-package.js @@ -0,0 +1,33 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +export function createTempPackage( + packageJson: {...}, + packagePath: string = fs.mkdtempSync( + path.join(os.tmpdir(), 'rn-metro-config-test-'), + ), +): string { + fs.mkdirSync(packagePath, {recursive: true}); + if (typeof packageJson === 'object') { + fs.writeFileSync( + path.join(packagePath, 'package.json'), + JSON.stringify(packageJson), + 'utf8', + ); + } + + // Wrapping path in realpath to resolve any symlinks introduced by mkdtemp + return fs.realpathSync(packagePath); +} diff --git a/packages/community-cli-plugin/src/utils/getWorkspaceRoot.js b/packages/community-cli-plugin/src/utils/getWorkspaceRoot.js new file mode 100644 index 000000000000..3101adde2381 --- /dev/null +++ b/packages/community-cli-plugin/src/utils/getWorkspaceRoot.js @@ -0,0 +1,86 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +import {logger} from '@react-native-community/cli-tools'; +import fs from 'fs'; +import micromatch from 'micromatch'; +import path from 'path'; +import yaml from 'yaml'; + +/** + * Get the workspace paths from the path of a potential workspace root. + * + * This supports: + * - [npm workspaces](https://docs.npmjs.com/cli/v10/using-npm/workspaces) + * - [yarn workspaces](https://yarnpkg.com/features/workspaces) + * - [pnpm workspaces](https://pnpm.io/workspaces) + */ +function getWorkspacePaths(packagePath: string): Array { + try { + // Checking pnpm workspaces first + const pnpmWorkspacePath = path.resolve(packagePath, 'pnpm-workspace.yaml'); + if (fs.existsSync(pnpmWorkspacePath)) { + const pnpmWorkspaceConfig = yaml.parse( + fs.readFileSync(pnpmWorkspacePath, 'utf8'), + ); + if ( + typeof pnpmWorkspaceConfig === 'object' && + Array.isArray(pnpmWorkspaceConfig.packages) + ) { + return pnpmWorkspaceConfig.packages; + } + } + // Falling back to npm / yarn workspaces + const packageJsonPath = path.resolve(packagePath, 'package.json'); + const {workspaces} = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + if (Array.isArray(workspaces)) { + return workspaces; + } else if ( + typeof workspaces === 'object' && + Array.isArray(workspaces.packages) + ) { + // An alternative way for yarn to declare workspace packages + return workspaces.packages; + } + } catch (err) { + if (err.code !== 'ENOENT') { + logger.debug(`Failed getting workspace root from ${packagePath}: ${err}`); + } + } + return []; +} + +/** + * Resolves the root of an npm or yarn workspace, by traversing the file tree + * upwards from a `candidatePath` in the search for + * - a directory with a package.json + * - which has a `workspaces` array of strings + * - which (possibly via a glob) includes the project root + */ +export function getWorkspaceRoot( + projectRoot: string, + candidatePath: string = projectRoot, +): ?string { + const workspacePaths = getWorkspacePaths(candidatePath); + // If one of the workspaces match the project root, this is the workspace root + // Note: While npm workspaces doesn't currently support globs, yarn does, which is why we use micromatch + const relativePath = path.relative(candidatePath, projectRoot); + // Using this instead of `micromatch.isMatch` to enable excluding patterns + if (micromatch([relativePath], workspacePaths).length > 0) { + return candidatePath; + } + // Try one level up + const parentDir = path.dirname(candidatePath); + if (parentDir !== candidatePath) { + return getWorkspaceRoot(projectRoot, parentDir); + } + return null; +} diff --git a/packages/community-cli-plugin/src/utils/loadMetroConfig.js b/packages/community-cli-plugin/src/utils/loadMetroConfig.js index 44d0d275cb0e..8c471a1e0de7 100644 --- a/packages/community-cli-plugin/src/utils/loadMetroConfig.js +++ b/packages/community-cli-plugin/src/utils/loadMetroConfig.js @@ -12,6 +12,7 @@ import type {Config} from '@react-native-community/cli-types'; import type {ConfigT, InputConfigT, YargArguments} from 'metro-config'; +import {getWorkspaceRoot} from './getWorkspaceRoot'; import {reactNativePlatformResolver} from './metroPlatformResolver'; import {CLIError, logger} from '@react-native-community/cli-tools'; import {loadConfig, mergeConfig, resolveConfig} from 'metro-config'; @@ -53,7 +54,20 @@ function getOverrideConfig( ); } - return { + // Always include the project root as a watch folder, since Metro expects this + const watchFolders = [config.projectRoot]; + + if (typeof config.watchFolders !== 'undefined') { + watchFolders.push(...config.watchFolders); + } else { + // Fallback to inferring a workspace root + const workspaceRoot = getWorkspaceRoot(ctx.root); + if (typeof workspaceRoot === 'string') { + watchFolders.push(workspaceRoot); + } + } + + const overrides: InputConfigT = { resolver, serializer: { // We can include multiple copies of InitializeCore here because metro will @@ -70,7 +84,10 @@ function getOverrideConfig( ), ], }, + watchFolders, }; + + return overrides; } /** @@ -107,10 +124,16 @@ This warning will be removed in future (https://github.com/facebook/metro/issues } } - const config = await loadConfig({ - cwd, - ...options, - }); + const config = await loadConfig( + { + cwd, + ...options, + }, + { + // Enables users to explicitly specify watchFolders + watchFolders: undefined, + }, + ); const overrideConfig = getOverrideConfig(ctx, config); diff --git a/packages/metro-config/index.js b/packages/metro-config/index.js index 2abf2c8757d4..6ae5fd7c8c72 100644 --- a/packages/metro-config/index.js +++ b/packages/metro-config/index.js @@ -8,7 +8,7 @@ * @noformat */ -/*:: import type {ConfigT} from 'metro-config'; */ +/*:: import type {InputConfigT} from 'metro-config'; */ const {getDefaultConfig: getBaseConfig, mergeConfig} = require('metro-config'); @@ -41,7 +41,7 @@ const INTERNAL_CALLSITES_REGEX = new RegExp( */ function getDefaultConfig( projectRoot /*: string */ -) /*: ConfigT */ { +) /*: InputConfigT */ { const config = { resolver: { resolverMainFields: ['react-native', 'browser', 'main'], @@ -82,14 +82,17 @@ function getDefaultConfig( }, }), }, - watchFolders: [], }; // Set global hook so that the CLI can detect when this config has been loaded global.__REACT_NATIVE_METRO_CONFIG_LOADED = true; + const defaults /* :InputConfigT */ = {...getBaseConfig.getDefaultValues(projectRoot)}; + // Deleting default empty watchFolders array allow a developer to explicitly specify it + delete defaults.watchFolders; + return mergeConfig( - getBaseConfig.getDefaultValues(projectRoot), + defaults, config, ); } diff --git a/yarn.lock b/yarn.lock index 0a96c50a3054..ef9162086364 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3633,7 +3633,7 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" -braces@^3.0.1, braces@~3.0.2: +braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -7249,6 +7249,14 @@ micromatch@^4.0.4: braces "^3.0.1" picomatch "^2.2.3" +micromatch@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + mime-db@1.52.0, "mime-db@>= 1.36.0 < 2": version "1.52.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" @@ -7815,7 +7823,7 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -9586,6 +9594,11 @@ yaml@^2.2.1: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== +yaml@^2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.4.tgz#53fc1d514be80aabf386dc6001eb29bf3b7523b2" + integrity sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA== + yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"