From c2c77a4fd117b2c04c9716265119dd83eb08c530 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Thu, 2 Apr 2026 20:11:49 +0530 Subject: [PATCH 1/2] fix(@angular/cli): supplement workspace member dependencies in ng update In npm/pnpm/yarn workspace setups, the package manager's list command runs against the workspace root and may not include packages that are only declared in a workspace member's package.json. This caused ng update to report "Package X is not a dependency" for packages installed in a workspace member even though they were present and installed. The fix reads the Angular project root's package.json directly and resolves any declared dependencies that are resolvable from node_modules but were absent from the package manager's output. This restores the behaviour that was present before the package-manager abstraction was introduced. Closes #32787 --- .../angular/cli/src/commands/update/cli.ts | 60 +++++++ .../cli/src/commands/update/cli_spec.ts | 150 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 packages/angular/cli/src/commands/update/cli_spec.ts diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index de6d7f53fea0..8b4f1f70d91d 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -231,6 +231,17 @@ export default class UpdateCommandModule extends CommandModule, + projectRoot: string, +): Promise { + const localManifest = await readPackageManifest(path.join(projectRoot, 'package.json')); + if (!localManifest) { + return; + } + + const localDeps: Record = { + ...localManifest.dependencies, + ...localManifest.devDependencies, + ...localManifest.peerDependencies, + }; + + for (const depName of Object.keys(localDeps)) { + if (dependencies.has(depName)) { + continue; + } + const pkgJsonPath = findPackageJson(projectRoot, depName); + if (!pkgJsonPath) { + continue; + } + const installed = await readPackageManifest(pkgJsonPath); + if (installed?.version) { + dependencies.set(depName, { + name: depName, + version: installed.version, + path: path.dirname(pkgJsonPath), + }); + } + } +} + async function readPackageManifest(manifestPath: string): Promise { try { const content = await fs.readFile(manifestPath, 'utf8'); diff --git a/packages/angular/cli/src/commands/update/cli_spec.ts b/packages/angular/cli/src/commands/update/cli_spec.ts new file mode 100644 index 000000000000..24719548576d --- /dev/null +++ b/packages/angular/cli/src/commands/update/cli_spec.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { InstalledPackage } from '../../package-managers'; +import { supplementWithLocalDependencies } from './cli'; + +/** + * Creates a minimal on-disk fixture that simulates an npm workspace member: + * + * / + * package.json ← Angular project manifest (workspace member) + * node_modules/ + * / + * package.json ← installed package manifest + */ +async function createWorkspaceMemberFixture(options: { + projectDeps: Record; + installedPackages: Array<{ name: string; version: string }>; +}): Promise { + const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ng-update-spec-')); + + // Write the Angular project's package.json + await fs.writeFile( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'test-app', + version: '0.0.0', + dependencies: options.projectDeps, + }), + ); + + // Write each installed package into node_modules + for (const pkg of options.installedPackages) { + // Support scoped packages like @angular/core + const pkgDir = path.join(projectRoot, 'node_modules', ...pkg.name.split('/')); + await fs.mkdir(pkgDir, { recursive: true }); + await fs.writeFile( + path.join(pkgDir, 'package.json'), + JSON.stringify({ name: pkg.name, version: pkg.version }), + ); + } + + return projectRoot; +} + +describe('supplementWithLocalDependencies', () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should add packages from the local package.json that are missing from the dependency map', async () => { + // Simulates an npm workspace member where `npm list` (run against the + // workspace root) did not return `@angular/core`, even though it is + // declared in the member's package.json and installed in node_modules. + tmpDir = await createWorkspaceMemberFixture({ + projectDeps: { '@angular/core': '^21.0.0' }, + installedPackages: [{ name: '@angular/core', version: '21.2.4' }], + }); + + const deps = new Map(); + + await supplementWithLocalDependencies(deps, tmpDir); + + expect(deps.has('@angular/core')).toBeTrue(); + expect(deps.get('@angular/core')?.version).toBe('21.2.4'); + }); + + it('should not overwrite a package that is already present in the dependency map', async () => { + tmpDir = await createWorkspaceMemberFixture({ + projectDeps: { '@angular/core': '^21.0.0' }, + installedPackages: [{ name: '@angular/core', version: '21.2.4' }], + }); + + // The package manager already returned a version for @angular/core. + const existingEntry: InstalledPackage = { name: '@angular/core', version: '21.0.0' }; + const deps = new Map([['@angular/core', existingEntry]]); + + await supplementWithLocalDependencies(deps, tmpDir); + + // The existing entry must not be overwritten. + expect(deps.get('@angular/core')).toBe(existingEntry); + expect(deps.get('@angular/core')?.version).toBe('21.0.0'); + }); + + it('should skip packages that are declared in package.json but not installed in node_modules', async () => { + tmpDir = await createWorkspaceMemberFixture({ + projectDeps: { 'not-installed': '^1.0.0' }, + installedPackages: [], + }); + + const deps = new Map(); + + await supplementWithLocalDependencies(deps, tmpDir); + + // Package is not installed; should not be added. + expect(deps.has('not-installed')).toBeFalse(); + }); + + it('should handle devDependencies and peerDependencies in addition to dependencies', async () => { + tmpDir = await createWorkspaceMemberFixture({ + projectDeps: {}, + installedPackages: [ + { name: 'rxjs', version: '7.8.2' }, + { name: 'zone.js', version: '0.15.0' }, + ], + }); + + // Write a package.json that uses devDependencies and peerDependencies. + await fs.writeFile( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + name: 'test-app', + version: '0.0.0', + devDependencies: { 'zone.js': '~0.15.0' }, + peerDependencies: { rxjs: '~7.8.0' }, + }), + ); + + const deps = new Map(); + + await supplementWithLocalDependencies(deps, tmpDir); + + expect(deps.has('zone.js')).toBeTrue(); + expect(deps.get('zone.js')?.version).toBe('0.15.0'); + expect(deps.has('rxjs')).toBeTrue(); + expect(deps.get('rxjs')?.version).toBe('7.8.2'); + }); + + it('should do nothing when the project root has no package.json', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ng-update-spec-')); + + const deps = new Map(); + + // Should resolve without throwing. + await expectAsync(supplementWithLocalDependencies(deps, tmpDir)).toBeResolved(); + expect(deps.size).toBe(0); + }); +}); From ec77e1b8528eef95d53059a5b20f53346d75d990 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Thu, 2 Apr 2026 20:56:03 +0530 Subject: [PATCH 2/2] perf(@angular/cli): create require instance once outside dependency loop --- .../angular/cli/src/commands/update/cli.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index 8b4f1f70d91d..fe4620870327 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -716,12 +716,16 @@ export async function supplementWithLocalDependencies( ...localManifest.peerDependencies, }; + const projectRequire = createRequire(path.join(projectRoot, 'package.json')); + for (const depName of Object.keys(localDeps)) { if (dependencies.has(depName)) { continue; } - const pkgJsonPath = findPackageJson(projectRoot, depName); - if (!pkgJsonPath) { + let pkgJsonPath: string; + try { + pkgJsonPath = projectRequire.resolve(`${depName}/package.json`); + } catch { continue; } const installed = await readPackageManifest(pkgJsonPath); @@ -744,14 +748,3 @@ async function readPackageManifest(manifestPath: string): Promise