Skip to content

Commit 11bc2e6

Browse files
committed
perf(@angular/cli): cache root manifest and resolve restricted package exports in ng add
This change enhances the `ng add` command's performance by caching the root project manifest (`package.json`) to avoid redundant disk reads and JSON parsing during peer dependency conflict checks. Additionally, it improves the robustness of `package.json` resolution for installed packages. Previously, resolving `package.json` could fail if a third-party package used the Node.js `"exports"` field without explicitly exporting its `package.json`. The CLI now correctly handles this by falling back to resolving the package's entry point and traversing upwards to find the manifest.
1 parent 8adfc22 commit 11bc2e6

File tree

1 file changed

+54
-26
lines changed
  • packages/angular/cli/src/commands/add

1 file changed

+54
-26
lines changed

packages/angular/cli/src/commands/add/cli.ts

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88

99
import { Listr, ListrRenderer, ListrTaskWrapper, color, figures } from 'listr2';
1010
import assert from 'node:assert';
11+
import { existsSync } from 'node:fs';
1112
import fs from 'node:fs/promises';
1213
import { createRequire } from 'node:module';
13-
import { dirname, join } from 'node:path';
14+
import { basename, dirname, join } from 'node:path';
1415
import npa from 'npm-package-arg';
1516
import semver, { Range, compare, intersects, prerelease, satisfies, valid } from 'semver';
1617
import { Argv } from 'yargs';
@@ -107,6 +108,7 @@ export default class AddCommandModule
107108
private readonly schematicName = 'ng-add';
108109
private rootRequire = createRequire(this.context.root + '/');
109110
#projectVersionCache = new Map<string, string | null>();
111+
#rootManifestCache: PackageManifest | null = null;
110112

111113
override async builder(argv: Argv): Promise<Argv<AddCommandArgs>> {
112114
const localYargs = (await super.builder(argv))
@@ -156,6 +158,7 @@ export default class AddCommandModule
156158

157159
async run(options: Options<AddCommandArgs> & OtherOptions): Promise<number | void> {
158160
this.#projectVersionCache.clear();
161+
this.#rootManifestCache = null;
159162
const { logger } = this.context;
160163
const { collection, skipConfirmation } = options;
161164

@@ -657,18 +660,7 @@ export default class AddCommandModule
657660
}
658661

659662
private isPackageInstalled(name: string): boolean {
660-
try {
661-
this.rootRequire.resolve(join(name, 'package.json'));
662-
663-
return true;
664-
} catch (e) {
665-
assertIsError(e);
666-
if (e.code !== 'MODULE_NOT_FOUND') {
667-
throw e;
668-
}
669-
}
670-
671-
return false;
663+
return !!this.resolvePackageJson(name);
672664
}
673665

674666
private executeSchematic(
@@ -707,12 +699,7 @@ export default class AddCommandModule
707699
return cachedVersion;
708700
}
709701

710-
const { root } = this.context;
711-
let installedPackagePath;
712-
try {
713-
installedPackagePath = this.rootRequire.resolve(join(name, 'package.json'));
714-
} catch {}
715-
702+
const installedPackagePath = this.resolvePackageJson(name);
716703
if (installedPackagePath) {
717704
try {
718705
const installedPackage = JSON.parse(
@@ -724,13 +711,7 @@ export default class AddCommandModule
724711
} catch {}
725712
}
726713

727-
let projectManifest;
728-
try {
729-
projectManifest = JSON.parse(
730-
await fs.readFile(join(root, 'package.json'), 'utf-8'),
731-
) as PackageManifest;
732-
} catch {}
733-
714+
const projectManifest = await this.getProjectManifest();
734715
if (projectManifest) {
735716
const version =
736717
projectManifest.dependencies?.[name] || projectManifest.devDependencies?.[name];
@@ -746,6 +727,53 @@ export default class AddCommandModule
746727
return null;
747728
}
748729

730+
private async getProjectManifest(): Promise<PackageManifest | null> {
731+
if (this.#rootManifestCache) {
732+
return this.#rootManifestCache;
733+
}
734+
735+
const { root } = this.context;
736+
try {
737+
this.#rootManifestCache = JSON.parse(
738+
await fs.readFile(join(root, 'package.json'), 'utf-8'),
739+
) as PackageManifest;
740+
741+
return this.#rootManifestCache;
742+
} catch {
743+
return null;
744+
}
745+
}
746+
747+
private resolvePackageJson(name: string): string | undefined {
748+
try {
749+
return this.rootRequire.resolve(join(name, 'package.json'));
750+
} catch (e) {
751+
assertIsError(e);
752+
if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
753+
try {
754+
const mainPath = this.rootRequire.resolve(name);
755+
let directory = dirname(mainPath);
756+
757+
// Stop at the node_modules boundary or the root of the file system
758+
while (directory && basename(directory) !== 'node_modules') {
759+
const packageJsonPath = join(directory, 'package.json');
760+
if (existsSync(packageJsonPath)) {
761+
return packageJsonPath;
762+
}
763+
764+
const parent = dirname(directory);
765+
if (parent === directory) {
766+
break;
767+
}
768+
directory = parent;
769+
}
770+
} catch {}
771+
}
772+
}
773+
774+
return undefined;
775+
}
776+
749777
private async getPeerDependencyConflicts(manifest: PackageManifest): Promise<string[] | false> {
750778
if (!manifest.peerDependencies) {
751779
return false;

0 commit comments

Comments
 (0)