Skip to content

Commit a512fb3

Browse files
committed
perf(@angular/cli): optimize ng add version discovery
The `ng add` command's version discovery mechanism could be slow when the `latest` tag of a package was incompatible, as it would fall back to exhaustively fetching the manifest for every available version. This commit introduces a performance optimization that uses a heuristic-based search. The new logic first identifies the latest release within each major version line and checks only those for compatibility. This dramatically reduces the number of network requests in the common case where peer dependency conflicts align with major versions. The exhaustive, version-by-version search is retained as a fallback to ensure correctness in edge cases.
1 parent 4f00055 commit a512fb3

File tree

1 file changed

+76
-22
lines changed
  • packages/angular/cli/src/commands/add

1 file changed

+76
-22
lines changed

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

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88

99
import { Listr, ListrRenderer, ListrTaskWrapper, color, figures } from 'listr2';
1010
import assert from 'node:assert';
11-
import { promises as fs } from 'node:fs';
11+
import fs from 'node:fs/promises';
1212
import { createRequire } from 'node:module';
1313
import { dirname, join } from 'node:path';
1414
import npa from 'npm-package-arg';
15-
import { Range, compare, intersects, prerelease, satisfies, valid } from 'semver';
15+
import semver, { Range, compare, intersects, prerelease, satisfies, valid } from 'semver';
1616
import { Argv } from 'yargs';
1717
import {
1818
CommandModuleImplementation,
@@ -358,30 +358,27 @@ export default class AddCommandModule
358358
throw new CommandError('Unable to load package information from registry.');
359359
}
360360

361-
// Allow prelease versions if the CLI itself is a prerelease.
362-
const allowPrereleases = !!prerelease(VERSION.full);
361+
// Allow prelease versions if the CLI itself is a prerelease or locally built.
362+
const allowPrereleases = !!prerelease(VERSION.full) || VERSION.full === '0.0.0';
363363
const potentialVersions = this.#getPotentialVersions(packageMetadata, allowPrereleases);
364364

365-
let found;
366-
for (const version of potentialVersions) {
367-
const manifest = await packageManager.getPackageManifest(`${packageName}@${version}`, {
365+
// Heuristic-based search: Check the latest release of each major version first.
366+
const majorVersions = this.#getMajorVersions(potentialVersions);
367+
let found = await this.#findCompatibleVersion(context, majorVersions, {
368+
registry,
369+
verbose,
370+
rejectionReasons,
371+
});
372+
373+
// Exhaustive search: If no compatible major version is found, fall back to checking all versions.
374+
if (!found) {
375+
const checkedVersions = new Set(majorVersions);
376+
const remainingVersions = potentialVersions.filter((v) => !checkedVersions.has(v));
377+
found = await this.#findCompatibleVersion(context, remainingVersions, {
368378
registry,
379+
verbose,
380+
rejectionReasons,
369381
});
370-
if (!manifest) {
371-
continue;
372-
}
373-
374-
const conflicts = await this.getPeerDependencyConflicts(manifest);
375-
if (conflicts) {
376-
if (verbose || rejectionReasons.length < DEFAULT_CONFLICT_DISPLAY_LIMIT) {
377-
rejectionReasons.push(...conflicts);
378-
}
379-
continue;
380-
}
381-
382-
context.packageIdentifier = npa.resolve(manifest.name, manifest.version);
383-
found = manifest;
384-
break;
385382
}
386383

387384
if (!found) {
@@ -403,10 +400,54 @@ export default class AddCommandModule
403400
}
404401
}
405402

403+
async #findCompatibleVersion(
404+
context: AddCommandTaskContext,
405+
versions: string[],
406+
options: {
407+
registry?: string;
408+
verbose?: boolean;
409+
rejectionReasons: string[];
410+
},
411+
): Promise<PackageManifest | null> {
412+
const { packageManager, packageIdentifier } = context;
413+
const { registry, verbose, rejectionReasons } = options;
414+
const packageName = packageIdentifier.name;
415+
assert(packageName, 'Package name must be defined.');
416+
417+
for (const version of versions) {
418+
const manifest = await packageManager.getPackageManifest(`${packageName}@${version}`, {
419+
registry,
420+
});
421+
if (!manifest) {
422+
continue;
423+
}
424+
425+
const conflicts = await this.getPeerDependencyConflicts(manifest);
426+
if (conflicts) {
427+
if (verbose || rejectionReasons.length < DEFAULT_CONFLICT_DISPLAY_LIMIT) {
428+
rejectionReasons.push(...conflicts);
429+
}
430+
continue;
431+
}
432+
433+
context.packageIdentifier = npa.resolve(manifest.name, manifest.version);
434+
435+
return manifest;
436+
}
437+
438+
return null;
439+
}
440+
406441
#getPotentialVersions(packageMetadata: PackageMetadata, allowPrereleases: boolean): string[] {
407442
const versionExclusions = packageVersionExclusions[packageMetadata.name];
443+
const latestVersion = packageMetadata['dist-tags']['latest'];
408444

409445
const versions = Object.values(packageMetadata.versions).filter((version) => {
446+
// Latest tag has already been checked
447+
if (latestVersion && version === latestVersion) {
448+
return false;
449+
}
450+
410451
// Prerelease versions are not stable and should not be considered by default
411452
if (!allowPrereleases && prerelease(version)) {
412453
return false;
@@ -424,6 +465,19 @@ export default class AddCommandModule
424465
return versions.sort((a, b) => compare(b, a, true));
425466
}
426467

468+
#getMajorVersions(versions: string[]): string[] {
469+
const majorVersions = new Map<number, string>();
470+
for (const version of versions) {
471+
const major = semver.major(version);
472+
const existing = majorVersions.get(major);
473+
if (!existing || semver.gt(version, existing)) {
474+
majorVersions.set(major, version);
475+
}
476+
}
477+
478+
return [...majorVersions.values()].sort((a, b) => compare(b, a, true));
479+
}
480+
427481
private async loadPackageInfoTask(
428482
context: AddCommandTaskContext,
429483
task: AddCommandTaskWrapper,

0 commit comments

Comments
 (0)