diff --git a/packages/angular/build/src/builders/library/builder-state.ts b/packages/angular/build/src/builders/library/builder-state.ts index 9a468c2fb543..24257782b333 100644 --- a/packages/angular/build/src/builders/library/builder-state.ts +++ b/packages/angular/build/src/builders/library/builder-state.ts @@ -32,7 +32,7 @@ export class BuilderState { readonly #watchedCompilationFiles = new Set(); readonly #pendingCompilationFiles = new Set(); readonly #pendingAssetFiles = new Set(); - readonly #assetMatchers?: readonly AssetMatcher[]; + #assetMatchers?: readonly AssetMatcher[]; #emittedBundleFiles = new Set(); #emittedManifestFiles = new Set(); @@ -66,7 +66,7 @@ export class BuilderState { constructor( options?: Partial< - Pick + Pick >, ) { if (options?.tsConfigPath) { @@ -80,8 +80,6 @@ export class BuilderState { this.#watchedCompilationFiles.add(entryPoint.entryFilePath); } } - - this.#assetMatchers = options?.assets ? createAssetMatchers(options.assets) : undefined; } /** Cached unified TypeScript and Angular compilation state. */ @@ -356,6 +354,10 @@ export class BuilderState { effectiveModifiedFiles = this.#pendingAssetFiles; } + if (effectiveModifiedFiles?.size && options.assets.length > 0) { + this.#assetMatchers ??= createAssetMatchers(options.assets); + } + return collectAssetsToEmit( options.assets, options.workspaceRoot, @@ -460,6 +462,7 @@ export class BuilderState { await this.#singleProgramCache?.compilationInstance.close?.(); this.#singleProgramCache = undefined; this.#compilationMode = undefined; + this.#assetMatchers = undefined; this.#hasEmittedTslib = false; this.#hasEmittedAssets = false; this.hasCompilationError = false; diff --git a/packages/angular/build/src/builders/library/builder-state_spec.ts b/packages/angular/build/src/builders/library/builder-state_spec.ts index da7420afc3e5..312301b09df2 100644 --- a/packages/angular/build/src/builders/library/builder-state_spec.ts +++ b/packages/angular/build/src/builders/library/builder-state_spec.ts @@ -305,7 +305,7 @@ describe('BuilderState', () => { workspaceRoot: tempRoot, assets: [{ glob: 'README.md', input: tempRoot, output: '.' }], }; - const state = new BuilderState(options); + const state = new BuilderState(); // Initial build emits README.md const initialAssets = await state.collectAssets(options); @@ -332,6 +332,51 @@ describe('BuilderState', () => { } }); + it('should lazily create asset matchers only on incremental watch asset collection', async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + const tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-builder-state-lazy-')); + + try { + const readmePath = join(tempRoot, 'README.md'); + await writeFile(readmePath, '# Initial'); + + let globAccessCount = 0; + const asset = { + get glob() { + globAccessCount++; + + return 'README.md'; + }, + input: tempRoot, + output: '.', + }; + const options = { + workspaceRoot: tempRoot, + assets: [asset], + }; + + // Constructing BuilderState should not compile asset matchers + const state = new BuilderState(); + expect(globAccessCount).toBe(0); + + state.recordEmittedOutputs({ filesToEmit: [], filesToDelete: [] }); + expect(globAccessCount).toBe(0); + + // First incremental watch collection initializes asset matchers once + const firstCollect = await state.collectAssets(options, new Set([readmePath])); + expect(firstCollect.filesToEmit).toHaveSize(1); + expect(globAccessCount).toBe(1); + + // Subsequent incremental watch collections reuse cached asset matchers + const secondCollect = await state.collectAssets(options, new Set([readmePath])); + expect(secondCollect.filesToEmit).toHaveSize(1); + expect(globAccessCount).toBe(1); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + it('should close compilationInstance and clear cached state on dispose', async () => { const closeSpy = jasmine.createSpy('close').and.resolveTo(); const state = new BuilderState({ packageJsonPath }); diff --git a/packages/angular/build/src/builders/library/builder.ts b/packages/angular/build/src/builders/library/builder.ts index 1e5d08b55b5f..4735eeea3093 100644 --- a/packages/angular/build/src/builders/library/builder.ts +++ b/packages/angular/build/src/builders/library/builder.ts @@ -119,6 +119,7 @@ export async function* executeLibraryBuilder( signal, watchFiles: [ ...buildState.watchedCompilationFiles, + ...normalizedOptions.styleIncludePaths, ...normalizedOptions.assets.map((asset) => asset.input), ], }); @@ -243,6 +244,7 @@ async function* runWatchLoop( signal: AbortSignal, ): AsyncIterableIterator { const { packageJsonPath, clearScreen } = options; + let hasPackageJsonError = false; for await (const changes of watcher) { if (signal.aborted) { @@ -259,20 +261,22 @@ async function* runWatchLoop( const changedFiles = buildState.getAffectedFiles(rawChangedFiles); for (const posixFile of changedFiles) { - if (/\.(?:scss|sass)$/i.test(posixFile)) { + if (/\.(?:scss|sass|css)$/i.test(posixFile)) { resetSassWorkerPoolCaches(); break; } } - // Check if package.json was modified - if (changedFiles.has(packageJsonPath)) { + // Check if package.json was modified or failed to reload on a previous iteration + if (hasPackageJsonError || changedFiles.has(packageJsonPath)) { buildState.hasEmittedManifests = false; try { const packageJson = await loadPackageJson(packageJsonPath); updateWatchedEntryPoints(packageJson, options, buildState); + hasPackageJsonError = false; } catch (error) { assertIsError(error); + hasPackageJsonError = true; await buildState.singleProgramCache?.compilationInstance.update?.(changedFiles); if (buildState.shouldCompileEntryPoints(packageJsonPath, changedFiles)) { buildState.hasCompilationError = true; 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 add8b5151d81..d1a41b244320 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 @@ -847,5 +847,63 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }, ]); }); + + it('should retry reloading broken package.json when another file changes and recover once fixed', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Break package.json with invalid JSON + await harness.writeFile('projects/lib/package.json', '{ invalid json'); + }, + async ({ result }) => { + expect(result?.success).toBeFalse(); + expect(result?.error).toContain("Failed to reload 'package.json'"); + + // Modify a source file while package.json is still broken + await harness.writeFile( + 'projects/lib/src/lib/lib.component.ts', + ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-after-pkg-error', + template: 'After Pkg Error', + }) + export class LibComponent {} + `, + ); + }, + async ({ result }) => { + expect(result?.success).toBeFalse(); + expect(result?.error).toContain("Failed to reload 'package.json'"); + + // Fix package.json + await harness.writeFile( + 'projects/lib/package.json', + JSON.stringify( + { + name: 'lib', + version: '0.0.1', + exports: { + '.': './src/public-api.ts', + }, + }, + null, + 2, + ), + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('lib-after-pkg-error'); + }, + ]); + }); }); });