From 703b03f268b7dca9009caaa00a69790ba028c63d Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Thu, 2 Apr 2026 19:45:15 +0530 Subject: [PATCH 1/3] 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 --- .../cli/src/commands/update/cli_spec.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 packages/angular/cli/src/commands/update/cli_spec.ts 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 a2b3d5288f11f9cd7f2fe14c750bc03fef493b28 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Thu, 2 Apr 2026 19:48:15 +0530 Subject: [PATCH 2/3] fix(@angular/build): apply file replacements to web worker entry bundles The esbuild synchronous API used for worker sub-builds does not support plugins, which are the mechanism through which file replacements are applied in the main application bundle. As a result, fileReplacements entries were silently ignored when bundling web workers. Fix this by rewriting the worker entry file's import specifiers before passing the content to buildSync. Each relative specifier is resolved to an absolute path and compared against the fileReplacements map; matching specifiers are replaced with the absolute path of the replacement file. The worker is then bundled via stdin so that esbuild resolves and bundles the replacement files normally. Also handle the case where the worker entry file itself is replaced, and ensure that rebuild file-tracking correctly excludes the synthetic stdin entry added by esbuild to the metafile while still tracking the original worker source file. Closes #29546 --- .../tests/options/file-replacements_spec.ts | 49 ++++++ .../tools/esbuild/angular/compiler-plugin.ts | 145 +++++++++++++++++- .../angular/cli/src/commands/update/cli.ts | 60 ++++++++ 3 files changed, 246 insertions(+), 8 deletions(-) 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..df1809461d10 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,54 @@ 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'); + } + }); }); }); 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..108039d8049d 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,129 @@ 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 bundles, where the + * esbuild synchronous API does not support plugins. + * + * Only the entry-file level is rewritten; transitive imports are handled because the rewritten + * specifiers point directly to the replacement files on disk, so esbuild will bundle them + * normally. + * + * @param contents Raw source text of the worker entry file. + * @param workerDir Absolute directory of the worker entry 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 { + // Matches static import/export specifiers: + // import ... from 'specifier' + // export ... from 'specifier' + // import 'specifier' + // Captures the quote character (group 1) and the specifier (group 2). + const importExportRe = /\b(?:import|export)\b[^;'"]*?(['"])([^'"]+)\1/g; + + // Extensions to try when resolving a specifier without an explicit extension. + const candidateExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']; + + let result = contents; + let match: RegExpExecArray | null; + + while ((match = importExportRe.exec(contents)) !== null) { + const specifier = match[2]; + + // Only process relative specifiers; bare package-name imports are not file-path replacements. + if (!specifier.startsWith('.') && !path.isAbsolute(specifier)) { + continue; + } + + const resolvedBase = path.isAbsolute(specifier) + ? specifier + : path.join(workerDir, specifier); + + let replacementPath: string | undefined; + + // First check if the specifier already includes an extension and resolves directly. + const directCandidate = path.normalize(resolvedBase); + replacementPath = fileReplacements[directCandidate]; + + if (!replacementPath) { + // Try appending each supported extension to resolve extensionless specifiers. + for (const ext of candidateExtensions) { + const candidate = path.normalize(resolvedBase + ext); + replacementPath = fileReplacements[candidate]; + if (replacementPath) { + break; + } + } + } + + if (replacementPath) { + // Replace only the specifier part within the matched import/export statement. + const fullMatch = match[0]; + const quote = match[1]; + const newSpecifier = replacementPath.replaceAll('\\', '/'); + const escapedSpecifier = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const newMatch = fullMatch.replace( + new RegExp(`${quote}${escapedSpecifier}${quote}`), + `${quote}${newSpecifier}${quote}`, + ); + result = result.replace(fullMatch, newMatch); + } + } + + return result; +} + function bundleWebWorker( build: PluginBuild, pluginOptions: CompilerPluginOptions, workerFile: string, ) { try { - return build.esbuild.buildSync({ + // If file replacements are configured, apply them to the worker entry file so that the + // synchronous esbuild build honours the same substitutions as the main application build. + // The esbuild synchronous API does not support plugins (which normally handle file + // replacements for the main build), so we rewrite the entry file's import specifiers + // before bundling. Imports in the rewritten file point directly to the replacement paths, + // which esbuild then resolves and bundles normally. + let entryPoints: string[] | undefined; + let stdin: { contents: string; resolveDir: string; loader: Loader } | undefined; + + if (pluginOptions.fileReplacements) { + // Check whether the worker entry file itself is being replaced. + const entryReplacement = pluginOptions.fileReplacements[path.normalize(workerFile)]; + const effectiveWorkerFile = entryReplacement ?? workerFile; + + // Rewrite any direct imports that are covered by file replacements. + 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 +897,16 @@ function bundleWebWorker( metafile: true, format: 'esm', entryNames: 'worker-[hash]', - entryPoints: [workerFile], + entryPoints, + stdin, 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'); From 4aa1c10b86bae56022d0f5306c87cd43c3393729 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Thu, 2 Apr 2026 21:15:24 +0530 Subject: [PATCH 3/3] fix(@angular/build): apply file replacements to full worker bundle graph via onResolve --- .../tests/options/file-replacements_spec.ts | 55 ++++++++ .../tools/esbuild/angular/compiler-plugin.ts | 122 +++++++++--------- 2 files changed, 118 insertions(+), 59 deletions(-) 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 df1809461d10..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 @@ -82,5 +82,60 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { 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 108039d8049d..9c9d0e17c199 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -771,15 +771,11 @@ 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 bundles, where the + * path. This allows file replacements to be honoured inside web worker entry files, where the * esbuild synchronous API does not support plugins. * - * Only the entry-file level is rewritten; transitive imports are handled because the rewritten - * specifiers point directly to the replacement files on disk, so esbuild will bundle them - * normally. - * - * @param contents Raw source text of the worker entry file. - * @param workerDir Absolute directory of the worker entry file (used to resolve relative specifiers). + * @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. */ @@ -788,63 +784,47 @@ function applyFileReplacementsToContent( workerDir: string, fileReplacements: Record, ): string { - // Matches static import/export specifiers: - // import ... from 'specifier' - // export ... from 'specifier' - // import 'specifier' - // Captures the quote character (group 1) and the specifier (group 2). - const importExportRe = /\b(?:import|export)\b[^;'"]*?(['"])([^'"]+)\1/g; - // Extensions to try when resolving a specifier without an explicit extension. const candidateExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']; - let result = contents; - let match: RegExpExecArray | null; - - while ((match = importExportRe.exec(contents)) !== null) { - const specifier = match[2]; - - // Only process relative specifiers; bare package-name imports are not file-path replacements. - if (!specifier.startsWith('.') && !path.isAbsolute(specifier)) { - continue; - } - - const resolvedBase = path.isAbsolute(specifier) - ? specifier - : path.join(workerDir, specifier); + // 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; + } - let replacementPath: string | undefined; + const resolvedBase = path.isAbsolute(specifier) + ? specifier + : path.join(workerDir, specifier); - // First check if the specifier already includes an extension and resolves directly. - const directCandidate = path.normalize(resolvedBase); - replacementPath = fileReplacements[directCandidate]; + // 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) { - const candidate = path.normalize(resolvedBase + ext); - replacementPath = fileReplacements[candidate]; - if (replacementPath) { - break; + 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) { - // Replace only the specifier part within the matched import/export statement. - const fullMatch = match[0]; - const quote = match[1]; + if (!replacementPath) { + return match; + } + const newSpecifier = replacementPath.replaceAll('\\', '/'); - const escapedSpecifier = specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const newMatch = fullMatch.replace( - new RegExp(`${quote}${escapedSpecifier}${quote}`), - `${quote}${newSpecifier}${quote}`, - ); - result = result.replace(fullMatch, newMatch); - } - } - return result; + return match.replace(`${quote}${specifier}${quote}`, `${quote}${newSpecifier}${quote}`); + }, + ); } function bundleWebWorker( @@ -853,21 +833,44 @@ function bundleWebWorker( workerFile: string, ) { try { - // If file replacements are configured, apply them to the worker entry file so that the + // 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. - // The esbuild synchronous API does not support plugins (which normally handle file - // replacements for the main build), so we rewrite the entry file's import specifiers - // before bundling. Imports in the rewritten file point directly to the replacement paths, - // which esbuild then resolves and bundles normally. + // + // 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 any direct imports that are covered by file replacements. + // 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( @@ -899,6 +902,7 @@ function bundleWebWorker( entryNames: 'worker-[hash]', entryPoints, stdin, + alias, sourcemap: pluginOptions.sourcemap, // Zone.js is not used in Web workers so no need to disable supported: undefined,