diff --git a/packages/angular/build/src/builders/application/tests/options/file-replacements_spec.ts b/packages/angular/build/src/builders/application/tests/options/file-replacements_spec.ts index a937f0bc430a..7fa3e5eb1eb5 100644 --- a/packages/angular/build/src/builders/application/tests/options/file-replacements_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/file-replacements_spec.ts @@ -33,5 +33,109 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/main.js').content.not.toContain('12345'); harness.expectFile('dist/browser/main.js').content.toContain('67890'); }); + + it('should apply file replacements inside web workers', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + fileReplacements: [{ replace: './src/app/env.ts', with: './src/app/env.prod.ts' }], + }); + + await harness.writeFile('src/app/env.ts', `export const value = 'development';`); + await harness.writeFile('src/app/env.prod.ts', `export const value = 'production';`); + + await harness.writeFile( + 'src/app/worker.ts', + `import { value } from './env';\nself.postMessage(value);`, + ); + + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-root', + standalone: false, + template: '

Worker Test

', + }) + export class AppComponent { + worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' }); + } + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Verify the worker output file exists + expect(harness.hasFileMatch('dist/browser', /^worker-[A-Z0-9]{8}\.js$/)).toBeTrue(); + + // Find the worker filename from the main bundle and read its content + const mainContent = harness.readFile('dist/browser/main.js'); + const workerMatch = mainContent.match(/worker-([A-Z0-9]{8})\.js/); + expect(workerMatch).not.toBeNull(); + + if (workerMatch) { + const workerFilename = `dist/browser/${workerMatch[0]}`; + // The worker bundle should contain the replaced (production) value + harness.expectFile(workerFilename).content.toContain('production'); + // The worker bundle should NOT contain the original (development) value + harness.expectFile(workerFilename).content.not.toContain('development'); + } + }); + + it('should apply file replacements to transitive imports inside web workers', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + fileReplacements: [{ replace: './src/app/env.ts', with: './src/app/env.prod.ts' }], + }); + + await harness.writeFile('src/app/env.ts', `export const value = 'development';`); + await harness.writeFile('src/app/env.prod.ts', `export const value = 'production';`); + + // The worker imports a helper that in turn imports the replaceable env file. + await harness.writeFile( + 'src/app/worker-helper.ts', + `export { value } from './env';`, + ); + + await harness.writeFile( + 'src/app/worker.ts', + `import { value } from './worker-helper';\nself.postMessage(value);`, + ); + + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-root', + standalone: false, + template: '

Worker Test

