Skip to content

Commit a39a331

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 ad0fd5f commit a39a331

File tree

1 file changed

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

1 file changed

+59
-26
lines changed

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

Lines changed: 59 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

@@ -665,18 +668,7 @@ export default class AddCommandModule
665668
}
666669

667670
private isPackageInstalled(name: string): boolean {
668-
try {
669-
this.rootRequire.resolve(join(name, 'package.json'));
670-
671-
return true;
672-
} catch (e) {
673-
assertIsError(e);
674-
if (e.code !== 'MODULE_NOT_FOUND') {
675-
throw e;
676-
}
677-
}
678-
679-
return false;
671+
return !!this.resolvePackageJson(name);
680672
}
681673

682674
private executeSchematic(
@@ -715,12 +707,7 @@ export default class AddCommandModule
715707
return cachedVersion;
716708
}
717709

718-
const { root } = this.context;
719-
let installedPackagePath;
720-
try {
721-
installedPackagePath = this.rootRequire.resolve(join(name, 'package.json'));
722-
} catch {}
723-
710+
const installedPackagePath = this.resolvePackageJson(name);
724711
if (installedPackagePath) {
725712
try {
726713
const installedPackage = JSON.parse(
@@ -732,13 +719,7 @@ export default class AddCommandModule
732719
} catch {}
733720
}
734721

735-
let projectManifest;
736-
try {
737-
projectManifest = JSON.parse(
738-
await fs.readFile(join(root, 'package.json'), 'utf-8'),
739-
) as PackageManifest;
740-
} catch {}
741-
722+
const projectManifest = await this.getProjectManifest();
742723
if (projectManifest) {
743724
const version =
744725
projectManifest.dependencies?.[name] || projectManifest.devDependencies?.[name];
@@ -754,6 +735,58 @@ export default class AddCommandModule
754735
return null;
755736
}
756737

738+
private async getProjectManifest(): Promise<PackageManifest | null> {
739+
if (this.#rootManifestCache) {
740+
return this.#rootManifestCache;
741+
}
742+
743+
const { root } = this.context;
744+
try {
745+
this.#rootManifestCache = JSON.parse(
746+
await fs.readFile(join(root, 'package.json'), 'utf-8'),
747+
) as PackageManifest;
748+
749+
return this.#rootManifestCache;
750+
} catch {
751+
return null;
752+
}
753+
}
754+
755+
private resolvePackageJson(name: string): string | undefined {
756+
try {
757+
return this.rootRequire.resolve(join(name, 'package.json'));
758+
} catch (e) {
759+
assertIsError(e);
760+
if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
761+
try {
762+
const mainPath = this.rootRequire.resolve(name);
763+
let directory = dirname(mainPath);
764+
765+
// Stop at the node_modules boundary or the root of the file system
766+
while (directory && basename(directory) !== 'node_modules') {
767+
const packageJsonPath = join(directory, 'package.json');
768+
if (existsSync(packageJsonPath)) {
769+
return packageJsonPath;
770+
}
771+
772+
const parent = dirname(directory);
773+
if (parent === directory) {
774+
break;
775+
}
776+
directory = parent;
777+
}
778+
} catch (e) {
779+
assertIsError(e);
780+
this.context.logger.debug(
781+
`Failed to resolve package '${name}' during fallback: ${e.message}`,
782+
);
783+
}
784+
}
785+
}
786+
787+
return undefined;
788+
}
789+
757790
private async getPeerDependencyConflicts(manifest: PackageManifest): Promise<string[] | false> {
758791
if (!manifest.peerDependencies) {
759792
return false;

0 commit comments

Comments
 (0)