diff --git a/packages/angular/build/src/builders/library/builder-state.ts b/packages/angular/build/src/builders/library/builder-state.ts new file mode 100644 index 000000000000..c20ef26ea7c5 --- /dev/null +++ b/packages/angular/build/src/builders/library/builder-state.ts @@ -0,0 +1,240 @@ +/** + * @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 { ExecutionResult } from './execution-result'; +import type { + BundleResult, + LibraryCompilationOutput, + NormalizedLibraryOptions, + SingleProgramCache, +} from './types'; + +/** + * Encapsulates incremental compilation state, watched compilation file tracking, + * and execution results across watch-mode rebuilds. + */ +export class BuilderState { + readonly #watchedCompilationFiles = new Set(); + readonly #pendingCompilationFiles = new Set(); + + #executionResult = new ExecutionResult(); + #singleProgramCache?: SingleProgramCache; + #compilationMode?: 'partial' | 'full'; + + /** Map of entry point names to their previous bundle results. */ + readonly previousBundleResults = new Map(); + + /** Set of changed POSIX ESM file paths pending bundling. */ + readonly pendingChangedEsmFiles = new Set(); + + /** Set of changed POSIX TypeScript declaration file paths pending bundling. */ + readonly pendingChangedDtsFiles = new Set(); + + /** Whether the previous build iteration encountered a compilation or diagnostic error. */ + hasCompilationError = false; + + /** Whether package.json and secondary entry point manifests have been emitted. */ + hasEmittedManifests = false; + + /** Whether the last emitted root package.json included a tslib dependency. */ + hasEmittedTslib = false; + + /** Whether static assets have been emitted in a prior iteration. */ + hasEmittedAssets = false; + + /** Whether entry points in package.json changed since the last compilation. */ + hasEntryPointsChanges = false; + + constructor( + options?: Partial< + Pick + >, + ) { + if (options?.tsConfigPath) { + this.#watchedCompilationFiles.add(options.tsConfigPath); + } + if (options?.packageJsonPath) { + this.#watchedCompilationFiles.add(options.packageJsonPath); + } + if (options?.entryPoints) { + for (const entryPoint of options.entryPoints.values()) { + this.#watchedCompilationFiles.add(entryPoint.entryFilePath); + } + } + } + + /** Cached unified TypeScript and Angular compilation state. */ + get singleProgramCache(): SingleProgramCache | undefined { + return this.#singleProgramCache; + } + + /** Effective compilation mode resolved from builder options and tsconfig. */ + get compilationMode(): 'partial' | 'full' | undefined { + return this.#compilationMode; + } + + /** Whether any active entry point bundle imports `tslib`. */ + get hasTslibImport(): boolean { + for (const result of this.previousBundleResults.values()) { + if (result.hasTslibImport) { + return true; + } + } + + return false; + } + + /** + * Set of POSIX file paths tracked for compilation and configuration + * (including `tsconfig.json`, `package.json`, entry points, and referenced source, + * template, and stylesheet files). + */ + get watchedCompilationFiles(): Set { + return this.#watchedCompilationFiles; + } + + /** + * Execution result holding errors, warnings, and logs for the current build iteration. + */ + get executionResult(): ExecutionResult { + return this.#executionResult; + } + + /** + * Resets the execution result for a new build iteration. + * + * @returns The fresh `ExecutionResult` instance for the current build iteration. + */ + resetExecutionResult(): ExecutionResult { + this.#executionResult = new ExecutionResult(); + + return this.#executionResult; + } + + /** + * Determines whether compilation and bundling need to run for the current build iteration. + * Also records incoming modified files so they are retained if the iteration aborts early. + * + * @param packageJsonPath Normalized POSIX path to the library's `package.json`. + * @param modifiedFiles Optional set of modified POSIX file paths in watch mode. + * @returns `true` if entry points should be compiled; otherwise `false`. + */ + shouldCompileEntryPoints(packageJsonPath: string, modifiedFiles?: ReadonlySet): boolean { + if (modifiedFiles) { + for (const file of modifiedFiles) { + if (file !== packageJsonPath) { + this.#pendingCompilationFiles.add(file); + } + } + } + + if ( + !modifiedFiles || + !this.#singleProgramCache || + this.hasCompilationError || + this.hasEntryPointsChanges || + this.pendingChangedEsmFiles.size > 0 || + this.pendingChangedDtsFiles.size > 0 + ) { + return true; + } + + for (const file of this.#pendingCompilationFiles) { + if (this.#watchedCompilationFiles.has(file)) { + return true; + } + } + + this.#pendingCompilationFiles.clear(); + + return false; + } + + /** + * Returns the effective set of modified POSIX file paths for compilation, + * merging any pending compilation files from earlier aborted iterations. + * + * @param modifiedFiles Optional set of modified POSIX file paths from the current iteration. + * @returns Merged set of modified POSIX file paths, or `undefined` for a full compilation. + */ + getCompilationModifiedFiles(modifiedFiles?: ReadonlySet): Set | undefined { + if (!modifiedFiles) { + this.#pendingCompilationFiles.clear(); + + return undefined; + } + + const files = new Set(modifiedFiles); + for (const file of this.#pendingCompilationFiles) { + files.add(file); + } + + return files; + } + + /** + * Records compilation output into the build state, updating the program cache, + * watched compilation files, and pending changed ESM/DTS file sets. + * + * @param output The compilation output produced by `compileLibrary`. + */ + recordCompilationOutput(output: LibraryCompilationOutput): void { + this.#pendingCompilationFiles.clear(); + this.#singleProgramCache = output.cache; + if (this.#compilationMode !== output.compilationMode) { + this.#compilationMode = output.compilationMode; + this.hasEmittedManifests = false; + } + + for (const file of output.referencedFiles) { + this.#watchedCompilationFiles.add(file); + } + for (const file of output.changedEsmFiles) { + this.pendingChangedEsmFiles.add(file); + } + for (const file of output.changedDtsFiles) { + this.pendingChangedDtsFiles.add(file); + } + } + + /** + * Records active bundle results across entry points after compilation and bundling succeed, + * clearing pending compilation/bundle flags. + * + * @param bundleResults Newly bundled results keyed by entry point name. + */ + recordBundleOutput(bundleResults: ReadonlyMap): void { + this.hasCompilationError = false; + this.hasEntryPointsChanges = false; + this.pendingChangedEsmFiles.clear(); + this.pendingChangedDtsFiles.clear(); + + for (const [name, bundleResult] of bundleResults) { + this.previousBundleResults.set(name, bundleResult); + } + } + + /** + * Disposes active compilation resources and clears cached state. + */ + async dispose(): Promise { + await this.#singleProgramCache?.compilationInstance.close?.(); + this.#singleProgramCache = undefined; + this.#compilationMode = undefined; + this.hasEmittedTslib = false; + this.hasEmittedAssets = false; + this.hasCompilationError = false; + this.hasEmittedManifests = false; + this.hasEntryPointsChanges = false; + this.previousBundleResults.clear(); + this.pendingChangedEsmFiles.clear(); + this.pendingChangedDtsFiles.clear(); + this.#pendingCompilationFiles.clear(); + this.#watchedCompilationFiles.clear(); + } +} diff --git a/packages/angular/build/src/builders/library/builder-state_spec.ts b/packages/angular/build/src/builders/library/builder-state_spec.ts new file mode 100644 index 000000000000..9cf744d288bc --- /dev/null +++ b/packages/angular/build/src/builders/library/builder-state_spec.ts @@ -0,0 +1,155 @@ +/** + * @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 { BuilderState } from './builder-state'; +import type { LibraryCompilationOutput, SingleProgramCache } from './types'; + +describe('BuilderState', () => { + const packageJsonPath = '/workspace/projects/my-lib/package.json'; + const tsConfigPath = '/workspace/projects/my-lib/tsconfig.lib.json'; + const entryFilePath = '/workspace/projects/my-lib/src/public-api.ts'; + + function createMockCache(closeSpy?: jasmine.Spy): SingleProgramCache { + return { + compilationInstance: { + close: closeSpy, + } as unknown as SingleProgramCache['compilationInstance'], + esmFiles: new Map(), + dtsFiles: new Map(), + failedFiles: new Set(), + }; + } + + function createMockCompilationOutput( + overrides: Partial = {}, + ): LibraryCompilationOutput { + return { + esmFiles: new Map(), + dtsFiles: new Map(), + changedEsmFiles: new Set(), + changedDtsFiles: new Set(), + referencedFiles: new Set(), + cache: createMockCache(), + hasStylesheetErrors: false, + diagnosePromise: Promise.resolve(), + declarationMap: false, + compilationMode: 'partial', + ...overrides, + }; + } + + describe('ExecutionResult', () => { + it('should track logs, warnings, and errors and reset per iteration', () => { + const state = new BuilderState(); + expect(state.executionResult.hasErrors).toBeFalse(); + expect(state.executionResult.output).toEqual({ success: true }); + + state.executionResult.addLog('Bundling my-lib'); + state.executionResult.addWarning('Unused import'); + state.executionResult.addWarnings(['Deprecated API']); + state.executionResult.addError('Type error'); + state.executionResult.addErrors(['Syntax error']); + + expect(state.executionResult.logs).toEqual(['Bundling my-lib']); + expect(state.executionResult.warnings).toEqual(['Unused import', 'Deprecated API']); + expect(state.executionResult.errors).toEqual(['Type error', 'Syntax error']); + expect(state.executionResult.hasErrors).toBeTrue(); + expect(state.executionResult.output).toEqual({ success: false }); + + const freshResult = state.resetExecutionResult(); + expect(freshResult).toBe(state.executionResult); + expect(freshResult.hasErrors).toBeFalse(); + expect(freshResult.logs).toEqual([]); + expect(freshResult.warnings).toEqual([]); + expect(freshResult.errors).toEqual([]); + }); + }); + + describe('shouldCompileEntryPoints & getCompilationModifiedFiles', () => { + it('should preserve pending compilation files across aborted iterations', () => { + const state = new BuilderState({ + packageJsonPath, + tsConfigPath, + entryPoints: new Map([ + [ + '.', + { + subpath: '.', + name: '.', + displayName: 'my-lib', + bundleName: 'my-lib', + entryFilePath, + isPrimary: true, + }, + ], + ]), + }); + + state.recordCompilationOutput( + createMockCompilationOutput({ + referencedFiles: new Set([entryFilePath]), + }), + ); + + // Iteration 1: entryFilePath changes, but build aborts before recordCompilationOutput + expect( + state.shouldCompileEntryPoints(packageJsonPath, new Set([entryFilePath, packageJsonPath])), + ).toBeTrue(); + + // Iteration 2: only package.json changes on recovery + expect( + state.shouldCompileEntryPoints(packageJsonPath, new Set([packageJsonPath])), + ).toBeTrue(); + expect(state.getCompilationModifiedFiles(new Set([packageJsonPath]))).toEqual( + new Set([packageJsonPath, entryFilePath]), + ); + + // Once compilation output is recorded, pending compilation files are cleared + state.recordCompilationOutput(createMockCompilationOutput()); + expect( + state.shouldCompileEntryPoints(packageJsonPath, new Set([packageJsonPath])), + ).toBeFalse(); + }); + + it('should invalidate hasEmittedManifests when compilationMode changes', () => { + const state = new BuilderState(); + state.hasEmittedManifests = true; + state.recordCompilationOutput(createMockCompilationOutput({ compilationMode: 'partial' })); + expect(state.hasEmittedManifests).toBeFalse(); + + state.hasEmittedManifests = true; + state.recordCompilationOutput(createMockCompilationOutput({ compilationMode: 'partial' })); + expect(state.hasEmittedManifests).toBeTrue(); + + state.recordCompilationOutput(createMockCompilationOutput({ compilationMode: 'full' })); + expect(state.hasEmittedManifests).toBeFalse(); + }); + }); + + describe('dispose', () => { + it('should close compilationInstance and clear cached state on dispose', async () => { + const closeSpy = jasmine.createSpy('close').and.resolveTo(); + const state = new BuilderState({ packageJsonPath }); + state.recordCompilationOutput( + createMockCompilationOutput({ + cache: createMockCache(closeSpy), + changedEsmFiles: new Set(['/a.js']), + changedDtsFiles: new Set(['/a.d.ts']), + }), + ); + + await state.dispose(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(state.singleProgramCache).toBeUndefined(); + expect(state.pendingChangedEsmFiles).toHaveSize(0); + expect(state.pendingChangedDtsFiles).toHaveSize(0); + expect(state.watchedCompilationFiles).toHaveSize(0); + }); + }); +}); diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index c3c2149adf5b..fa8e717c9776 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -8,6 +8,7 @@ import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; import type { logging } from '@angular-devkit/core'; +import type { ComponentStylesheetBundler } from '../../tools/esbuild/angular/component-stylesheets'; import { resetSassWorkerPoolCaches, shutdownSassWorkerPool, @@ -23,13 +24,14 @@ import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { getSupportedBrowsers } from '../../utils/supported-browsers'; import { assertCompatibleAngularVersion } from '../../utils/version'; import type { BuildWatcher } from '../../utils/watcher'; +import { BuilderState } from './builder-state'; +import type { ExecutionResult } from './execution-result'; import { normalizeLibraryOptions } from './options'; -import type { BuildActionResult } from './pipeline/build-action'; +import type { BuildActionContext } from './pipeline/build-action'; import { updateWatchedEntryPoints } from './pipeline/entry-points'; -import type { createComponentStylesheetBundlerForLibrary } from './pipeline/stylesheet-bundler'; import { loadPackageJson } from './pipeline/utils'; import type { Schema as LibraryBuilderOptions } from './schema'; -import type { NormalizedLibraryOptions, SingleBuildState } from './types'; +import type { NormalizedLibraryOptions } from './types'; /** * Executes the library builder to compile, bundle, and package an Angular library into the Angular Package Format (APF). @@ -63,8 +65,6 @@ export async function* executeLibraryBuilder( projectRoot, outputPath, deleteOutputPath, - packageJsonPath, - tsConfigPath, watch: isWatchMode, poll, cacheOptions, @@ -89,17 +89,14 @@ export async function* executeLibraryBuilder( } // Dynamically lazy-loaded to prevent importing dependencies at the top level. - const [ - { buildAction, createSingleBuildState, hasModifiedWatchedFile }, - { createComponentStylesheetBundlerForLibrary }, - ] = await Promise.all([ + const [{ buildAction }, { createComponentStylesheetBundlerForLibrary }] = await Promise.all([ import('./pipeline/build-action'), import('./pipeline/stylesheet-bundler'), ]); - let stylesheetBundler: ReturnType | undefined; + let stylesheetBundler: ComponentStylesheetBundler | undefined; let watcher: BuildWatcher | undefined; - const buildState = createSingleBuildState(); + const buildState = new BuilderState(normalizedOptions); try { const browsers = getSupportedBrowsers(projectRoot, logger); @@ -110,13 +107,6 @@ export async function* executeLibraryBuilder( target, ); - // Track all referenced compilation files for watch mode - const watchedCompilationFiles = new Set([tsConfigPath, packageJsonPath]); - - for (const entryPoint of normalizedOptions.entryPoints.values()) { - watchedCompilationFiles.add(entryPoint.entryFilePath); - } - if (isWatchMode) { const { setupWatcher } = await import('../../utils/watcher'); watcher = await setupWatcher({ @@ -127,7 +117,10 @@ export async function* executeLibraryBuilder( poll, preserveSymlinks, signal, - watchFiles: watchedCompilationFiles, + watchFiles: [ + ...buildState.watchedCompilationFiles, + ...normalizedOptions.assets.map((asset) => asset.input), + ], }); context.addTeardown?.(() => void watcher?.close()); @@ -139,11 +132,10 @@ export async function* executeLibraryBuilder( { options: normalizedOptions, stylesheetBundler, - watchedCompilationFiles, isWatchMode, - context, buildState, }, + logger, withProgress, watcher, buildAction, @@ -163,12 +155,10 @@ export async function* executeLibraryBuilder( watcher, normalizedOptions, stylesheetBundler, - watchedCompilationFiles, - context, + logger, withProgress, buildState, buildAction, - hasModifiedWatchedFile, signal, ); } finally { @@ -178,7 +168,7 @@ export async function* executeLibraryBuilder( await Promise.allSettled([ watcher?.close(), stylesheetBundler?.dispose(), - buildState.singleProgramCache?.compilationInstance.close?.(), + buildState.dispose(), ]); } } @@ -189,6 +179,7 @@ export async function* executeLibraryBuilder( * * @param message Progress spinner message to display while building. * @param actionContext The build action context and state. + * @param logger The Architect logger API. * @param withProgress Progress wrapper function (`withSpinner` or `withNoProgress`). * @param watcher Optional file watcher for watch mode. * @param buildAction The library build action function to execute. @@ -196,33 +187,34 @@ export async function* executeLibraryBuilder( */ async function executeBuild( message: string, - actionContext: import('./pipeline/build-action').BuildActionContext, + actionContext: BuildActionContext, + logger: logging.LoggerApi, withProgress: typeof withSpinner, watcher: BuildWatcher | undefined, buildAction: typeof import('./pipeline/build-action').buildAction, ): Promise { const startTime = process.hrtime.bigint(); - const { context, watchedCompilationFiles, isWatchMode, options } = actionContext; + const { buildState, isWatchMode, options } = actionContext; try { const result = await withProgress(message, () => buildAction(actionContext)); - logMessages(context.logger, result); + logMessages(logger, result); - const success = result.errors.length === 0; - logBuildResult(context.logger, startTime, success, options.outputPath); - if (success && isWatchMode) { + const { output } = result; + logBuildResult(logger, startTime, output.success, options.outputPath); + if (output.success && isWatchMode) { logCumulativeDurations(); } - return { success }; + return output; } catch (error) { assertIsError(error); - logBuildResult(context.logger, startTime, false); + logBuildResult(logger, startTime, false); return { success: false, error: error.message }; } finally { if (watcher) { - watcher.add(Array.from(watchedCompilationFiles)); + watcher.add(Array.from(buildState.watchedCompilationFiles)); const { assetsToEmit } = actionContext; if (assetsToEmit?.length) { @@ -238,32 +230,29 @@ async function executeBuild( * @param watcher The file watcher instance. * @param options The normalized library builder options with POSIX paths. * @param stylesheetBundler The component stylesheet bundler instance. - * @param watchedCompilationFiles Set of POSIX file paths tracked for compilation. - * @param context The Architect builder context. + * @param logger The Architect logger API. * @param withProgress Progress wrapper function (`withSpinner` or `withNoProgress`). * @param buildState The state preserved across incremental builds. * @param buildAction The library build action function to execute. - * @param hasModifiedWatchedFile Helper to check whether watched compilation files changed. - * @param signal Optional abort signal to terminate the watch loop. + * @param signal Abort signal to terminate the watch loop. + * @returns An async iterator yielding builder output results. */ async function* runWatchLoop( watcher: BuildWatcher, options: NormalizedLibraryOptions, - stylesheetBundler: ReturnType, - watchedCompilationFiles: Set, - context: BuilderContext, + stylesheetBundler: ComponentStylesheetBundler, + logger: logging.LoggerApi, withProgress: typeof withSpinner, - buildState: SingleBuildState, + buildState: BuilderState, buildAction: typeof import('./pipeline/build-action').buildAction, - hasModifiedWatchedFile: typeof import('./pipeline/build-action').hasModifiedWatchedFile, - signal?: AbortSignal, + signal: AbortSignal, ): AsyncIterableIterator { const { collectAssetsToEmit } = await import('./pipeline/assets'); const { workspaceRoot, packageJsonPath, assets, clearScreen } = options; for await (const changes of watcher) { - if (signal?.aborted) { + if (signal.aborted) { break; } @@ -299,29 +288,17 @@ async function* runWatchLoop( } // Check if package.json was modified - let hasPackageJsonChanges = false; if (changedFiles.has(packageJsonPath)) { + buildState.hasEmittedManifests = false; try { const packageJson = await loadPackageJson(packageJsonPath); - if (!packageJson.name) { - throw new Error(`The package.json at '${packageJsonPath}' must contain a 'name'.`); - } - options.packageJson = packageJson; - options.packageName = packageJson.name; - - updateWatchedEntryPoints( - packageJson, - options, - buildState, - watchedCompilationFiles, - packageJsonPath, - ); - - hasPackageJsonChanges = true; + updateWatchedEntryPoints(packageJson, options, buildState); } catch (error) { assertIsError(error); await buildState.singleProgramCache?.compilationInstance.update?.(changedFiles); - buildState.hasEmittedManifests = false; + if (buildState.shouldCompileEntryPoints(packageJsonPath, changedFiles)) { + buildState.hasCompilationError = true; + } yield { success: false, @@ -331,11 +308,10 @@ async function* runWatchLoop( } } - const hasSourceChanges = - !buildState.singleProgramCache || - buildState.hasCompilationError || - buildState.hasEntryPointsChanges || - hasModifiedWatchedFile(changedFiles, watchedCompilationFiles, packageJsonPath); + const hasSourceChanges = buildState.shouldCompileEntryPoints(packageJsonPath, changedFiles); + if (!hasSourceChanges) { + await buildState.singleProgramCache?.compilationInstance.update?.(changedFiles); + } const assetsToEmit = await collectAssetsToEmit( assets, @@ -343,7 +319,7 @@ async function* runWatchLoop( buildState.hasEmittedAssets ? changedFiles : undefined, ); - if (!hasSourceChanges && !hasPackageJsonChanges && assetsToEmit.length === 0) { + if (!hasSourceChanges && buildState.hasEmittedManifests && assetsToEmit.length === 0) { continue; } @@ -352,13 +328,12 @@ async function* runWatchLoop( { options, stylesheetBundler, - watchedCompilationFiles, isWatchMode: true, - context, buildState, modifiedFiles: changedFiles, assetsToEmit, }, + logger, withProgress, watcher, buildAction, @@ -370,12 +345,9 @@ async function* runWatchLoop( * Logs informational, warning, and error messages produced during a build iteration. * * @param logger The Architect logger API. - * @param result The build action result containing logs, warnings, and errors. + * @param result The build execution result containing logs, warnings, and errors. */ -function logMessages( - logger: logging.LoggerApi, - { logs, warnings, errors }: BuildActionResult, -): void { +function logMessages(logger: logging.LoggerApi, { logs, warnings, errors }: ExecutionResult): void { if (logs.length) { logger.info(logs.join('\n')); } diff --git a/packages/angular/build/src/builders/library/execution-result.ts b/packages/angular/build/src/builders/library/execution-result.ts new file mode 100644 index 000000000000..4f9826360503 --- /dev/null +++ b/packages/angular/build/src/builders/library/execution-result.ts @@ -0,0 +1,75 @@ +/** + * @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 type { BuilderOutput } from '@angular-devkit/architect'; + +/** + * Encapsulates the diagnostic messages (`errors`, `warnings`, and `logs`) and + * builder output status for a single library build iteration. + */ +export class ExecutionResult { + readonly #errors: string[] = []; + readonly #warnings: string[] = []; + readonly #logs: string[] = []; + + /** Formatted error messages produced during the build iteration. */ + get errors(): readonly string[] { + return this.#errors; + } + + /** Formatted warning messages produced during the build iteration. */ + get warnings(): readonly string[] { + return this.#warnings; + } + + /** Informational log messages produced during the build iteration. */ + get logs(): readonly string[] { + return this.#logs; + } + + /** Whether any errors have been recorded in the build iteration. */ + get hasErrors(): boolean { + return this.#errors.length > 0; + } + + /** Architect builder output status for the build iteration. */ + get output(): BuilderOutput { + return { + success: !this.hasErrors, + }; + } + + /** Records an informational log message. */ + addLog(value: string): void { + this.#logs.push(value); + } + + /** Records a formatted error message. */ + addError(error: string): void { + this.#errors.push(error); + } + + /** Records multiple formatted error messages. */ + addErrors(errors: Iterable): void { + for (const error of errors) { + this.#errors.push(error); + } + } + + /** Records a formatted warning message. */ + addWarning(warning: string): void { + this.#warnings.push(warning); + } + + /** Records multiple formatted warning messages. */ + addWarnings(warnings: Iterable): void { + for (const warning of warnings) { + this.#warnings.push(warning); + } + } +} diff --git a/packages/angular/build/src/builders/library/index.ts b/packages/angular/build/src/builders/library/index.ts index 6a6ddf7a1ce7..da1f5d9385a3 100644 --- a/packages/angular/build/src/builders/library/index.ts +++ b/packages/angular/build/src/builders/library/index.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { Builder, createBuilder } from '@angular-devkit/architect'; +import { type Builder, createBuilder } from '@angular-devkit/architect'; import { executeLibraryBuilder } from './builder'; import type { Schema as LibraryBuilderOptions } from './schema'; 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 f121048b1fc7..734ea23c544e 100644 --- a/packages/angular/build/src/builders/library/pipeline/build-action.ts +++ b/packages/angular/build/src/builders/library/pipeline/build-action.ts @@ -6,44 +6,20 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { BuilderContext } from '@angular-devkit/architect'; import path from 'node:path'; import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; import { emitFilesToDisk } from '../../../utils/disk-files'; import { assertIsError } from '../../../utils/error'; -import type { NormalizedLibraryOptions, SingleBuildState } from '../types'; +import type { BuilderState } from '../builder-state'; +import type { ExecutionResult } from '../execution-result'; +import type { NormalizedLibraryOptions } from '../types'; import { collectAssetsToEmit } from './assets'; -import { bundleEntryPoints } from './bundler'; -import { type LibraryCompilationDiagnostics, compileLibrary } from './compilation'; +import { type BundleEntryPointsOutput, bundleEntryPoints } from './bundler'; +import { compileLibrary } from './compilation'; import { collectEntryPointsToBundle } from './entry-points'; import { generatePackageManifests } from './package-manifests'; import type { DiskOutputFile, OutputFile } from './utils'; -/** - * Result of executing a single library build iteration. - */ -export interface BuildActionResult { - /** Formatted error messages produced during the build. */ - errors: string[]; - - /** Formatted warning messages produced during the build. */ - warnings: string[]; - - /** Informational log messages produced during the build. */ - logs: string[]; -} - -/** - * Creates a fresh {@link SingleBuildState} instance. - */ -export function createSingleBuildState(): SingleBuildState { - return { - previousBundleResults: new Map(), - pendingChangedEsmFiles: new Set(), - pendingChangedDtsFiles: new Set(), - }; -} - /** * Context required to execute a single library build iteration. */ @@ -51,35 +27,23 @@ export interface BuildActionContext { /** Normalized options for the library build with POSIX paths. */ options: NormalizedLibraryOptions; - /** The Architect builder context. */ - context: BuilderContext; - /** Bundler instance used to process component stylesheets. */ stylesheetBundler: ComponentStylesheetBundler; /** Whether the builder is running in watch mode. */ isWatchMode: boolean; - /** - * Set of POSIX file paths tracked for compilation and configuration - * (including `tsconfig.json`, `package.json`, entry points, and referenced source, - * template, and stylesheet files). Updated during compilation and used to determine - * whether file changes require recompiling entry points, excluding asset files so - * asset-only changes do not trigger code compilation. - */ - watchedCompilationFiles: Set; - - /** State preserved across incremental builds in watch mode. */ - buildState: SingleBuildState; + /** Build state tracking compilation caches and emitted outputs. */ + buildState: BuilderState; /** Set of POSIX file paths modified since the last build iteration. */ modifiedFiles?: Set; /** - * Asset files to emit to disk for the current build iteration. + * Asset files to emit for the current build iteration. * Pre-collected in the watch loop to avoid redundant asset matching, or resolved and * populated by `buildAction` when omitted (such as during the initial build) so the - * caller can register their source paths with the file watcher. + * caller can register their POSIX source paths with the file watcher. */ assetsToEmit?: DiskOutputFile[]; } @@ -90,138 +54,84 @@ export interface BuildActionContext { * manifest generation, and asset copying. * * @param actionContext The build action state and configuration. - * @returns A promise resolving to the build action result containing errors, warnings, and logs. + * @returns A promise resolving to the build iteration's `ExecutionResult`. */ -export async function buildAction(actionContext: BuildActionContext): Promise { - const { - options, - stylesheetBundler, - isWatchMode, - watchedCompilationFiles, - buildState, - modifiedFiles, - } = actionContext; - - const errors: string[] = []; - const warnings: string[] = []; - const logs: string[] = []; +export async function buildAction(actionContext: BuildActionContext): Promise { + const { options, stylesheetBundler, isWatchMode, buildState, modifiedFiles } = actionContext; + const result = buildState.resetExecutionResult(); const { packageJsonPath } = options; - const { pendingChangedEsmFiles, pendingChangedDtsFiles } = buildState; - if (!modifiedFiles || modifiedFiles.has(packageJsonPath)) { - buildState.hasEmittedManifests = false; - } - - const shouldCompileEntryPoints = - !modifiedFiles || - !buildState.singleProgramCache || - buildState.hasCompilationError || - buildState.hasEntryPointsChanges || - pendingChangedEsmFiles.size > 0 || - pendingChangedDtsFiles.size > 0 || - hasModifiedWatchedFile(modifiedFiles, watchedCompilationFiles, packageJsonPath); + const shouldCompileEntryPoints = buildState.shouldCompileEntryPoints( + packageJsonPath, + modifiedFiles, + ); try { - if (!buildState.hasEmittedManifests) { - const dependencyErrors = verifyAllowedDependencies(options); - if (dependencyErrors.length > 0) { - errors.push(...dependencyErrors); - - return { errors, warnings, logs }; - } + if (!buildState.hasEmittedManifests && !verifyAllowedDependencies(options, result)) { + return result; } const filesToEmit: OutputFile[] = []; if (shouldCompileEntryPoints) { buildState.hasCompilationError = true; - buildState.hasEntryPointsChanges = false; + + const compilationOutput = await compileLibrary( + options, + stylesheetBundler, + buildState, + buildState.getCompilationModifiedFiles(modifiedFiles), + ); + buildState.recordCompilationOutput(compilationOutput); const { esmFiles, dtsFiles, - changedEsmFiles, - changedDtsFiles, - referencedFiles, - cache, hasStylesheetErrors, diagnosePromise, declarationMap, compilationMode, - } = await compileLibrary( - options.entryPoints.values(), - options, - stylesheetBundler, - buildState.singleProgramCache, - modifiedFiles, - ); - buildState.singleProgramCache = cache; - if (buildState.compilationMode !== compilationMode) { - buildState.compilationMode = compilationMode; - buildState.hasEmittedManifests = false; - } - - for (const file of referencedFiles) { - watchedCompilationFiles.add(file); - } - for (const file of changedEsmFiles) { - pendingChangedEsmFiles.add(file); - } - for (const file of changedDtsFiles) { - pendingChangedDtsFiles.add(file); - } + } = compilationOutput; - let bundleOutput: Awaited> | undefined; - let diagnostics: LibraryCompilationDiagnostics; if (hasStylesheetErrors) { - diagnostics = await diagnosePromise; - } else { - const itemsToBundle = collectEntryPointsToBundle(options, buildState, logs); - try { - [bundleOutput, diagnostics] = await Promise.all([ - bundleEntryPoints(itemsToBundle, esmFiles, dtsFiles, { - ...options, - declarationMap, - compilationMode, - }), - diagnosePromise, - ]); - } catch (error) { - assertIsError(error); - // Prioritize TypeScript/Angular diagnostic errors over secondary bundler failures. - diagnostics = await diagnosePromise; - if (diagnostics.errors.length === 0) { - errors.push(error.message); - } - } - } - - warnings.push(...diagnostics.warnings, ...(bundleOutput?.warnings ?? [])); - if (diagnostics.errors.length > 0 || errors.length > 0 || !bundleOutput) { - errors.push(...diagnostics.errors); + await diagnosePromise; - return { errors, warnings, logs }; + return result; } - buildState.hasCompilationError = false; - pendingChangedEsmFiles.clear(); - pendingChangedDtsFiles.clear(); + const itemsToBundle = collectEntryPointsToBundle(options, buildState); + let bundleOutput: BundleEntryPointsOutput; + try { + [bundleOutput] = await Promise.all([ + bundleEntryPoints(itemsToBundle, esmFiles, dtsFiles, { + ...options, + declarationMap, + compilationMode, + }), + diagnosePromise, + ]); + } catch (error) { + assertIsError(error); + // Prioritize TypeScript/Angular diagnostic errors over secondary bundler failures. + await diagnosePromise; + if (!result.hasErrors) { + result.addError(error.message); + } - filesToEmit.push(...bundleOutput.filesToEmit); - for (const [name, bundleResult] of bundleOutput.bundleResults) { - buildState.previousBundleResults.set(name, bundleResult); + return result; } - } - let hasTslibImport = false; - for (const entryPoint of options.entryPoints.values()) { - if (buildState.previousBundleResults.get(entryPoint.name)?.hasTslibImport) { - hasTslibImport = true; - break; + result.addWarnings(bundleOutput.warnings); + if (result.hasErrors) { + return result; } + + filesToEmit.push(...bundleOutput.filesToEmit); + buildState.recordBundleOutput(bundleOutput.bundleResults); } + const { hasTslibImport } = buildState; if (!buildState.hasEmittedManifests || buildState.hasEmittedTslib !== hasTslibImport) { filesToEmit.push( ...(await generatePackageManifests( @@ -249,33 +159,10 @@ export async function buildAction(actionContext: BuildActionContext): Promise, - watchedCompilationFiles: ReadonlySet, - packageJsonPath: string, -): boolean { - for (const file of modifiedFiles) { - if (file !== packageJsonPath && watchedCompilationFiles.has(file)) { - return true; - } - } - - return false; + return result; } /** @@ -283,24 +170,29 @@ export function hasModifiedWatchedFile( * permitted by `allowedNonPeerDependencies`. * * @param options The normalized library builder options. - * @returns An array of error messages for any disallowed dependencies. + * @param result The execution result to record any disallowed dependency errors into. + * @returns `true` if all dependencies are allowed; otherwise `false`. */ -function verifyAllowedDependencies(options: NormalizedLibraryOptions): string[] { +function verifyAllowedDependencies( + options: NormalizedLibraryOptions, + result: ExecutionResult, +): boolean { const { packageJson, allowedNonPeerDependencies } = options; const dependencies = { ...(packageJson.dependencies ?? {}), ...(packageJson.optionalDependencies ?? {}), }; - const errors: string[] = []; + let isValid = true; for (const dep of Object.keys(dependencies)) { if (!allowedNonPeerDependencies.some((regex) => regex.test(dep))) { - errors.push( + result.addError( `Dependency '${dep}' must be explicitly allowed using the 'allowedNonPeerDependencies' option, ` + `or moved to 'peerDependencies' in 'package.json'.`, ); + isValid = false; } } - return errors; + return isValid; } diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index 367777667fe3..38b28f442df6 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -15,80 +15,41 @@ import { import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; import { useTypeChecking } from '../../../utils/environment-options'; import { toPosixPath } from '../../../utils/path'; -import type { NormalizedEntryPoint, NormalizedLibraryOptions, SingleProgramCache } from '../types'; +import type { BuilderState } from '../builder-state'; +import type { LibraryCompilationOutput, NormalizedLibraryOptions } from '../types'; import { isDeclarationFile, isDeclarationSourceMapFile } from './utils'; const EMITTED_EXTENSIONS = ['.js', '.mjs', '.cjs', '.d.ts', '.d.mts', '.d.cts']; -/** - * Formatted diagnostic messages produced during library compilation. - */ -export interface LibraryCompilationDiagnostics { - /** Formatted compilation error messages. */ - readonly errors: string[]; - - /** Formatted compilation warning messages. */ - readonly warnings: string[]; -} - -/** - * Output of the unified library compilation step. - */ -export interface LibraryCompilationOutput { - /** Read-only map of emitted JavaScript files keyed by POSIX path. */ - readonly esmFiles: ReadonlyMap; - - /** Read-only map of emitted TypeScript declaration files keyed by POSIX path. */ - readonly dtsFiles: ReadonlyMap; - - /** Set of POSIX JavaScript file paths that were modified or newly emitted in this iteration. */ - readonly changedEsmFiles: ReadonlySet; - - /** Set of POSIX declaration file paths that were modified or newly emitted in this iteration. */ - readonly changedDtsFiles: ReadonlySet; - - /** Set of all POSIX source and dependency file paths referenced during compilation. */ - readonly referencedFiles: ReadonlySet; - - /** Cached compilation state for subsequent incremental builds. */ - readonly cache: SingleProgramCache; - - /** Whether any component stylesheet bundling errors occurred. */ - readonly hasStylesheetErrors: boolean; - - /** Promise that resolves to formatted compilation diagnostic errors and warnings. */ - readonly diagnosePromise: Promise; - - /** Effective declarationMap setting resolved from builder options and tsconfig. */ - readonly declarationMap: boolean; - - /** Effective compilationMode setting resolved from builder options and tsconfig. */ - readonly compilationMode: 'partial' | 'full'; -} - /** * Compiles all library entry points in a single TypeScript and Angular compilation pass. * - * @param entryPoints The collection of normalized library entry points to compile. * @param options The normalized library builder options with POSIX paths. * @param stylesheetBundler The component stylesheet bundler instance. - * @param cached Optional cached compilation state from a previous build iteration. + * @param buildState The build state tracking compilation caches and diagnostics. * @param modifiedFiles Optional set of modified POSIX file paths for incremental compilation. - * @returns A promise resolving to the compilation output containing emitted files and diagnostics. + * @returns A promise resolving to the compilation output containing emitted files and cache state. */ export async function compileLibrary( - entryPoints: Iterable, options: NormalizedLibraryOptions, stylesheetBundler: ComponentStylesheetBundler, - cached?: SingleProgramCache, + buildState: BuilderState, modifiedFiles?: Set, ): Promise { - const { tsConfigPath, compilationMode, preserveSymlinks, colors, declarationMap, cacheOptions } = - options; + const { + tsConfigPath, + entryPoints, + compilationMode, + preserveSymlinks, + colors, + declarationMap, + cacheOptions, + } = options; + const cached = buildState.singleProgramCache; const entryPathsMap: Record = {}; const rootFiles: string[] = []; - for (const ep of entryPoints) { + for (const ep of entryPoints.values()) { entryPathsMap[ep.displayName] = [ep.entryFilePath]; rootFiles.push(ep.entryFilePath); } @@ -98,12 +59,13 @@ export async function compileLibrary( try { let effectiveModifiedFiles = modifiedFiles; - if (cached?.failedFiles?.size) { + if (cached && cached.failedFiles.size > 0) { stylesheetBundler.invalidate(cached.failedFiles); - effectiveModifiedFiles = new Set(modifiedFiles); + const merged = new Set(modifiedFiles); for (const file of cached.failedFiles) { - effectiveModifiedFiles.add(file); + merged.add(file); } + effectiveModifiedFiles = merged; } if (effectiveModifiedFiles && effectiveModifiedFiles.size > 0) { @@ -168,6 +130,7 @@ export async function compileLibrary( stylesheetErrors, stylesheetWarnings, colors, + buildState, ); const esmFiles = cached?.esmFiles ?? new Map(); @@ -179,7 +142,7 @@ export async function compileLibrary( allReferencedFiles.add(toPosixPath(ref)); } - if (effectiveModifiedFiles) { + if (cached && effectiveModifiedFiles) { for (const modifiedFile of effectiveModifiedFiles) { if (allReferencedFiles.has(modifiedFile)) { continue; @@ -207,21 +170,15 @@ export async function compileLibrary( for (const { filename, contents } of emittedFiles) { const normalized = toPosixPath(filename); - if (normalized.endsWith('.map')) { - const isDtsMap = isDeclarationSourceMapFile(normalized); - const targetMap = isDtsMap ? dtsFiles : esmFiles; - if (targetMap.get(normalized) !== contents) { - targetMap.set(normalized, contents); - (isDtsMap ? changedDtsFiles : changedEsmFiles).add(normalized.slice(0, -4)); - } - } else if (isDeclarationFile(normalized)) { - if (dtsFiles.get(normalized) !== contents) { - changedDtsFiles.add(normalized); - dtsFiles.set(normalized, contents); - } - } else if (esmFiles.get(normalized) !== contents) { - changedEsmFiles.add(normalized); - esmFiles.set(normalized, contents); + const isMap = normalized.endsWith('.map'); + const isDts = isMap ? isDeclarationSourceMapFile(normalized) : isDeclarationFile(normalized); + const targetFiles = isDts ? dtsFiles : esmFiles; + + if (targetFiles.get(normalized) !== contents) { + targetFiles.set(normalized, contents); + (isDts ? changedDtsFiles : changedEsmFiles).add( + isMap ? normalized.slice(0, -4) : normalized, + ); } } @@ -235,7 +192,7 @@ export async function compileLibrary( compilationInstance, esmFiles, dtsFiles, - failedFiles: failedFiles.size > 0 ? failedFiles : undefined, + failedFiles, }, hasStylesheetErrors, diagnosePromise, @@ -251,49 +208,43 @@ export async function compileLibrary( } /** - * Formats stylesheet bundling diagnostics and TypeScript/Angular diagnostics. + * Formats stylesheet bundling diagnostics and TypeScript/Angular diagnostics + * and records them on the build state's execution result. * * @param compilationInstance The active Angular compilation instance. * @param stylesheetErrors List of stylesheet bundling errors. * @param stylesheetWarnings List of stylesheet bundling warnings. * @param colors Whether diagnostic messages should be formatted with ANSI colors. - * @returns A promise resolving to the formatted diagnostic errors and warnings. + * @param buildState The build state whose execution result receives the formatted diagnostics. */ async function runDiagnosticsAndFormat( compilationInstance: AngularCompilation, stylesheetErrors: PartialMessage[], stylesheetWarnings: PartialMessage[], colors: boolean, -): Promise { - const errorsOut: string[] = []; - const warningsOut: string[] = []; - - if (stylesheetErrors.length > 0) { - const formatted = await formatMessages(stylesheetErrors, { kind: 'error', color: colors }); - errorsOut.push(...formatted); - } else if (useTypeChecking) { - const { errors, warnings } = await compilationInstance.diagnoseFiles(); - if (errors?.length) { - const formatted = await formatMessages(errors, { kind: 'error', color: colors }); - errorsOut.push(...formatted); + buildState: BuilderState, +): Promise { + const { executionResult } = buildState; + const errors = [...stylesheetErrors]; + const warnings: PartialMessage[] = []; + + if (errors.length === 0 && useTypeChecking) { + const diagnostics = await compilationInstance.diagnoseFiles(); + if (diagnostics.errors?.length) { + errors.push(...diagnostics.errors); } - - if (warnings?.length) { - const formatted = await formatMessages(warnings, { kind: 'warning', color: colors }); - warningsOut.push(...formatted); + if (diagnostics.warnings?.length) { + warnings.push(...diagnostics.warnings); } } - if (stylesheetWarnings.length > 0) { - const formattedStyleWarnings = await formatMessages(stylesheetWarnings, { - kind: 'warning', - color: colors, - }); - warningsOut.push(...formattedStyleWarnings); + warnings.push(...stylesheetWarnings); + + if (errors.length > 0) { + executionResult.addErrors(await formatMessages(errors, { kind: 'error', color: colors })); } - return { - errors: errorsOut, - warnings: warningsOut, - }; + if (warnings.length > 0) { + executionResult.addWarnings(await formatMessages(warnings, { kind: 'warning', color: colors })); + } } diff --git a/packages/angular/build/src/builders/library/pipeline/entry-points.ts b/packages/angular/build/src/builders/library/pipeline/entry-points.ts index 5cff4ca82136..f50824c5d9f3 100644 --- a/packages/angular/build/src/builders/library/pipeline/entry-points.ts +++ b/packages/angular/build/src/builders/library/pipeline/entry-points.ts @@ -8,12 +8,8 @@ import path from 'node:path'; import { toPosixPath } from '../../../utils/path'; -import type { - NormalizedEntryPoint, - NormalizedLibraryOptions, - PackageJsonData, - SingleBuildState, -} from '../types'; +import type { BuilderState } from '../builder-state'; +import type { NormalizedEntryPoint, NormalizedLibraryOptions, PackageJsonData } from '../types'; import type { BundleEntryPointInput } from './bundler'; /** @@ -24,7 +20,7 @@ import type { BundleEntryPointInput } from './bundler'; * @returns The sanitized bundle base name. */ export function getEntryPointBundleName(packageName: string, entryPointName = '.'): string { - const isPrimary = !entryPointName || entryPointName === '.'; + const isPrimary = entryPointName === '.'; const pkgName = packageName[0] === '@' ? packageName.slice(1) : packageName; const epName = isPrimary ? pkgName : `${pkgName}-${entryPointName}`; @@ -50,11 +46,7 @@ function normalizeEntryPoint( projectRoot: string, packageName: string, ): NormalizedEntryPoint { - const name = isPrimary - ? '.' - : posixKey[0] === '.' && posixKey[1] === '/' - ? posixKey.slice(2) - : posixKey; + const name = isPrimary ? '.' : posixKey.startsWith('./') ? posixKey.slice(2) : posixKey; if (name !== '.' && (path.posix.isAbsolute(name) || name.includes('..'))) { throw new Error( @@ -139,7 +131,7 @@ export function normalizeEntryPoints( exportsRecord = { '.': rawExports }; } else { // If an object has no keys starting with '.', Node.js treats the entire object as the '.' entry point. - const hasSubpathKeys = Object.keys(rawExports as object).some((k) => k.startsWith('.')); + const hasSubpathKeys = Object.keys(rawExports).some((k) => k.startsWith('.')); exportsRecord = hasSubpathKeys ? (rawExports as Record) : { '.': rawExports }; } @@ -190,10 +182,7 @@ export function normalizeEntryPoints( usedBundleNames.set(entryPoint.bundleName, key); entryPoints.set(entryPoint.name, entryPoint); - - if (entryPoint.isPrimary) { - hasPrimary = true; - } + hasPrimary ||= isPrimary; } if (!hasPrimary) { @@ -206,7 +195,8 @@ export function normalizeEntryPoints( } /** - * Determines whether two sets of normalized entry points differ in keys, POSIX entry paths, or subpaths. + * Determines whether two sets of normalized entry points differ in keys, POSIX entry paths, + * subpaths, bundle names, or display names. * * @param oldEntryPoints The previous map of normalized entry points. * @param newEntryPoints The new map of normalized entry points. @@ -242,29 +232,36 @@ export function haveEntryPointsChanged( * @param packageJson The reloaded `package.json` data. * @param options The normalized library builder options. * @param buildState The state preserved across incremental builds. - * @param watchedCompilationFiles Set of POSIX file paths tracked for compilation. - * @param packageJsonPath Normalized POSIX path to `package.json`. */ export function updateWatchedEntryPoints( packageJson: PackageJsonData, options: NormalizedLibraryOptions, - buildState: SingleBuildState, - watchedCompilationFiles: Set, - packageJsonPath: string, + buildState: BuilderState, ): void { + const { packageJsonPath } = options; + if (!packageJson.name) { + throw new Error(`The package.json at '${packageJsonPath}' must contain a 'name'.`); + } + const newEntryPoints = normalizeEntryPoints( packageJson.exports, options.projectRoot, packageJsonPath, - options.packageName, + packageJson.name, ); + const { watchedCompilationFiles, previousBundleResults } = buildState; + if (haveEntryPointsChanged(options.entryPoints, newEntryPoints)) { + for (const oldEntryPoint of options.entryPoints.values()) { + watchedCompilationFiles.delete(oldEntryPoint.entryFilePath); + } + for (const entryPoint of newEntryPoints.values()) { watchedCompilationFiles.add(entryPoint.entryFilePath); } - for (const name of buildState.previousBundleResults.keys()) { + for (const name of previousBundleResults.keys()) { const oldEp = options.entryPoints.get(name); const newEp = newEntryPoints.get(name); if ( @@ -273,13 +270,16 @@ export function updateWatchedEntryPoints( newEp.entryFilePath !== oldEp.entryFilePath || newEp.bundleName !== oldEp.bundleName ) { - buildState.previousBundleResults.delete(name); + previousBundleResults.delete(name); } } options.entryPoints = newEntryPoints; buildState.hasEntryPointsChanges = true; } + + options.packageJson = packageJson; + options.packageName = packageJson.name; } /** @@ -288,15 +288,14 @@ export function updateWatchedEntryPoints( * * @param options The normalized library builder options. * @param buildState The state preserved across incremental builds. - * @param logs Array of informational log messages to append to when an entry point is queued. * @returns The list of entry points that require bundling in the current build iteration. */ export function collectEntryPointsToBundle( options: NormalizedLibraryOptions, - buildState: SingleBuildState, - logs: string[], + buildState: BuilderState, ): BundleEntryPointInput[] { - const { pendingChangedEsmFiles, pendingChangedDtsFiles, previousBundleResults } = buildState; + const { pendingChangedEsmFiles, pendingChangedDtsFiles, previousBundleResults, executionResult } = + buildState; const itemsToBundle: BundleEntryPointInput[] = []; for (const entryPoint of options.entryPoints.values()) { @@ -309,7 +308,7 @@ export function collectEntryPointsToBundle( hasEntryPointChanges(previousBundleResult.dtsModuleIds, pendingChangedDtsFiles); if (hasEsmChanges || hasDtsChanges) { - logs.push(`Compiling ${entryPoint.displayName}...`); + executionResult.addLog(`Compiling ${entryPoint.displayName}...`); itemsToBundle.push({ entryPoint, hasEsmChanges, diff --git a/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts b/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts index 834b7aec6835..e4653ef56e2b 100644 --- a/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts +++ b/packages/angular/build/src/builders/library/pipeline/entry-points_spec.ts @@ -6,16 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import { join, resolve } from 'node:path'; -import { toPosixPath } from '../../../utils/path'; +import { join } from 'node:path'; +import { BuilderState } from '../builder-state'; import type { BundleResult, NormalizedEntryPoint, NormalizedLibraryOptions, PackageJsonData, - SingleBuildState, } from '../types'; import { + collectEntryPointsToBundle, getEntryPointBundleName, haveEntryPointsChanged, normalizeEntryPoints, @@ -23,10 +23,19 @@ import { } from './entry-points'; describe('entry-points pipeline', () => { - const projectRoot = resolve('/workspace/projects/my-lib'); + const projectRoot = '/workspace/projects/my-lib'; const packageJsonPath = join(projectRoot, 'package.json'); const packageName = '@my-scope/my-lib'; + function createBundleResult(overrides: Partial = {}): BundleResult { + return { + esmModuleIds: new Set(), + dtsModuleIds: new Set(), + hasTslibImport: false, + ...overrides, + }; + } + describe('getEntryPointBundleName', () => { it('should compute bundle name for primary entry point without scope', () => { expect(getEntryPointBundleName('my-lib')).toBe('my-lib'); @@ -505,6 +514,8 @@ describe('entry-points pipeline', () => { }); describe('updateWatchedEntryPoints', () => { + const tsConfigPath = join(projectRoot, 'tsconfig.lib.json'); + it('should update options, watched files, build state, and prune removed bundles when entry points change', () => { const initialEntryPoints = normalizeEntryPoints( { @@ -520,17 +531,14 @@ describe('entry-points pipeline', () => { entryPoints: initialEntryPoints, projectRoot, packageName, + packageJsonPath, + tsConfigPath, + assets: [], } as unknown as NormalizedLibraryOptions; - const buildState = { - previousBundleResults: new Map([ - ['.', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], - ['old-feature', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], - ]), - hasEntryPointsChanges: false, - } as unknown as SingleBuildState; - - const watchedCompilationFiles = new Set(); + const buildState = new BuilderState(options); + buildState.previousBundleResults.set('.', createBundleResult()); + buildState.previousBundleResults.set('old-feature', createBundleResult()); const newPackageJson: PackageJsonData = { name: packageName, @@ -540,13 +548,7 @@ describe('entry-points pipeline', () => { }, }; - updateWatchedEntryPoints( - newPackageJson, - options, - buildState, - watchedCompilationFiles, - packageJsonPath, - ); + updateWatchedEntryPoints(newPackageJson, options, buildState); expect(buildState.hasEntryPointsChanges).toBeTrue(); expect(options.entryPoints.has('new-feature')).toBeTrue(); @@ -554,16 +556,17 @@ describe('entry-points pipeline', () => { expect(buildState.previousBundleResults.has('old-feature')).toBeFalse(); expect(buildState.previousBundleResults.has('.')).toBeTrue(); expect( - watchedCompilationFiles.has(toPosixPath(join(projectRoot, 'src/public-api.ts'))), + buildState.watchedCompilationFiles.has(join(projectRoot, 'src/public-api.ts')), ).toBeTrue(); expect( - watchedCompilationFiles.has( - toPosixPath(join(projectRoot, 'new-feature/src/public-api.ts')), - ), + buildState.watchedCompilationFiles.has(join(projectRoot, 'old-feature/src/public-api.ts')), + ).toBeFalse(); + expect( + buildState.watchedCompilationFiles.has(join(projectRoot, 'new-feature/src/public-api.ts')), ).toBeTrue(); }); - it('should not mutate buildState or options when entry points do not change', () => { + it('should not mutate buildState or options.entryPoints when entry points do not change', () => { const initialEntryPoints = normalizeEntryPoints( { '.': './src/public-api.ts', @@ -578,17 +581,14 @@ describe('entry-points pipeline', () => { entryPoints: initialEntryPoints, projectRoot, packageName, + packageJsonPath, + tsConfigPath, + assets: [], } as unknown as NormalizedLibraryOptions; - const buildState = { - previousBundleResults: new Map([ - ['.', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], - ['feature', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], - ]), - hasEntryPointsChanges: false, - } as unknown as SingleBuildState; - - const watchedCompilationFiles = new Set(); + const buildState = new BuilderState(options); + buildState.previousBundleResults.set('.', createBundleResult()); + buildState.previousBundleResults.set('feature', createBundleResult()); const samePackageJson: PackageJsonData = { name: packageName, @@ -598,17 +598,10 @@ describe('entry-points pipeline', () => { }, }; - updateWatchedEntryPoints( - samePackageJson, - options, - buildState, - watchedCompilationFiles, - packageJsonPath, - ); + updateWatchedEntryPoints(samePackageJson, options, buildState); expect(buildState.hasEntryPointsChanges).toBeFalse(); expect(options.entryPoints).toBe(initialEntryPoints); - expect(watchedCompilationFiles).toHaveSize(0); expect(buildState.previousBundleResults).toHaveSize(2); }); @@ -626,16 +619,13 @@ describe('entry-points pipeline', () => { entryPoints: initialEntryPoints, projectRoot, packageName, + packageJsonPath, + tsConfigPath, + assets: [], } as unknown as NormalizedLibraryOptions; - const buildState = { - previousBundleResults: new Map([ - ['.', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], - ]), - hasEntryPointsChanges: false, - } as unknown as SingleBuildState; - - const watchedCompilationFiles = new Set(); + const buildState = new BuilderState(options); + buildState.previousBundleResults.set('.', createBundleResult()); const updatedPackageJson: PackageJsonData = { name: packageName, @@ -644,13 +634,7 @@ describe('entry-points pipeline', () => { }, }; - updateWatchedEntryPoints( - updatedPackageJson, - options, - buildState, - watchedCompilationFiles, - packageJsonPath, - ); + updateWatchedEntryPoints(updatedPackageJson, options, buildState); expect(buildState.hasEntryPointsChanges).toBeTrue(); expect(buildState.previousBundleResults.has('.')).toBeFalse(); @@ -658,7 +642,7 @@ describe('entry-points pipeline', () => { join(projectRoot, 'src/other-api.ts'), ); expect( - watchedCompilationFiles.has(toPosixPath(join(projectRoot, 'src/other-api.ts'))), + buildState.watchedCompilationFiles.has(join(projectRoot, 'src/other-api.ts')), ).toBeTrue(); }); @@ -676,16 +660,13 @@ describe('entry-points pipeline', () => { entryPoints: initialEntryPoints, projectRoot, packageName, + packageJsonPath, + tsConfigPath, + assets: [], } as unknown as NormalizedLibraryOptions; - const buildState = { - previousBundleResults: new Map([ - ['.', { esmModuleIds: new Set(), dtsModuleIds: new Set() }], - ]), - hasEntryPointsChanges: false, - } as unknown as SingleBuildState; - - const watchedCompilationFiles = new Set(); + const buildState = new BuilderState(options); + buildState.previousBundleResults.set('.', createBundleResult()); const updatedPackageJson: PackageJsonData = { name: '@renamed-scope/my-lib', @@ -694,19 +675,108 @@ describe('entry-points pipeline', () => { }, }; - options.packageName = updatedPackageJson.name; - - updateWatchedEntryPoints( - updatedPackageJson, - options, - buildState, - watchedCompilationFiles, - packageJsonPath, - ); + updateWatchedEntryPoints(updatedPackageJson, options, buildState); expect(buildState.hasEntryPointsChanges).toBeTrue(); expect(buildState.previousBundleResults.has('.')).toBeFalse(); + expect(options.packageName).toBe('@renamed-scope/my-lib'); expect(options.entryPoints.get('.')?.bundleName).toBe('renamed-scope-my-lib'); }); + + it('should throw when updated packageJson is missing a name field', () => { + const options = { + entryPoints: new Map(), + projectRoot, + packageName, + packageJsonPath, + assets: [], + } as unknown as NormalizedLibraryOptions; + const buildState = new BuilderState(options); + + expect(() => + updateWatchedEntryPoints({} as PackageJsonData, options, buildState), + ).toThrowError(/must contain a 'name'/); + }); + }); + + describe('collectEntryPointsToBundle', () => { + it('should return all entry points with ESM and DTS changes on initial build', () => { + const entryPoints = normalizeEntryPoints( + { + '.': './src/public-api.ts', + './testing': './testing/src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ); + const options = { entryPoints } as unknown as NormalizedLibraryOptions; + const buildState = new BuilderState(options); + + const items = collectEntryPointsToBundle(options, buildState); + + expect(items).toHaveSize(2); + expect(items[0]).toEqual({ + entryPoint: jasmine.objectContaining({ name: '.' }), + previousBundleResult: undefined, + hasEsmChanges: true, + hasDtsChanges: true, + }); + expect(items[1]).toEqual({ + entryPoint: jasmine.objectContaining({ name: 'testing' }), + previousBundleResult: undefined, + hasEsmChanges: true, + hasDtsChanges: true, + }); + expect(buildState.executionResult.logs).toEqual([ + 'Compiling @my-scope/my-lib...', + 'Compiling @my-scope/my-lib/testing...', + ]); + }); + + it('should only return entry points whose ESM or DTS module IDs intersect pending changes', () => { + const entryPoints = normalizeEntryPoints( + { + '.': './src/public-api.ts', + './testing': './testing/src/public-api.ts', + }, + projectRoot, + packageJsonPath, + packageName, + ); + const options = { entryPoints } as unknown as NormalizedLibraryOptions; + const buildState = new BuilderState(options); + + const primaryEntryJs = join(projectRoot, 'src/public-api.js'); + const primaryEntryDts = join(projectRoot, 'src/public-api.d.ts'); + const testingEntryJs = join(projectRoot, 'testing/src/public-api.js'); + const testingEntryDts = join(projectRoot, 'testing/src/public-api.d.ts'); + + buildState.previousBundleResults.set( + '.', + createBundleResult({ + esmModuleIds: new Set([primaryEntryJs]), + dtsModuleIds: new Set([primaryEntryDts]), + }), + ); + buildState.previousBundleResults.set( + 'testing', + createBundleResult({ + esmModuleIds: new Set([testingEntryJs]), + dtsModuleIds: new Set([testingEntryDts]), + }), + ); + + buildState.pendingChangedEsmFiles.add(testingEntryJs); + + const items = collectEntryPointsToBundle(options, buildState); + + expect(items).toHaveSize(1); + expect(items[0]?.entryPoint.name).toBe('testing'); + expect(items[0]?.hasEsmChanges).toBeTrue(); + expect(items[0]?.hasDtsChanges).toBeFalse(); + expect(items[0]?.previousBundleResult).toBe(buildState.previousBundleResults.get('testing')); + expect(buildState.executionResult.logs).toEqual(['Compiling @my-scope/my-lib/testing...']); + }); }); }); diff --git a/packages/angular/build/src/builders/library/pipeline/package-manifests.ts b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts index 98ed88a506e6..5238ccca76c2 100644 --- a/packages/angular/build/src/builders/library/pipeline/package-manifests.ts +++ b/packages/angular/build/src/builders/library/pipeline/package-manifests.ts @@ -78,13 +78,10 @@ export async function generatePackageManifests( }; if (hasTslibImport && !rawPackageJson.dependencies?.['tslib']) { - const tslibVersion = await getAngularTslibRange(options.workspaceRoot); - if (tslibVersion) { - distPackageJson.dependencies = { - ...rawPackageJson.dependencies, - tslib: tslibVersion, - }; - } + distPackageJson.dependencies = { + ...rawPackageJson.dependencies, + tslib: await getAngularTslibRange(options.workspaceRoot), + }; } // Retain scripts if keepLifecycleScripts is set @@ -159,6 +156,11 @@ export async function generatePackageManifests( /** * Creates or updates export conditions for an entry point, preserving custom user-defined conditions. + * + * @param existingConditions Existing export conditions from the user's `package.json`. + * @param dtsPath Relative POSIX path to the bundled `.d.ts` file. + * @param fesmPath Relative POSIX path to the bundled FESM `.mjs` file. + * @returns The merged export conditions object. */ function createExportConditions( existingConditions: unknown, diff --git a/packages/angular/build/src/builders/library/types.ts b/packages/angular/build/src/builders/library/types.ts index 2deeddf589e5..2d2d9f61891d 100644 --- a/packages/angular/build/src/builders/library/types.ts +++ b/packages/angular/build/src/builders/library/types.ts @@ -9,7 +9,7 @@ import type { AngularCompilation } from '../../tools/angular/compilation'; import type { StylesheetPluginsass } from '../../tools/esbuild/stylesheets/stylesheet-plugin-factory'; import type { normalizeAssetPatterns } from '../../utils'; -import type { normalizeCacheOptions } from '../../utils/normalize-cache'; +import type { NormalizedCachedOptions } from '../../utils/normalize-cache'; import type { PostcssConfiguration } from '../../utils/postcss-configuration'; /** @@ -68,12 +68,16 @@ export interface NormalizedLibraryOptions { /** Absolute POSIX path to the library project root directory. */ projectRoot: string; + /** Name of the npm package from the library's `package.json`. */ packageName: string; + + /** Parsed contents of the library's `package.json`. */ packageJson: PackageJsonData; /** Absolute POSIX path to the build output directory. */ outputPath: string; + /** Whether to delete the output directory before building. */ deleteOutputPath: boolean; /** Absolute POSIX path to the library's `package.json`. */ @@ -82,29 +86,58 @@ export interface NormalizedLibraryOptions { /** Absolute POSIX path to the TypeScript configuration file. */ tsConfigPath: string; + /** Map of normalized entry points keyed by entry point name. */ entryPoints: Map; + + /** Default stylesheet language for inline component styles. */ inlineStyleLanguage: 'css' | 'less' | 'sass' | 'scss'; /** Absolute POSIX paths to style preprocessor include directories. */ styleIncludePaths: string[]; + /** Optional Sass preprocessor configuration. */ sass?: StylesheetPluginsass; /** Normalized asset patterns with POSIX `input` and `output` paths. */ assets: ReturnType; + /** Angular compilation mode (`'partial'` or `'full'`). */ compilationMode?: 'partial' | 'full'; + + /** Whether to generate source maps for TypeScript declaration files. */ declarationMap?: boolean; + + /** Regular expressions matching allowed non-peer dependencies. */ allowedNonPeerDependencies: RegExp[]; + + /** Whether to preserve lifecycle scripts in the emitted `package.json`. */ keepLifecycleScripts: boolean; + + /** Whether to run the build in watch mode. */ watch: boolean; + + /** Optional file watching polling interval in milliseconds. */ poll?: number; + + /** Whether to preserve symbolic links during resolution and bundling. */ preserveSymlinks: boolean; + + /** Whether to display build progress in the terminal. */ progress: boolean; + + /** Whether to clear the terminal screen before watch rebuilds. */ clearScreen?: boolean; - cacheOptions: ReturnType; + + /** Normalized build cache configuration. */ + cacheOptions: NormalizedCachedOptions; + + /** Resolved PostCSS configuration, if present in the workspace. */ postcssConfiguration?: { config: PostcssConfiguration; configPath: string }; + + /** Resolved Tailwind CSS configuration, if applicable. */ tailwindConfiguration?: { file: string; package: string }; + + /** Whether terminal color output is enabled. */ colors: boolean; } @@ -135,41 +168,41 @@ export interface SingleProgramCache { /** In-memory map of emitted TypeScript declaration files keyed by POSIX path. */ readonly dtsFiles: Map; - /** Set of POSIX file paths that failed during stylesheet bundling or compilation. */ - readonly failedFiles?: ReadonlySet; + /** Set of POSIX file paths that failed during stylesheet bundling. */ + readonly failedFiles: ReadonlySet; } /** - * State preserved across incremental builds in watch mode. + * Output of the unified library compilation step. */ -export interface SingleBuildState { - /** Cached unified TypeScript and Angular compilation state. */ - singleProgramCache?: SingleProgramCache; +export interface LibraryCompilationOutput { + /** Read-only map of emitted JavaScript files keyed by POSIX path. */ + readonly esmFiles: ReadonlyMap; - /** Effective compilation mode resolved from builder options and tsconfig. */ - compilationMode?: 'partial' | 'full'; + /** Read-only map of emitted TypeScript declaration files keyed by POSIX path. */ + readonly dtsFiles: ReadonlyMap; - /** Map of entry point names to their previous bundle results. */ - previousBundleResults: Map; + /** Set of POSIX JavaScript file paths that were added, modified, or deleted in this iteration. */ + readonly changedEsmFiles: ReadonlySet; - /** Set of changed POSIX ESM file paths pending bundling. */ - pendingChangedEsmFiles: Set; + /** Set of POSIX declaration file paths that were added, modified, or deleted in this iteration. */ + readonly changedDtsFiles: ReadonlySet; - /** Set of changed POSIX TypeScript declaration file paths pending bundling. */ - pendingChangedDtsFiles: Set; + /** Set of all POSIX source and dependency file paths referenced during compilation. */ + readonly referencedFiles: ReadonlySet; - /** Whether the previous build iteration encountered a compilation or diagnostic error. */ - hasCompilationError?: boolean; + /** Cached compilation state for subsequent incremental builds. */ + readonly cache: SingleProgramCache; - /** Whether package.json and secondary entry point manifests have been emitted. */ - hasEmittedManifests?: boolean; + /** Whether any component stylesheet bundling errors occurred. */ + readonly hasStylesheetErrors: boolean; - /** Whether the last emitted root package.json included a tslib dependency. */ - hasEmittedTslib?: boolean; + /** Promise that resolves when compilation diagnostics have been formatted and recorded. */ + readonly diagnosePromise: Promise; - /** Whether static assets have been emitted in a prior iteration. */ - hasEmittedAssets?: boolean; + /** Effective declarationMap setting resolved from builder options and tsconfig. */ + readonly declarationMap: boolean; - /** Whether entry points in package.json changed since the last compilation. */ - hasEntryPointsChanges?: boolean; + /** Effective compilationMode setting resolved from builder options and tsconfig. */ + readonly compilationMode: 'partial' | 'full'; }