', + }) + export class AppComponent { + worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' }); + } + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Verify the worker output file exists + expect(harness.hasFileMatch('dist/browser', /^worker-[A-Z0-9]{8}\.js$/)).toBeTrue(); + + // Find the worker filename from the main bundle and read its content + const mainContent = harness.readFile('dist/browser/main.js'); + const workerMatch = mainContent.match(/worker-([A-Z0-9]{8})\.js/); + expect(workerMatch).not.toBeNull(); + + if (workerMatch) { + const workerFilename = `dist/browser/${workerMatch[0]}`; + // The worker bundle should contain the replaced (production) value + harness.expectFile(workerFilename).content.toContain('production'); + // The worker bundle should NOT contain the original (development) value + harness.expectFile(workerFilename).content.not.toContain('development'); + } + }); }); }); diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 1bcb8c40500a..9c9d0e17c199 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -19,6 +19,7 @@ import type { } from 'esbuild'; import assert from 'node:assert'; import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import { maxWorkers, useTypeChecking } from '../../../utils/environment-options'; @@ -278,12 +279,21 @@ export function createCompilerPlugin( metafile: workerResult.metafile, }); - referencedFileTracker.add( - containingFile, - Object.keys(workerResult.metafile.inputs).map((input) => - path.join(build.initialOptions.absWorkingDir ?? '', input), - ), - ); + const metafileInputPaths = Object.keys(workerResult.metafile.inputs) + // When file replacements are used, the worker entry is passed via stdin and + // esbuild reports it as "" in the metafile. Exclude this virtual entry + // since it does not correspond to a real file path. + .filter((input) => input !== '') + .map((input) => path.join(build.initialOptions.absWorkingDir ?? '', input)); + + // Always ensure the actual worker entry file is tracked as a dependency even when + // the build used stdin (e.g. due to file replacements). This guarantees rebuilds + // are triggered when the source worker file changes. + if (!metafileInputPaths.includes(fullWorkerPath)) { + metafileInputPaths.push(fullWorkerPath); + } + + referencedFileTracker.add(containingFile, metafileInputPaths); // Return bundled worker file entry name to be used in the built output const workerCodeFile = workerResult.outputFiles.find((file) => @@ -757,13 +767,132 @@ function createCompilerOptionsTransformer( }; } +/** + * Rewrites static import/export specifiers in a TypeScript/JavaScript source file to apply + * file replacements. For each relative or absolute specifier that resolves to a path present + * in the `fileReplacements` map, the specifier is replaced with the corresponding replacement + * path. This allows file replacements to be honoured inside web worker entry files, where the + * esbuild synchronous API does not support plugins. + * + * @param contents Raw source text of the source file. + * @param workerDir Absolute directory of the source file (used to resolve relative specifiers). + * @param fileReplacements Map from original absolute path to replacement absolute path. + * @returns The rewritten source text, or the original text if no replacements are needed. + */ +function applyFileReplacementsToContent( + contents: string, + workerDir: string, + fileReplacements: Record, +): string { + // Extensions to try when resolving a specifier without an explicit extension. + const candidateExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']; + + // Use a line-anchored regex with a callback to replace import/export specifiers in-place. + // The pattern matches static import and export-from statements (including multiline forms) + // and captures the specifier. `[\s\S]*?` is used instead of `.*?` so that multiline import + // lists (common in TypeScript) are matched correctly. + return contents.replace( + /^(import|export)([\s\S]*?\s+from\s+|\s+)(['"])([^'"]+)\3/gm, + (match, _keyword, _middle, quote, specifier) => { + // Only process relative specifiers; bare package-name imports are not file-path replacements. + if (!specifier.startsWith('.') && !path.isAbsolute(specifier)) { + return match; + } + + const resolvedBase = path.isAbsolute(specifier) + ? specifier + : path.join(workerDir, specifier); + + // First check if the specifier already includes an extension and resolves directly. + let replacementPath: string | undefined = fileReplacements[path.normalize(resolvedBase)]; + + if (!replacementPath) { + // Try appending each supported extension to resolve extensionless specifiers. + for (const ext of candidateExtensions) { + replacementPath = fileReplacements[path.normalize(resolvedBase + ext)]; + if (replacementPath) { + break; + } + } + } + + if (!replacementPath) { + return match; + } + + const newSpecifier = replacementPath.replaceAll('\\', '/'); + + return match.replace(`${quote}${specifier}${quote}`, `${quote}${newSpecifier}${quote}`); + }, + ); +} + function bundleWebWorker( build: PluginBuild, pluginOptions: CompilerPluginOptions, workerFile: string, ) { try { - return build.esbuild.buildSync({ + // If file replacements are configured, apply them to the worker bundle so that the + // synchronous esbuild build honours the same substitutions as the main application build. + // + // Because the esbuild synchronous API does not support plugins, file replacements are + // applied via two complementary mechanisms: + // + // 1. `alias`: esbuild's built-in alias option intercepts every resolve call across the + // entire bundle graph — entry file and all transitive imports — and redirects any + // import whose specifier exactly matches an original path to the replacement path. + // This covers imports that use a path form identical to the fileReplacements key + // (e.g. TypeScript path-mapped or absolute imports). + // + // 2. stdin rewriting: for relative specifiers in the worker entry file (the most common + // case), `applyFileReplacementsToContent` resolves each specifier to an absolute path, + // looks it up in the fileReplacements map, and rewrites the source text before passing + // it to esbuild via stdin. The rewritten specifiers now point directly to the + // replacement files, so esbuild bundles them without needing further intervention. + let entryPoints: string[] | undefined; + let stdin: { contents: string; resolveDir: string; loader: Loader } | undefined; + let alias: Record | undefined; + + if (pluginOptions.fileReplacements) { + // Pass all file replacements as esbuild aliases so that every import in the worker + // bundle graph — not just the entry — is subject to replacement at resolve time. + alias = Object.fromEntries( + Object.entries(pluginOptions.fileReplacements).map(([original, replacement]) => [ + original.replaceAll('\\', '/'), + replacement.replaceAll('\\', '/'), + ]), + ); + + // Check whether the worker entry file itself is being replaced. + const entryReplacement = pluginOptions.fileReplacements[path.normalize(workerFile)]; + const effectiveWorkerFile = entryReplacement ?? workerFile; + + // Rewrite relative import specifiers in the entry file that resolve to a replaced path. + // This handles the common case where transitive-dependency imports inside the entry use + // relative paths that esbuild alias (which matches raw specifier text) would not catch. + const workerDir = path.dirname(effectiveWorkerFile); + const originalContents = readFileSync(effectiveWorkerFile, 'utf-8'); + const rewrittenContents = applyFileReplacementsToContent( + originalContents, + workerDir, + pluginOptions.fileReplacements, + ); + + if (rewrittenContents !== originalContents || entryReplacement) { + // Use stdin to pass the rewritten content so that the correct bundle is produced. + // Infer the esbuild loader from the effective worker file extension. + const stdinLoader: Loader = + path.extname(effectiveWorkerFile).toLowerCase() === '.tsx' ? 'tsx' : 'ts'; + stdin = { contents: rewrittenContents, resolveDir: workerDir, loader: stdinLoader }; + } else { + entryPoints = [workerFile]; + } + } else { + entryPoints = [workerFile]; + } + + const result = build.esbuild.buildSync({ ...build.initialOptions, platform: 'browser', write: false, @@ -771,13 +900,17 @@ function bundleWebWorker( metafile: true, format: 'esm', entryNames: 'worker-[hash]', - entryPoints: [workerFile], + entryPoints, + stdin, + alias, sourcemap: pluginOptions.sourcemap, // Zone.js is not used in Web workers so no need to disable supported: undefined, // Plugins are not supported in sync esbuild calls plugins: undefined, }); + + return result; } catch (error) { if (error && typeof error === 'object' && 'errors' in error && 'warnings' in error) { return error as BuildFailure; 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); + }); +});