Skip to content

Commit

Permalink
[cli] Add dependency exclusion to npx expo install (expo#22736)
Browse files Browse the repository at this point in the history
# Why
Fulfills
https://linear.app/expo/issue/ENG-8564/add-dependency-exclusion-to-npx-expo-install.
Developers will be able to suppress warnings from `npx expo install
--check` via a package.json config, and thus suppress doctor warnings
when they intentionally choose to install a different version,
especially when using dev builds and not needing to stick to the Expo
Go- bundled version.

# How
`npx expo install --check|fix` reads from `expo.install.exclude` if it's
available, and, if one of the packages it thinks it should fix is in
that list, it will skip asking/ fixing it. If you run `EXPO_DEBUG=1 npx
expo install --check`, the packages whose checks were actually skipped
will be listed in debug output; otherwise there are no warnings or
messages about the excluded packages.

The package.json configuration verbiage is intended to be similar to the
[autolinking
config](https://docs.expo.dev/modules/autolinking/#exclude).

~~It also seemed appropriate that `npx expo install` should behave
differently if you try to install an excluded package, so someone
doesn't come along and write over a version that another developer
determined was good and thus applied the override for. Currently, it
aborts if any of the packages are in the exclusion list. I could see
doing a warning and skipping the affected packages instead, but the
command outright failing is pretty hard to miss.~~

`npx expo install` now installs excluded packages with the latest
version, just like a package without a specified native version. It will
add a note describing what it's doing and why.

# Test Plan
- [x] test without `expo.install.exclude` (works the same)
- [x] test with empty exclusion list (works the same)
- [x] test with exclusions that aren't actual dependencies (works the
same)
- [x] test `expo install --fix|check` when excluding an actual
dependency (skips it)
- [x] test with `EXPO_DEBUG=1` (lists excluded packages)
- [x] test `expo install [package]` with exclusions (installs latest
with notes)
- [x] test `expo install [package]` without exclusions (proceeds as it
normally does)

## Output
`npx expo install --check`, showing note that it is skipping checking
packages in the exclude list:
<img width="884" alt="image"
src="https://github.com/expo/expo/assets/8053974/30cef729-af18-44cb-98ef-11fc9729ae3d">
Additional debug output for `EXPO_DEBUG=1 npx expo install --check`:
<img width="904" alt="image"
src="https://github.com/expo/expo/assets/8053974/9096a939-066c-431b-a467-6d3d70ef448d">
`expo install <package>` with exclusions:
<img width="932" alt="image"
src="https://github.com/expo/expo/assets/8053974/d51e9e5a-cc59-4aa4-9cb6-4a2294b01ccc">

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
This is required for changes to Expo modules.
-->

- [x] Documentation is up to date to reflect these changes (eg:
https://docs.expo.dev and README.md).
- [x] Conforms with the [Documentation Writing Style
Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
- [x] This diff will work correctly for `npx expo prebuild` & EAS Build
(eg: updated a module plugin).

---------

Co-authored-by: Brent Vatne <[email protected]>
  • Loading branch information
keith-kurak and brentvatne authored Jun 13, 2023
1 parent b9e44d7 commit 6fb32dd
Show file tree
Hide file tree
Showing 11 changed files with 312 additions and 2 deletions.
21 changes: 21 additions & 0 deletions docs/pages/more/expo-cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,27 @@ The command `npx expo install expo-camera` and `npx expo install expo-camera --f

<Terminal cmd={['$ npx expo install --fix']} />

### 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`.
Expand Down
1 change: 1 addition & 0 deletions packages/@expo/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
40 changes: 40 additions & 0 deletions packages/@expo/cli/src/install/__tests__/checkPackages-test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getConfig } from '@expo/config';

import { asMock } from '../../__tests__/asMock';
import { Log } from '../../log';
import {
Expand Down Expand Up @@ -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([]);
Expand Down
12 changes: 12 additions & 0 deletions packages/@expo/cli/src/install/checkPackages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions packages/@expo/cli/src/install/installAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -87,10 +89,20 @@ export async function installPackagesAsync(
packageManagerArguments: string[];
}
): Promise<void> {
// 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(
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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/[email protected]',
'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, () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe(logIncorrectDependencies, () => {
actualVersion: '1.0.0',
packageName: 'react-native',
expectedVersionOrRange: '~2.0.0',
packageType: 'dependencies',
},
]);

Expand Down Expand Up @@ -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');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PackageJSONConfig } from '@expo/config';
import npmPackageArg from 'npm-package-arg';

import { getVersionsAsync, SDKVersion } from '../../../api/getVersions';
Expand Down Expand Up @@ -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,
Expand All @@ -99,13 +106,19 @@ 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);

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 {
Expand All @@ -124,6 +137,7 @@ export async function getVersionedPackagesAsync(
return {
packages: versionedPackages,
messages,
excludedNativeModules,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading

0 comments on commit 6fb32dd

Please sign in to comment.