diff --git a/docs/pages/more/expo-cli.mdx b/docs/pages/more/expo-cli.mdx index 1f4e1506bcb67..a24f2b716021a 100644 --- a/docs/pages/more/expo-cli.mdx +++ b/docs/pages/more/expo-cli.mdx @@ -315,6 +315,27 @@ The command `npx expo install expo-camera` and `npx expo install expo-camera --f +### Configuring dependency validation + +> Available in SDK 49 and above. + +There may be circumstances where you want to use a version of a package is different from the version recommended by `npx expo install`. + +For example, you are testing a new version of `react-native-reanimated` to verify that it works well in your app and fixes a bug that you encountered. Now you want to 1) not be warned by `npx expo start` or `npx expo-doctor` and 2) not have that package version changed when you run `npx expo install --fix`. + +You can exclude specific packages from the version checks while still allowing the `install` command to install, check, and fix any other dependencies. This configuration extends to the checking done by `npx expo-doctor`. + +To exclude packages from version checking, set the `expo.install` config object in your project's **package.json**: +```json package.json +{ + "expo": { + "install": { + "exclude": ["expo-updates", "expo-splash-screen"] + } + } +} +``` + ### Install package managers `npx expo install` has support for `yarn`, `npm`, and `pnpm`. diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md index 6d8ab3d41acf8..90bc7502a5e46 100644 --- a/packages/@expo/cli/CHANGELOG.md +++ b/packages/@expo/cli/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unpublished +- Exclude dependencies from check/fix operations in `expo install` when set in package.json `expo.install.exclude`. ([#22736](https://github.com/expo/expo/pull/22736) by [@keith-kurak](https://github.com/keith-kurak)) ### 🛠 Breaking changes diff --git a/packages/@expo/cli/src/install/__tests__/checkPackages-test.ts b/packages/@expo/cli/src/install/__tests__/checkPackages-test.ts index f4c0798bcd003..ea4f9ea8ac36a 100644 --- a/packages/@expo/cli/src/install/__tests__/checkPackages-test.ts +++ b/packages/@expo/cli/src/install/__tests__/checkPackages-test.ts @@ -1,3 +1,5 @@ +import { getConfig } from '@expo/config'; + import { asMock } from '../../__tests__/asMock'; import { Log } from '../../log'; import { @@ -63,6 +65,44 @@ describe(checkPackagesAsync, () => { ); }); + it(`notifies when dependencies are on exclude list`, async () => { + asMock(confirmAsync).mockResolvedValueOnce(false); + // @ts-expect-error + asMock(getConfig).mockReturnValueOnce({ + pkg: { + expo: { + install: { + exclude: ['expo-av', 'expo-blur'], + }, + }, + }, + exp: { + sdkVersion: '45.0.0', + name: 'my-app', + slug: 'my-app', + }, + }); + asMock(getVersionedDependenciesAsync).mockResolvedValueOnce([ + { + packageName: 'expo-av', + packageType: 'dependencies', + expectedVersionOrRange: '^2.0.0', + actualVersion: '1.0.0', + }, + ]); + await checkPackagesAsync('/', { + packages: ['expo-av'], + options: { fix: true }, + // @ts-expect-error + packageManager: {}, + packageManagerArguments: [], + }); + + expect(Log.log).toBeCalledWith( + expect.stringContaining('Skipped fixing dependencies: expo-av and expo-blur') + ); + }); + it(`checks packages and exits with zero if all are valid`, async () => { asMock(confirmAsync).mockResolvedValueOnce(false); asMock(getVersionedDependenciesAsync).mockResolvedValueOnce([]); diff --git a/packages/@expo/cli/src/install/checkPackages.ts b/packages/@expo/cli/src/install/checkPackages.ts index d7d35b09b581d..0139a541c8dd4 100644 --- a/packages/@expo/cli/src/install/checkPackages.ts +++ b/packages/@expo/cli/src/install/checkPackages.ts @@ -8,7 +8,9 @@ import { logIncorrectDependencies, } from '../start/doctor/dependencies/validateDependenciesVersions'; import { isInteractive } from '../utils/interactive'; +import { learnMore } from '../utils/link'; import { confirmAsync } from '../utils/prompts'; +import { joinWithCommasAnd } from '../utils/strings'; import { fixPackagesAsync } from './installAsync'; import { Options } from './resolveOptions'; @@ -47,6 +49,16 @@ export async function checkPackagesAsync( skipPlugins: true, }); + if (pkg.expo?.install?.exclude?.length) { + Log.log( + chalk`Skipped ${fix ? 'fixing' : 'checking'} dependencies: ${joinWithCommasAnd( + pkg.expo.install.exclude + )}. These dependencies are listed in {bold expo.install.exclude} in package.json. ${learnMore( + 'https://expo.dev/more/expo-cli/#configuring-dependency-validation' + )}` + ); + } + const dependencies = await getVersionedDependenciesAsync(projectRoot, exp, pkg, packages); if (!dependencies.length) { diff --git a/packages/@expo/cli/src/install/installAsync.ts b/packages/@expo/cli/src/install/installAsync.ts index 83c92f5006559..c59ee4fef6c89 100644 --- a/packages/@expo/cli/src/install/installAsync.ts +++ b/packages/@expo/cli/src/install/installAsync.ts @@ -10,7 +10,9 @@ import { import { getVersionedDependenciesAsync } from '../start/doctor/dependencies/validateDependenciesVersions'; import { groupBy } from '../utils/array'; import { findUpProjectRootOrAssert } from '../utils/findUp'; +import { learnMore } from '../utils/link'; import { setNodeEnv } from '../utils/nodeEnv'; +import { joinWithCommasAnd } from '../utils/strings'; import { checkPackagesAsync } from './checkPackages'; import { Options } from './resolveOptions'; @@ -87,10 +89,20 @@ export async function installPackagesAsync( packageManagerArguments: string[]; } ): Promise { + // Read the project Expo config without plugins. + const { pkg } = getConfig(projectRoot, { + // Sometimes users will add a plugin to the config before installing the library, + // this wouldn't work unless we dangerously disable plugin serialization. + skipPlugins: true, + }); + + //assertNotInstallingExcludedPackages(projectRoot, packages, pkg); + const versioning = await getVersionedPackagesAsync(projectRoot, { packages, // sdkVersion is always defined because we don't skipSDKVersionRequirement in getConfig. sdkVersion, + pkg, }); Log.log( @@ -99,6 +111,20 @@ export async function installPackagesAsync( }using {bold ${packageManager.name}}` ); + if (versioning.excludedNativeModules.length) { + Log.log( + chalk`\u203A Using latest version instead of ${joinWithCommasAnd( + versioning.excludedNativeModules.map( + ({ bundledNativeVersion, name }) => `${bundledNativeVersion} for ${name}` + ) + )} because ${ + versioning.excludedNativeModules.length > 1 ? 'they are' : 'it is' + } listed in {bold expo.install.exclude} in package.json. ${learnMore( + 'https://expo.dev/more/expo-cli/#configuring-dependency-validation' + )}` + ); + } + await packageManager.addAsync([...packageManagerArguments, ...versioning.packages]); await applyPluginsAsync(projectRoot, versioning.packages); diff --git a/packages/@expo/cli/src/start/doctor/dependencies/__tests__/getVersionedPackages-test.ts b/packages/@expo/cli/src/start/doctor/dependencies/__tests__/getVersionedPackages-test.ts index 735b5ab5715ce..e8dbd58e0b8d7 100644 --- a/packages/@expo/cli/src/start/doctor/dependencies/__tests__/getVersionedPackages-test.ts +++ b/packages/@expo/cli/src/start/doctor/dependencies/__tests__/getVersionedPackages-test.ts @@ -36,6 +36,7 @@ describe(getVersionedPackagesAsync, () => { const { packages, messages } = await getVersionedPackagesAsync('/', { sdkVersion: '1.0.0', packages: ['@expo/vector-icons', 'react@next', 'expo-camera', 'uuid@^3.4.0'], + pkg: {}, }); expect(packages).toEqual([ @@ -49,6 +50,93 @@ describe(getVersionedPackagesAsync, () => { expect(messages).toEqual(['2 SDK 1.0.0 compatible native modules', '2 other packages']); }); + + it('should not specify versions for excluded packages', async () => { + asMock(getVersionedNativeModulesAsync).mockResolvedValueOnce({}); + asMock(getVersionsAsync).mockResolvedValueOnce({ + sdkVersions: { + '1.0.0': { + relatedPackages: { + '@expo/vector-icons': '3.0.0', + 'react-native': 'default', + react: 'default', + 'react-dom': 'default', + 'expo-sms': 'default', + }, + facebookReactVersion: 'facebook-react', + facebookReactNativeVersion: 'facebook-rn', + }, + }, + } as any); + const { packages, messages, excludedNativeModules } = await getVersionedPackagesAsync('/', { + sdkVersion: '1.0.0', + packages: ['@expo/vector-icons', 'react@next', 'expo-camera', 'uuid@^3.4.0'], + pkg: { + expo: { + install: { + exclude: ['@expo/vector-icons'], + }, + }, + }, + }); + + expect(packages).toEqual([ + // Excluded + '@expo/vector-icons', + // Custom + 'react@facebook-react', + // Passthrough + 'expo-camera', + 'uuid@^3.4.0', + ]); + + expect(messages).toEqual(['1 SDK 1.0.0 compatible native module', '3 other packages']); + expect(excludedNativeModules).toEqual([ + { name: '@expo/vector-icons', bundledNativeVersion: '3.0.0' }, + ]); + }); + + it('should not list packages in expo.install.exclude that do not have a bundledNativeVersion', async () => { + asMock(getVersionedNativeModulesAsync).mockResolvedValueOnce({}); + asMock(getVersionsAsync).mockResolvedValueOnce({ + sdkVersions: { + '1.0.0': { + relatedPackages: { + '@expo/vector-icons': '3.0.0', + 'react-native': 'default', + react: 'default', + 'react-dom': 'default', + 'expo-sms': 'default', + }, + facebookReactVersion: 'facebook-react', + facebookReactNativeVersion: 'facebook-rn', + }, + }, + } as any); + const { packages, messages, excludedNativeModules } = await getVersionedPackagesAsync('/', { + sdkVersion: '1.0.0', + packages: ['@expo/vector-icons', 'react@next', 'expo-camera', 'uuid@^3.4.0'], + pkg: { + expo: { + install: { + exclude: ['expo-camera'], + }, + }, + }, + }); + + expect(packages).toEqual([ + // Custom + '@expo/vector-icons@3.0.0', + 'react@facebook-react', + // Passthrough + 'expo-camera', // but also excluded + 'uuid@^3.4.0', + ]); + + expect(messages).toEqual(['2 SDK 1.0.0 compatible native modules', '2 other packages']); + expect(excludedNativeModules).toEqual([]); + }); }); describe(getOperationLog, () => { diff --git a/packages/@expo/cli/src/start/doctor/dependencies/__tests__/validateDependenciesVersions-test.ts b/packages/@expo/cli/src/start/doctor/dependencies/__tests__/validateDependenciesVersions-test.ts index 0a22bc2660f6b..562839aead92a 100644 --- a/packages/@expo/cli/src/start/doctor/dependencies/__tests__/validateDependenciesVersions-test.ts +++ b/packages/@expo/cli/src/start/doctor/dependencies/__tests__/validateDependenciesVersions-test.ts @@ -34,6 +34,7 @@ describe(logIncorrectDependencies, () => { actualVersion: '1.0.0', packageName: 'react-native', expectedVersionOrRange: '~2.0.0', + packageType: 'dependencies', }, ]); @@ -108,6 +109,38 @@ describe(validateDependenciesVersionsAsync, () => { expect(Log.warn).toHaveBeenNthCalledWith(3, expect.stringContaining('expo-updates')); }); + it('skips packages do not match bundled native modules but are in package.json expo.install.exclude', async () => { + asMock(Log.warn).mockReset(); + vol.fromJSON( + { + 'node_modules/expo-splash-screen/package.json': JSON.stringify({ + version: '0.2.3', + }), + 'node_modules/expo-updates/package.json': JSON.stringify({ + version: '1.3.4', + }), + }, + projectRoot + ); + const exp = { + sdkVersion: '41.0.0', + }; + const pkg = { + dependencies: { 'expo-splash-screen': '~0.2.3', 'expo-updates': '~1.3.4' }, + expo: { install: { exclude: ['expo-splash-screen'] } }, + }; + + await expect(validateDependenciesVersionsAsync(projectRoot, exp as any, pkg)).resolves.toBe( + false + ); + expect(Log.warn).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('Some dependencies are incompatible with the installed') + ); + expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('expo-updates')); + expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('expo-splash-screen')); + }); + it('resolves to true when installed package uses "exports"', async () => { const packageJsonPath = path.join(projectRoot, 'node_modules/firebase/package.json'); diff --git a/packages/@expo/cli/src/start/doctor/dependencies/getVersionedPackages.ts b/packages/@expo/cli/src/start/doctor/dependencies/getVersionedPackages.ts index 3a8c419b23e79..b080a7fd26a11 100644 --- a/packages/@expo/cli/src/start/doctor/dependencies/getVersionedPackages.ts +++ b/packages/@expo/cli/src/start/doctor/dependencies/getVersionedPackages.ts @@ -1,3 +1,4 @@ +import { PackageJSONConfig } from '@expo/config'; import npmPackageArg from 'npm-package-arg'; import { getVersionsAsync, SDKVersion } from '../../../api/getVersions'; @@ -84,13 +85,19 @@ export async function getVersionedPackagesAsync( { packages, sdkVersion, + pkg, }: { /** List of npm packages to process. */ packages: string[]; /** Target SDK Version number to version the `packages` for. */ sdkVersion: string; + pkg: PackageJSONConfig; } -): Promise<{ packages: string[]; messages: string[] }> { +): Promise<{ + packages: string[]; + messages: string[]; + excludedNativeModules: { name: string; bundledNativeVersion: string }[]; +}> { const versionsForSdk = await getCombinedKnownVersionsAsync({ projectRoot, sdkVersion, @@ -99,6 +106,7 @@ export async function getVersionedPackagesAsync( let nativeModulesCount = 0; let othersCount = 0; + const excludedNativeModules: { name: string; bundledNativeVersion: string }[] = []; const versionedPackages = packages.map((arg) => { const { name, type, raw } = npmPackageArg(arg); @@ -106,6 +114,11 @@ export async function getVersionedPackagesAsync( if (['tag', 'version', 'range'].includes(type) && name && versionsForSdk[name]) { // Unimodule packages from npm registry are modified to use the bundled version. // Some packages have the recommended version listed in https://exp.host/--/api/v2/versions. + if (pkg?.expo?.install?.exclude?.includes(name)) { + othersCount++; + excludedNativeModules.push({ name, bundledNativeVersion: versionsForSdk[name] }); + return raw; + } nativeModulesCount++; return `${name}@${versionsForSdk[name]}`; } else { @@ -124,6 +137,7 @@ export async function getVersionedPackagesAsync( return { packages: versionedPackages, messages, + excludedNativeModules, }; } diff --git a/packages/@expo/cli/src/start/doctor/dependencies/validateDependenciesVersions.ts b/packages/@expo/cli/src/start/doctor/dependencies/validateDependenciesVersions.ts index 12d2ca09f0d26..08d84ae80a3d5 100644 --- a/packages/@expo/cli/src/start/doctor/dependencies/validateDependenciesVersions.ts +++ b/packages/@expo/cli/src/start/doctor/dependencies/validateDependenciesVersions.ts @@ -115,9 +115,21 @@ export async function getVersionedDependenciesAsync( const packageVersions = await resolvePackageVersionsAsync(projectRoot, resolvedPackagesToCheck); debug(`Package versions: %O`, packageVersions); // find incorrect dependencies by comparing the actual package versions with the bundled native module version ranges - const incorrectDeps = findIncorrectDependencies(pkg, packageVersions, combinedKnownPackages); + let incorrectDeps = findIncorrectDependencies(pkg, packageVersions, combinedKnownPackages); debug(`Incorrect dependencies: %O`, incorrectDeps); + if (pkg?.expo?.install?.exclude) { + const packagesToExclude = pkg.expo.install.exclude; + const incorrectAndExcludedDeps = incorrectDeps.filter((dep) => + packagesToExclude.includes(dep.packageName) + ); + debug( + `Incorrect dependency warnings filtered out by expo.install.exclude: %O`, + incorrectAndExcludedDeps.map((dep) => dep.packageName) + ); + incorrectDeps = incorrectDeps.filter((dep) => !packagesToExclude.includes(dep.packageName)); + } + return incorrectDeps; } diff --git a/packages/@expo/cli/src/utils/__tests__/strings-test.ts b/packages/@expo/cli/src/utils/__tests__/strings-test.ts new file mode 100644 index 0000000000000..dbceff6c103cd --- /dev/null +++ b/packages/@expo/cli/src/utils/__tests__/strings-test.ts @@ -0,0 +1,37 @@ +import { joinWithCommasAnd } from '../strings'; + +describe(joinWithCommasAnd, () => { + it(`joins 3+ items with an oxford comma`, () => { + expect(joinWithCommasAnd(['a', 'b', 'c'])).toEqual('a, b, and c'); + }); + + it(`joins 2 items with an 'and'`, () => { + expect(joinWithCommasAnd(['a', 'b'])).toEqual('a and b'); + }); + + it(`returns a single item`, () => { + expect(joinWithCommasAnd(['a'])).toEqual('a'); + }); + + it(`returns an empty string for zero items`, () => { + expect(joinWithCommasAnd([])).toEqual(''); + }); + + it(`joins limit+1 with 'and 1 other'`, () => { + expect(joinWithCommasAnd(['a', 'b', 'c', 'd', 'e'], 4)).toEqual('a, b, c, d, and 1 other'); + }); + + it(`joins limit+1 with 'and 1 other'`, () => { + expect(joinWithCommasAnd(['a', 'b', 'c', 'd', 'e'], 4)).toEqual('a, b, c, d, and 1 other'); + }); + + it(`joins limit+2 or more with 'and x others'`, () => { + expect(joinWithCommasAnd(['a', 'b', 'c', 'd', 'e', 'f'], 4)).toEqual( + 'a, b, c, d, and 2 others' + ); + }); + + it(`eliminates duplicates`, () => { + expect(joinWithCommasAnd(['a', 'c', 'b', 'c'])).toEqual('a, c, and b'); + }); +}); diff --git a/packages/@expo/cli/src/utils/strings.ts b/packages/@expo/cli/src/utils/strings.ts new file mode 100644 index 0000000000000..a7144316bf245 --- /dev/null +++ b/packages/@expo/cli/src/utils/strings.ts @@ -0,0 +1,26 @@ +/** + * Joins strings with commas and 'and', based on English rules, limiting the number of items enumerated to keep from filling the console. + * @param items strings to join + * @param limit max number of strings to enumerate before using 'others' + * @returns joined string + */ +export function joinWithCommasAnd(items: string[], limit: number | undefined = 10): string { + if (!items.length) { + return ''; + } + + const uniqueItems = items.filter((value, index, array) => array.indexOf(value) === index); + + if (uniqueItems.length === 1) { + return uniqueItems[0]; + } + + if (limit && uniqueItems.length > limit) { + const first = uniqueItems.slice(0, limit); + const remaining = uniqueItems.length - limit; + return `${first.join(', ')}, and ${remaining} ${remaining > 1 ? 'others' : 'other'}`; + } + + const last = uniqueItems.pop(); + return `${uniqueItems.join(', ')}${uniqueItems.length >= 2 ? ',' : ''} and ${last}`; +}