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);
+ });
+});