From 496c8187cefc0c3754fc1fa3d2979d347a15ddf0 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:45:23 +0000 Subject: [PATCH 1/3] refactor(@angular/build): unify and deduplicate emitFilesToDisk across builders Relocate emitFilesToDisk into src/utils/emit-files-to-disk.ts and unify the file emission descriptor shape across the application and library builders around origin ('memory' | 'disk') and source. Update emitFilesToDisk to manage per-invocation parent directory creation internally with bounded concurrency, allowing application/index.ts, library/pipeline/build-action.ts, and utils/test-files.ts to share the same helper. This also removes the cross-rebuild directoryExists cache from the library builder's SingleBuildState so deleted output subdirectories are properly recreated during watch mode rebuilds. --- .../src/builders/application/build-action.ts | 4 +- .../build/src/builders/application/index.ts | 40 +++-------- .../build/src/builders/application/results.ts | 2 +- .../src/builders/dev-server/vite/utils.ts | 2 +- .../extract-i18n/application-extraction.ts | 2 +- .../src/builders/karma/assets-middleware.ts | 2 +- .../builders/library/pipeline/build-action.ts | 21 +----- .../src/builders/library/pipeline/utils.ts | 8 +-- .../library/tests/behavior/watch_spec.ts | 38 +++++++++++ .../build/src/builders/library/types.ts | 3 - .../unit-test/runners/vitest/plugins.ts | 2 +- packages/angular/build/src/private.ts | 2 +- .../angular/build/src/tools/esbuild/utils.ts | 24 +------ .../vite/middlewares/assets-middleware.ts | 2 +- .../build/src/utils/emit-files-to-disk.ts | 67 +++++++++++++++++++ .../src/utils/emit-files-to-disk_spec.ts | 53 +++++++++++++++ .../angular/build/src/utils/test-files.ts | 33 ++------- .../extract-i18n/application-extraction.ts | 2 +- 18 files changed, 192 insertions(+), 115 deletions(-) create mode 100644 packages/angular/build/src/utils/emit-files-to-disk.ts create mode 100644 packages/angular/build/src/utils/emit-files-to-disk_spec.ts diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index b728ab9a8df5..e0edf9eec25b 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -333,7 +333,7 @@ function* emitOutputResults( incrementalResult.files.push({ path: destination, type: BuildOutputFileType.Browser, - inputPath: source, + source: source, origin: 'disk', }); } @@ -406,7 +406,7 @@ function createFullResult( ...assetFiles.map(({ source, destination }) => ({ path: destination, type: BuildOutputFileType.Browser, - inputPath: source, + source, origin: 'disk' as const, })), ...outputFiles.map((file) => ({ diff --git a/packages/angular/build/src/builders/application/index.ts b/packages/angular/build/src/builders/application/index.ts index 59ded1cd8182..f1b16a27e394 100644 --- a/packages/angular/build/src/builders/application/index.ts +++ b/packages/angular/build/src/builders/application/index.ts @@ -11,9 +11,10 @@ import assert from 'node:assert'; import fs from 'node:fs/promises'; import path from 'node:path'; import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; -import { createJsonBuildManifest, emitFilesToDisk } from '../../tools/esbuild/utils'; +import { createJsonBuildManifest } from '../../tools/esbuild/utils'; import { colors as ansiColors } from '../../utils/color'; import { deleteOutputDir } from '../../utils/delete-output-dir'; +import { emitFilesToDisk } from '../../utils/emit-files-to-disk'; import { bazelEsbuildPluginPath, useJSONBuildLogs } from '../../utils/environment-options'; import { isSubDirectory } from '../../utils/path'; import { purgeStaleBuildCache } from '../../utils/purge-cache'; @@ -197,37 +198,14 @@ export async function* buildApplication( } // Writes the output files to disk and ensures the containing directories are present - const directoryExists = new Set(); try { - await emitFilesToDisk(result.files, async (file) => { - if ( - outputOptions.ignoreServer && - (file.type === BuildOutputFileType.ServerApplication || - file.type === BuildOutputFileType.ServerRoot) - ) { - return; - } - - const fullFilePath = generateFullPath(file.path, file.type, outputOptions); - - // Ensure output subdirectories exist - const fileBasePath = path.dirname(fullFilePath); - if (fileBasePath && !directoryExists.has(fileBasePath)) { - await fs.mkdir(fileBasePath, { recursive: true }); - directoryExists.add(fileBasePath); - } - - if (file.origin === 'memory') { - // Write file contents - await fs.writeFile(fullFilePath, file.contents); - } else { - // Copy file contents - await fs.cp(file.inputPath, fullFilePath, { - mode: fs.constants.COPYFILE_FICLONE, - preserveTimestamps: true, - }); - } - }); + await emitFilesToDisk(result.files, (file) => + outputOptions.ignoreServer && + (file.type === BuildOutputFileType.ServerApplication || + file.type === BuildOutputFileType.ServerRoot) + ? undefined + : generateFullPath(file.path, file.type, outputOptions), + ); } catch (error) { context.logger.error(error instanceof Error ? error.message : String(error)); yield { success: false }; diff --git a/packages/angular/build/src/builders/application/results.ts b/packages/angular/build/src/builders/application/results.ts index 8e95176639f5..e9bd786a3fe6 100644 --- a/packages/angular/build/src/builders/application/results.ts +++ b/packages/angular/build/src/builders/application/results.ts @@ -53,7 +53,7 @@ export interface BaseResultFile { export interface DiskFile extends BaseResultFile { origin: 'disk'; - inputPath: string; + source: string; } export interface MemoryFile extends BaseResultFile { diff --git a/packages/angular/build/src/builders/dev-server/vite/utils.ts b/packages/angular/build/src/builders/dev-server/vite/utils.ts index bc2d4a513b5a..4a0b85b9324a 100644 --- a/packages/angular/build/src/builders/dev-server/vite/utils.ts +++ b/packages/angular/build/src/builders/dev-server/vite/utils.ts @@ -40,7 +40,7 @@ export function updateResultRecord( ): void { if (file.origin === 'disk') { assetFiles.set('/' + normalizePath(file.path), { - source: normalizePath(file.inputPath), + source: normalizePath(file.source), updated: !initial, }); diff --git a/packages/angular/build/src/builders/extract-i18n/application-extraction.ts b/packages/angular/build/src/builders/extract-i18n/application-extraction.ts index b18c8164481f..02233fade493 100644 --- a/packages/angular/build/src/builders/extract-i18n/application-extraction.ts +++ b/packages/angular/build/src/builders/extract-i18n/application-extraction.ts @@ -107,7 +107,7 @@ function setupLocalizeExtractor( if (file?.origin === 'memory') { content = textDecoder.decode(file.contents); } else if (file?.origin === 'disk') { - content = readFileSync(file.inputPath, 'utf-8'); + content = readFileSync(file.source, 'utf-8'); } if (content === undefined) { throw new Error('Unknown file requested: ' + requestedPath); diff --git a/packages/angular/build/src/builders/karma/assets-middleware.ts b/packages/angular/build/src/builders/karma/assets-middleware.ts index 45483cb4ec41..6f04faffae46 100644 --- a/packages/angular/build/src/builders/karma/assets-middleware.ts +++ b/packages/angular/build/src/builders/karma/assets-middleware.ts @@ -60,7 +60,7 @@ export class AngularAssetsMiddleware { // https://github.com/karma-runner/karma/blob/84f85e7016efc2266fa6b3465f494a3fa151c85c/lib/middleware/common.js#L10 switch (file.origin) { case 'disk': - this.serveFile(file.inputPath, undefined, res, undefined, undefined, /* doNotCache */ true); + this.serveFile(file.source, undefined, res, undefined, undefined, /* doNotCache */ true); break; case 'memory': // Include pathname to help with Content-Type headers. diff --git a/packages/angular/build/src/builders/library/pipeline/build-action.ts b/packages/angular/build/src/builders/library/pipeline/build-action.ts index 294eb954421b..a430312ac925 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -7,10 +7,9 @@ */ import type { BuilderContext } from '@angular-devkit/architect'; -import { constants, copyFile, mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; -import { emitFilesToDisk } from '../../../tools/esbuild/utils'; +import { emitFilesToDisk } from '../../../utils/emit-files-to-disk'; import { toPosixPath } from '../../../utils/path'; import type { NormalizedLibraryOptions, SingleBuildState } from '../types'; import { collectAssetsToEmit } from './assets'; @@ -27,7 +26,6 @@ export function createSingleBuildState(): SingleBuildState { previousBundleResults: new Map(), pendingChangedEsmFiles: new Set(), pendingChangedDtsFiles: new Set(), - directoryExists: new Set(), }; } @@ -90,7 +88,7 @@ export async function buildAction(actionContext: BuildActionContext): Promise(filesToEmit, async (file) => { - const fullFilePath = path.join(options.outputPath, file.path); - const fileBasePath = path.dirname(fullFilePath); - if (fileBasePath && !directoryExists.has(fileBasePath)) { - await mkdir(fileBasePath, { recursive: true }); - directoryExists.add(fileBasePath); - } - - if (file.type === 'memory') { - await writeFile(fullFilePath, file.contents); - } else { - await copyFile(file.source, fullFilePath, constants.COPYFILE_FICLONE); - } - }); + await emitFilesToDisk(filesToEmit, (file) => path.join(options.outputPath, file.path)); buildState.hasEmittedManifests = true; buildState.hasEmittedTslib = hasTslibImport; diff --git a/packages/angular/build/src/builders/library/pipeline/utils.ts b/packages/angular/build/src/builders/library/pipeline/utils.ts index 8258c8874e42..9092a73005ef 100644 --- a/packages/angular/build/src/builders/library/pipeline/utils.ts +++ b/packages/angular/build/src/builders/library/pipeline/utils.ts @@ -26,7 +26,7 @@ export const TYPES_OUTPUT_DIR = 'types'; * Represents an in-memory file to be emitted to disk. */ export interface MemoryOutputFile { - type: 'memory'; + origin: 'memory'; /** The destination path where the file should be written. */ path: string; @@ -39,7 +39,7 @@ export interface MemoryOutputFile { * Represents an existing file on disk to be copied to a destination path. */ export interface DiskOutputFile { - type: 'disk'; + origin: 'disk'; /** The path to the source file on disk. */ source: string; @@ -62,7 +62,7 @@ export type OutputFile = MemoryOutputFile | DiskOutputFile; */ export function createDiskOutputFile(source: string, path: string): DiskOutputFile { return { - type: 'disk', + origin: 'disk', source, path, }; @@ -80,7 +80,7 @@ export function createMemoryOutputFile( contents: string | Uint8Array | Record, ): MemoryOutputFile { return { - type: 'memory', + origin: 'memory', path, contents: typeof contents === 'string' || contents instanceof Uint8Array diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts index 2533da417909..bf0521e6916f 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { rm } from 'node:fs/promises'; import { executeLibraryBuilder } from '../../builder'; import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; @@ -563,5 +564,42 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }, ]); }); + + it('should recreate deleted output directories on subsequent watch rebuilds', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeTrue(); + + // Remove output subdirectory externally between rebuilds + await rm(harness.resolvePath('dist/lib/fesm2022'), { recursive: true, force: true }); + expect(harness.hasFile('dist/lib/fesm2022/lib.mjs')).toBeFalse(); + + // Trigger a rebuild + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-recreated', + template: 'Recreated', + }) + export class LibComponent {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(content).toContain('lib-recreated'); + }, + ]); + }); }); }); diff --git a/packages/angular/build/src/builders/library/types.ts b/packages/angular/build/src/builders/library/types.ts index 7ec8545a9c8e..d360cfe2c868 100644 --- a/packages/angular/build/src/builders/library/types.ts +++ b/packages/angular/build/src/builders/library/types.ts @@ -153,7 +153,4 @@ export interface SingleBuildState { /** Whether entry points in package.json changed since the last compilation. */ hasEntryPointsChanges?: boolean; - - /** Cache of output directory paths known to exist on disk. */ - directoryExists: Set; } diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index 0a8a2c8990b4..72308b6b57d6 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -322,7 +322,7 @@ async function loadResultFile(file: ResultFile): Promise { return textDecoder.decode(file.contents); } - return readFile(file.inputPath, 'utf-8'); + return readFile(file.source, 'utf-8'); } export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] { diff --git a/packages/angular/build/src/private.ts b/packages/angular/build/src/private.ts index 9db709640b57..6f98e4f8f0e9 100644 --- a/packages/angular/build/src/private.ts +++ b/packages/angular/build/src/private.ts @@ -34,7 +34,6 @@ export { // Tools export type { ExternalResultMetadata } from './tools/esbuild/bundler-execution-result'; -export { emitFilesToDisk } from './tools/esbuild/utils'; export { transformSupportedBrowsersToTargets } from './tools/esbuild/target'; export { SassCompiler } from './tools/sass/sass-service'; @@ -73,6 +72,7 @@ export { ComponentStylesheetBundler } from './tools/esbuild/angular/component-st export * from './utils/bundle-calculator'; export { checkPort } from './utils/check-port'; export { deleteOutputDir } from './utils/delete-output-dir'; +export { emitFilesToDisk } from './utils/emit-files-to-disk'; export { type I18nOptions, createI18nOptions, loadTranslations } from './utils/i18n-options'; export { IndexHtmlGenerator, diff --git a/packages/angular/build/src/tools/esbuild/utils.ts b/packages/angular/build/src/tools/esbuild/utils.ts index 3bbaabdd2400..e93bb295b439 100644 --- a/packages/angular/build/src/tools/esbuild/utils.ts +++ b/packages/angular/build/src/tools/esbuild/utils.ts @@ -17,11 +17,7 @@ import { OutputMode } from '../../builders/application/schema'; import { BudgetCalculatorResult } from '../../utils/bundle-calculator'; import { BundleStats, generateEsbuildBuildStatsTable } from '../../utils/stats-table'; -import { - BuildOutputAsset, - ExecutionResult, - PrerenderedRoutesRecord, -} from './bundler-execution-result'; +import { ExecutionResult, PrerenderedRoutesRecord } from './bundler-execution-result'; import { type BuildOutputFile, BuildOutputFileType, type InitialFileRecord } from './bundler-files'; export function logBuildStats( @@ -212,24 +208,6 @@ export function getFeatureSupport(nativeAsyncAwait: boolean): BuildOptions['supp }; } -const MAX_CONCURRENT_WRITES = 64; -export async function emitFilesToDisk( - files: readonly T[], - writeFileCallback: (file: T) => Promise, -): Promise { - // Write files in groups of MAX_CONCURRENT_WRITES to avoid too many open files - for (let fileIndex = 0; fileIndex < files.length;) { - const groupMax = Math.min(fileIndex + MAX_CONCURRENT_WRITES, files.length); - - const actions = []; - while (fileIndex < groupMax) { - actions.push(writeFileCallback(files[fileIndex++])); - } - - await Promise.all(actions); - } -} - interface BuildManifest { errors: string[]; warnings: string[]; diff --git a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts index 3c38d000b30c..a1aa19361c5c 100644 --- a/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts +++ b/packages/angular/build/src/tools/vite/middlewares/assets-middleware.ts @@ -236,7 +236,7 @@ export function createBuildAssetsMiddleware( const outputFile = buildResultFiles.get(pathname.slice(1)); if (outputFile) { const contents = - outputFile.origin === 'memory' ? outputFile.contents : readHandler(outputFile.inputPath); + outputFile.origin === 'memory' ? outputFile.contents : readHandler(outputFile.source); const etag = `W/${calculateHash(contents)}`; if (checkAndHandleEtag(req, res, etag)) { diff --git a/packages/angular/build/src/utils/emit-files-to-disk.ts b/packages/angular/build/src/utils/emit-files-to-disk.ts new file mode 100644 index 000000000000..f73afa34829f --- /dev/null +++ b/packages/angular/build/src/utils/emit-files-to-disk.ts @@ -0,0 +1,67 @@ +/** + * @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 { constants, cp, mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { runConcurrent } from './concurrency'; + +/** + * Maximum number of concurrent file system write/copy operations to avoid open file descriptor exhaustion. + */ +const MAX_CONCURRENT_WRITES = 64; + +/** + * Represents a file that can be emitted to disk from memory or copied from an existing path on disk. + */ +export type EmittableFile = + { origin: 'memory'; contents: string | Uint8Array } | { origin: 'disk'; source: string }; + +/** + * Writes or copies a collection of files to disk with bounded concurrency, + * ensuring parent directories are created once per invocation. + * + * @param files The collection of files to emit. + * @param resolveDestinationPath Callback returning the absolute destination path for a file, or `undefined` to skip it. + */ +export async function emitFilesToDisk( + files: readonly T[], + resolveDestinationPath: (file: T) => string | undefined, +): Promise { + if (files.length === 0) { + return; + } + + const directoryExists = new Set(); + + await runConcurrent(files, MAX_CONCURRENT_WRITES, async (file) => { + const fullFilePath = resolveDestinationPath(file); + if (!fullFilePath) { + return; + } + + // Ensure output subdirectories exist + const fileBasePath = dirname(fullFilePath); + if (fileBasePath && !directoryExists.has(fileBasePath)) { + await mkdir(fileBasePath, { recursive: true }); + directoryExists.add(fileBasePath); + } + + if (file.origin === 'memory') { + // Write file contents + await writeFile(fullFilePath, file.contents); + + return; + } + + // Copy file contents + await cp(file.source, fullFilePath, { + mode: constants.COPYFILE_FICLONE, + preserveTimestamps: true, + }); + }); +} diff --git a/packages/angular/build/src/utils/emit-files-to-disk_spec.ts b/packages/angular/build/src/utils/emit-files-to-disk_spec.ts new file mode 100644 index 000000000000..028653028b43 --- /dev/null +++ b/packages/angular/build/src/utils/emit-files-to-disk_spec.ts @@ -0,0 +1,53 @@ +/** + * @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 assert from 'node:assert'; +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { type EmittableFile, emitFilesToDisk } from './emit-files-to-disk'; + +describe('emitFilesToDisk', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-emit-files-test-')); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it('should write memory files and copy disk files while creating nested directories', async () => { + const sourceFile = join(tempRoot, 'source-asset.txt'); + await writeFile(sourceFile, 'from-disk'); + + const files: (EmittableFile & { path: string })[] = [ + { origin: 'memory', path: 'nested/a/mem1.txt', contents: new TextEncoder().encode('mem-1') }, + { origin: 'memory', path: 'nested/a/mem2.txt', contents: 'mem-2' }, + { origin: 'disk', path: 'nested/b/disk1.txt', source: sourceFile }, + { origin: 'disk', path: 'nested/c/disk2.txt', source: sourceFile }, + { origin: 'memory', path: 'skipped/file.txt', contents: 'should-not-exist' }, + ]; + + const outDir = join(tempRoot, 'out'); + await emitFilesToDisk(files, (file) => + file.path.startsWith('skipped/') ? undefined : join(outDir, file.path), + ); + + expect(await readFile(join(outDir, 'nested/a/mem1.txt'), 'utf-8')).toBe('mem-1'); + expect(await readFile(join(outDir, 'nested/a/mem2.txt'), 'utf-8')).toBe('mem-2'); + expect(await readFile(join(outDir, 'nested/b/disk1.txt'), 'utf-8')).toBe('from-disk'); + expect(await readFile(join(outDir, 'nested/c/disk2.txt'), 'utf-8')).toBe('from-disk'); + expect(existsSync(join(outDir, 'skipped/file.txt'))).toBeFalse(); + }); +}); diff --git a/packages/angular/build/src/utils/test-files.ts b/packages/angular/build/src/utils/test-files.ts index 6790d007e220..db480686b66a 100644 --- a/packages/angular/build/src/utils/test-files.ts +++ b/packages/angular/build/src/utils/test-files.ts @@ -6,11 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import * as fs from 'node:fs/promises'; import path from 'node:path'; import { ResultFile } from '../builders/application/results'; import { BuildOutputFileType } from '../tools/esbuild/bundler-files'; -import { emitFilesToDisk } from '../tools/esbuild/utils'; +import { emitFilesToDisk } from './emit-files-to-disk'; /** * Writes a collection of build result files to a specified directory. @@ -20,29 +19,11 @@ import { emitFilesToDisk } from '../tools/esbuild/utils'; * @param files A collection of `ResultFile` objects, representing the build output. * @param testDir The absolute path to the directory where the files should be written. */ -export async function writeTestFiles(files: readonly ResultFile[], testDir: string): Promise { - const directoryExists = new Set(); +export function writeTestFiles(files: readonly ResultFile[], testDir: string): Promise { // Writes the test related output files to disk and ensures the containing directories are present - await emitFilesToDisk(files, async (file) => { - if (file.type !== BuildOutputFileType.Browser && file.type !== BuildOutputFileType.Media) { - return; - } - - const fullFilePath = path.join(testDir, file.path); - - // Ensure output subdirectories exist - const fileBasePath = path.dirname(fullFilePath); - if (fileBasePath && !directoryExists.has(fileBasePath)) { - await fs.mkdir(fileBasePath, { recursive: true }); - directoryExists.add(fileBasePath); - } - - if (file.origin === 'memory') { - // Write file contents - await fs.writeFile(fullFilePath, file.contents); - } else { - // Copy file contents - await fs.copyFile(file.inputPath, fullFilePath, fs.constants.COPYFILE_FICLONE); - } - }); + return emitFilesToDisk(files, (file) => + file.type === BuildOutputFileType.Browser || file.type === BuildOutputFileType.Media + ? path.join(testDir, file.path) + : undefined, + ); } diff --git a/packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts b/packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts index 68347fa0fb24..508b27e2d833 100644 --- a/packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts +++ b/packages/angular_devkit/build_angular/src/builders/extract-i18n/application-extraction.ts @@ -112,7 +112,7 @@ function setupLocalizeExtractor( if (file?.origin === 'memory') { content = textDecoder.decode(file.contents); } else if (file?.origin === 'disk') { - content = readFileSync(file.inputPath, 'utf-8'); + content = readFileSync(file.source, 'utf-8'); } if (content === undefined) { throw new Error('Unknown file requested: ' + requestedPath); From 2a73fc9b5d2cb49411535573b5ef55663c1a4a90 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:59:50 +0000 Subject: [PATCH 2/3] fixup! refactor(@angular/build): unify and deduplicate emitFilesToDisk across builders --- .../angular/build/src/utils/emit-files-to-disk.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/src/utils/emit-files-to-disk.ts b/packages/angular/build/src/utils/emit-files-to-disk.ts index f73afa34829f..b6df74088451 100644 --- a/packages/angular/build/src/utils/emit-files-to-disk.ts +++ b/packages/angular/build/src/utils/emit-files-to-disk.ts @@ -36,7 +36,7 @@ export async function emitFilesToDisk( return; } - const directoryExists = new Set(); + const directoryCreationPromises = new Map>(); await runConcurrent(files, MAX_CONCURRENT_WRITES, async (file) => { const fullFilePath = resolveDestinationPath(file); @@ -46,9 +46,13 @@ export async function emitFilesToDisk( // Ensure output subdirectories exist const fileBasePath = dirname(fullFilePath); - if (fileBasePath && !directoryExists.has(fileBasePath)) { - await mkdir(fileBasePath, { recursive: true }); - directoryExists.add(fileBasePath); + if (fileBasePath) { + let mkdirPromise = directoryCreationPromises.get(fileBasePath); + if (!mkdirPromise) { + mkdirPromise = mkdir(fileBasePath, { recursive: true }); + directoryCreationPromises.set(fileBasePath, mkdirPromise); + } + await mkdirPromise; } if (file.origin === 'memory') { From cfb186ba51bc352c3b9d3a1c28f57edbc1162d7f Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:13:52 +0000 Subject: [PATCH 3/3] fixup! refactor(@angular/build): unify and deduplicate emitFilesToDisk across builders --- packages/angular/build/src/builders/application/build-action.ts | 2 +- .../build/src/builders/library/tests/behavior/watch_spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index e0edf9eec25b..eaae50848a48 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -333,7 +333,7 @@ function* emitOutputResults( incrementalResult.files.push({ path: destination, type: BuildOutputFileType.Browser, - source: source, + source, origin: 'disk', }); } diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts index bf0521e6916f..415f24d269c0 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -594,7 +594,7 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => `, ); }, - async ({ result }) => { + ({ result }) => { expect(result?.success).toBeTrue(); const content = harness.readFile('dist/lib/fesm2022/lib.mjs'); expect(content).toContain('lib-recreated');