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}`;
+}