diff --git a/packages/angular/build/src/builders/library/pipeline/compilation.ts b/packages/angular/build/src/builders/library/pipeline/compilation.ts index 163c6194d6ad..07dd0665c08a 100644 --- a/packages/angular/build/src/builders/library/pipeline/compilation.ts +++ b/packages/angular/build/src/builders/library/pipeline/compilation.ts @@ -7,13 +7,16 @@ */ import { type PartialMessage, formatMessages } from 'esbuild'; -import { existsSync } from 'node:fs'; +import { access, constants } from 'node:fs/promises'; import { type AngularCompilation, + DiagnosticModes, + type EmitFileResult, createAngularCompilation, } from '../../../tools/angular/compilation'; import type { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; import { useTypeChecking } from '../../../utils/environment-options'; +import { assertIsError } from '../../../utils/error'; import { toPosixPath } from '../../../utils/path'; import type { BuilderState } from '../builder-state'; import type { LibraryCompilationOutput, NormalizedLibraryOptions } from '../types'; @@ -70,12 +73,22 @@ export async function compileLibrary( if (effectiveModifiedFiles && effectiveModifiedFiles.size > 0) { await compilationInstance.update?.(effectiveModifiedFiles); - buildState.clearFileReferences(effectiveModifiedFiles); } const stylesheetWarnings: PartialMessage[] = []; const stylesheetErrors: PartialMessage[] = []; const failedFiles = new Set(); + const clearedReferenceFiles = new Set(); + const clearPreviousReferencesOnce = (targetFile: string): void => { + if (!cached) { + return; + } + const posixTarget = toPosixPath(targetFile); + if (!clearedReferenceFiles.has(posixTarget)) { + clearedReferenceFiles.add(posixTarget); + buildState.clearFileReferences([posixTarget]); + } + }; const hostOptions = { modifiedFiles: effectiveModifiedFiles, @@ -86,17 +99,23 @@ export async function compileLibrary( ): Promise { const result = stylesheetFile ? await stylesheetBundler.bundleFile(stylesheetFile) - : await stylesheetBundler.bundleInline(data, containingFile); - - if (result.referencedFiles) { - buildState.addFileReferences(stylesheetFile ?? containingFile, result.referencedFiles); - } + : await stylesheetBundler.bundleInline( + data, + containingFile, + containingFile.endsWith('.html') ? 'css' : undefined, + ); if (result.warnings.length > 0) { stylesheetWarnings.push(...result.warnings); } + const referenceTarget = stylesheetFile ?? containingFile; + if (result.errors?.length) { + if (result.referencedFiles) { + buildState.addFileReferences(referenceTarget, result.referencedFiles); + } + stylesheetErrors.push(...result.errors); failedFiles.add(toPosixPath(containingFile)); if (stylesheetFile) { @@ -106,12 +125,22 @@ export async function compileLibrary( return ''; } + clearPreviousReferencesOnce(referenceTarget); + + if (result.referencedFiles) { + buildState.addFileReferences(referenceTarget, result.referencedFiles); + } + return result.contents; }, processWebWorker: () => '', }; - const { referencedFiles, compilerOptions } = await compilationInstance.initialize( + const { + referencedFiles, + compilerOptions, + warnings: initWarnings, + } = await compilationInstance.initialize( tsConfigPath, hostOptions, { @@ -126,6 +155,10 @@ export async function compileLibrary( 'library', ); + if (initWarnings?.length) { + stylesheetWarnings.unshift(...initWarnings); + } + const hasStylesheetErrors = stylesheetErrors.length > 0; const emittedFiles = hasStylesheetErrors ? [] : await compilationInstance.emitAffectedFiles(); const diagnosePromise = runDiagnosticsAndFormat( @@ -138,49 +171,14 @@ export async function compileLibrary( const esmFiles = cached?.esmFiles ?? new Map(); const dtsFiles = cached?.dtsFiles ?? new Map(); - const changedEsmFiles = new Set(); - const changedDtsFiles = new Set(); const posixReferencedFiles = new Set(referencedFiles.map(toPosixPath)); - - if (cached && effectiveModifiedFiles) { - for (const modifiedFile of effectiveModifiedFiles) { - if (posixReferencedFiles.has(modifiedFile)) { - continue; - } - - const basePathWithoutExt = modifiedFile.replace(/(?:\.d\.[cm]?ts|\.[cm]?[jt]sx?)$/i, ''); - if (basePathWithoutExt === modifiedFile || existsSync(modifiedFile)) { - continue; - } - - for (const ext of EMITTED_EXTENSIONS) { - const outputPath = `${basePathWithoutExt}${ext}`; - const mapPath = `${outputPath}.map`; - if (esmFiles.delete(outputPath)) { - changedEsmFiles.add(outputPath); - } - if (dtsFiles.delete(outputPath)) { - changedDtsFiles.add(outputPath); - } - esmFiles.delete(mapPath); - dtsFiles.delete(mapPath); - } - } - } - - for (const { filename, contents } of emittedFiles) { - const normalized = toPosixPath(filename); - 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, - ); - } - } + const { changedEsmFiles, changedDtsFiles } = await updateEmittedFileCaches( + emittedFiles, + esmFiles, + dtsFiles, + posixReferencedFiles, + cached ? effectiveModifiedFiles : undefined, + ); return { esmFiles, @@ -207,13 +205,84 @@ export async function compileLibrary( } } +/** + * Updates the in-memory ESM and DTS file maps with newly emitted files and prunes deleted + * source files, returning the sets of changed ESM and DTS file paths. + * + * @param emittedFiles Newly emitted files from the Angular compilation. + * @param esmFiles In-memory map of POSIX ESM file paths to contents. + * @param dtsFiles In-memory map of POSIX declaration file paths to contents. + * @param posixReferencedFiles Set of POSIX file paths currently referenced by the program. + * @param modifiedFiles Optional set of modified POSIX file paths in incremental builds. + * @returns A promise resolving to the sets of changed ESM and DTS file paths. + */ +async function updateEmittedFileCaches( + emittedFiles: Iterable, + esmFiles: Map, + dtsFiles: Map, + posixReferencedFiles: ReadonlySet, + modifiedFiles?: ReadonlySet, +): Promise<{ changedEsmFiles: Set; changedDtsFiles: Set }> { + const changedEsmFiles = new Set(); + const changedDtsFiles = new Set(); + + const removalChecks: Promise[] = []; + for (const modifiedFile of modifiedFiles ?? []) { + if (posixReferencedFiles.has(modifiedFile)) { + continue; + } + + const basePathWithoutExt = modifiedFile.replace(/(?:\.d\.[cm]?ts|\.[cm]?[jt]sx?)$/i, ''); + if (basePathWithoutExt === modifiedFile) { + continue; + } + + removalChecks.push( + access(modifiedFile, constants.F_OK).then( + () => {}, + () => { + for (const ext of EMITTED_EXTENSIONS) { + const outputPath = `${basePathWithoutExt}${ext}`; + const mapPath = `${outputPath}.map`; + if (esmFiles.delete(outputPath)) { + changedEsmFiles.add(outputPath); + } + if (dtsFiles.delete(outputPath)) { + changedDtsFiles.add(outputPath); + } + esmFiles.delete(mapPath); + dtsFiles.delete(mapPath); + } + }, + ), + ); + } + if (removalChecks.length > 0) { + await Promise.all(removalChecks); + } + + for (const { filename, contents } of emittedFiles) { + const normalized = toPosixPath(filename); + 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); + } + } + + return { changedEsmFiles, changedDtsFiles }; +} + /** * 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 stylesheetWarnings List of initialization and stylesheet bundling warnings. * @param colors Whether diagnostic messages should be formatted with ANSI colors. * @param buildState The build state whose execution result receives the formatted diagnostics. */ @@ -228,23 +297,38 @@ async function runDiagnosticsAndFormat( const errors = [...stylesheetErrors]; const warnings: PartialMessage[] = []; - if (errors.length === 0 && useTypeChecking) { - const diagnostics = await compilationInstance.diagnoseFiles(); + try { + const diagnostics = await compilationInstance.diagnoseFiles( + useTypeChecking ? DiagnosticModes.All : DiagnosticModes.All & ~DiagnosticModes.Semantic, + ); if (diagnostics.errors?.length) { errors.push(...diagnostics.errors); } if (diagnostics.warnings?.length) { warnings.push(...diagnostics.warnings); } + } catch (error) { + assertIsError(error); + errors.push({ + text: error.message, + location: null, + }); } warnings.push(...stylesheetWarnings); - if (errors.length > 0) { - executionResult.addErrors(await formatMessages(errors, { kind: 'error', color: colors })); - } + try { + if (errors.length > 0) { + executionResult.addErrors(await formatMessages(errors, { kind: 'error', color: colors })); + } - if (warnings.length > 0) { - executionResult.addWarnings(await formatMessages(warnings, { kind: 'warning', color: colors })); + if (warnings.length > 0) { + executionResult.addWarnings( + await formatMessages(warnings, { kind: 'warning', color: colors }), + ); + } + } catch (error) { + assertIsError(error); + executionResult.addError(error.message); } } diff --git a/packages/angular/build/src/builders/library/pipeline/compilation_spec.ts b/packages/angular/build/src/builders/library/pipeline/compilation_spec.ts new file mode 100644 index 000000000000..143a73358e57 --- /dev/null +++ b/packages/angular/build/src/builders/library/pipeline/compilation_spec.ts @@ -0,0 +1,394 @@ +/** + * @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 { PartialMessage } from 'esbuild'; +import type { AngularHostOptions } from '../../../tools/angular/angular-host'; +import { + type AngularCompilation, + type AngularCompilationResult, + DiagnosticModes, + type EmitFileResult, +} from '../../../tools/angular/compilation'; +import type { + ComponentStylesheetBundler, + ComponentStylesheetResult, +} from '../../../tools/esbuild/angular/component-stylesheets'; +import { BuilderState } from '../builder-state'; +import type { NormalizedLibraryOptions } from '../types'; +import { compileLibrary } from './compilation'; + +describe('compileLibrary pipeline', () => { + const workspaceRoot = '/workspace'; + const projectRoot = '/workspace/projects/my-lib'; + + function createOptions( + overrides: Partial = {}, + ): NormalizedLibraryOptions { + return { + workspaceRoot, + projectRoot, + packageName: 'my-lib', + packageJson: { name: 'my-lib' }, + outputPath: '/workspace/dist/my-lib', + deleteOutputPath: true, + packageJsonPath: '/workspace/projects/my-lib/package.json', + tsConfigPath: '/workspace/projects/my-lib/tsconfig.lib.json', + entryPoints: new Map([ + [ + '.', + { + subpath: '.', + name: '.', + displayName: 'my-lib', + bundleName: 'my-lib', + entryFilePath: '/workspace/projects/my-lib/src/public-api.ts', + isPrimary: true, + }, + ], + ]), + inlineStyleLanguage: 'scss', + styleIncludePaths: [], + assets: [], + compilationMode: 'partial', + declarationMap: true, + allowedNonPeerDependencies: [/^tslib$/], + keepLifecycleScripts: false, + watch: false, + preserveSymlinks: false, + progress: false, + cacheOptions: { + enabled: false, + basePath: '/workspace/.angular/cache', + path: '/workspace/.angular/cache/my-lib', + }, + colors: false, + ...overrides, + }; + } + + function createStyleResult( + overrides: { + errors?: PartialMessage[]; + warnings?: PartialMessage[]; + contents?: string; + referencedFiles?: Set; + } = {}, + ): ComponentStylesheetResult { + return { + errors: undefined, + warnings: [], + contents: '', + outputFiles: [], + metafile: { inputs: {}, outputs: {} }, + referencedFiles: new Set(), + externalImports: new Set(), + platform: 'browser', + initialFiles: new Map(), + ...overrides, + } as unknown as ComponentStylesheetResult; + } + + function createMockStateWithCompilation( + compilationInstance: AngularCompilation, + esmFiles = new Map(), + dtsFiles = new Map(), + ): BuilderState { + const state = new BuilderState(); + state.recordCompilationOutput({ + esmFiles, + dtsFiles, + changedEsmFiles: new Set(), + changedDtsFiles: new Set(), + referencedFiles: new Set(), + cache: { + compilationInstance, + esmFiles, + dtsFiles, + failedFiles: new Set(), + }, + hasStylesheetErrors: false, + diagnosePromise: Promise.resolve(), + declarationMap: true, + compilationMode: 'partial', + }); + state.resetExecutionResult(); + + return state; + } + + it('should forward warnings from compilationInstance.initialize()', async () => { + const compilationInstance: AngularCompilation = { + initialize: jasmine.createSpy('initialize').and.resolveTo({ + compilerOptions: { declarationMap: true, compilationMode: 'partial' }, + referencedFiles: ['/workspace/projects/my-lib/src/public-api.ts'], + warnings: [ + { + text: 'Unsupported tsconfig option warning', + location: { + file: '/workspace/projects/my-lib/tsconfig.lib.json', + line: 2, + column: 4, + }, + }, + ], + } satisfies AngularCompilationResult), + emitAffectedFiles: () => [], + diagnoseFiles: jasmine.createSpy('diagnoseFiles').and.resolveTo({ + warnings: [ + { + text: 'NG8107: Optional chain warning', + location: { + file: '/workspace/projects/my-lib/src/lib/lib.component.html', + line: 1, + column: 1, + }, + }, + ], + }), + }; + + const bundler = { + invalidate: jasmine.createSpy('invalidate'), + bundleFile: jasmine.createSpy('bundleFile'), + bundleInline: jasmine.createSpy('bundleInline'), + } as unknown as ComponentStylesheetBundler; + + const state = createMockStateWithCompilation(compilationInstance); + const output = await compileLibrary(createOptions(), bundler, state); + await output.diagnosePromise; + + expect(compilationInstance.diagnoseFiles).toHaveBeenCalledWith(DiagnosticModes.All); + + const warningsText = state.executionResult.warnings.join('\n'); + expect(warningsText).toContain('Unsupported tsconfig option warning'); + expect(warningsText).toContain('NG8107: Optional chain warning'); + }); + + it('should run diagnoseFiles when stylesheetErrors are present and catch diagnoseFiles rejections', async () => { + const compilationInstance: AngularCompilation = { + initialize: jasmine + .createSpy('initialize') + .and.callFake(async (_tsconfig: string, hostOptions: AngularHostOptions) => { + await hostOptions.transformStylesheet( + '.broken {', + '/workspace/projects/my-lib/src/lib/lib.component.ts', + '/workspace/projects/my-lib/src/lib/lib.component.scss', + ); + + return { + compilerOptions: { declarationMap: true, compilationMode: 'partial' }, + referencedFiles: ['/workspace/projects/my-lib/src/lib/lib.component.ts'], + }; + }), + emitAffectedFiles: jasmine.createSpy('emitAffectedFiles').and.returnValue([]), + diagnoseFiles: jasmine + .createSpy('diagnoseFiles') + .and.rejectWith(new Error('Diagnostic crash')), + }; + + const bundler = { + invalidate: jasmine.createSpy('invalidate'), + bundleFile: jasmine.createSpy('bundleFile').and.resolveTo( + createStyleResult({ + errors: [{ text: 'Sass syntax error' }], + }), + ), + bundleInline: jasmine.createSpy('bundleInline'), + } as unknown as ComponentStylesheetBundler; + + const state = createMockStateWithCompilation(compilationInstance); + const output = await compileLibrary(createOptions(), bundler, state); + + expect(output.hasStylesheetErrors).toBeTrue(); + expect(compilationInstance.emitAffectedFiles).not.toHaveBeenCalled(); + + await expectAsync(output.diagnosePromise).toBeResolved(); + expect(compilationInstance.diagnoseFiles).toHaveBeenCalledWith(DiagnosticModes.All); + + const errorsText = state.executionResult.errors.join('\n'); + expect(errorsText).toContain('Sass syntax error'); + expect(errorsText).toContain('Diagnostic crash'); + }); + + it('should pass "css" language to bundleInline when containingFile ends with .html and undefined otherwise', async () => { + const compilationInstance: AngularCompilation = { + initialize: jasmine + .createSpy('initialize') + .and.callFake(async (_tsconfig: string, hostOptions: AngularHostOptions) => { + await hostOptions.transformStylesheet( + 'p { color: red; }', + '/workspace/projects/my-lib/src/lib/lib.component.html', + ); + await hostOptions.transformStylesheet( + '$c: blue; p { color: $c; }', + '/workspace/projects/my-lib/src/lib/lib.component.ts', + ); + + return { + compilerOptions: { declarationMap: false, compilationMode: 'partial' }, + referencedFiles: ['/workspace/projects/my-lib/src/lib/lib.component.ts'], + }; + }), + emitAffectedFiles: () => [], + diagnoseFiles: async () => ({}), + }; + + const bundleInlineSpy = jasmine + .createSpy('bundleInline') + .and.resolveTo(createStyleResult({ contents: 'p{color:red}' })); + const bundler = { + invalidate: jasmine.createSpy('invalidate'), + bundleFile: jasmine.createSpy('bundleFile'), + bundleInline: bundleInlineSpy, + } as unknown as ComponentStylesheetBundler; + + const state = createMockStateWithCompilation(compilationInstance); + const output = await compileLibrary(createOptions(), bundler, state); + await output.diagnosePromise; + + expect(bundleInlineSpy).toHaveBeenCalledTimes(2); + expect(bundleInlineSpy.calls.argsFor(0)).toEqual([ + 'p { color: red; }', + '/workspace/projects/my-lib/src/lib/lib.component.html', + 'css', + ]); + expect(bundleInlineSpy.calls.argsFor(1)).toEqual([ + '$c: blue; p { color: $c; }', + '/workspace/projects/my-lib/src/lib/lib.component.ts', + undefined, + ]); + }); + + it('should preserve existing file references when stylesheet bundling fails and replace them when bundling succeeds', async () => { + const compTs = '/workspace/projects/my-lib/src/lib/lib.component.ts'; + const compScss = '/workspace/projects/my-lib/src/lib/lib.component.scss'; + const partialOld = '/workspace/projects/my-lib/src/lib/_old.scss'; + const partialNew = '/workspace/projects/my-lib/src/lib/_new.scss'; + + let shouldFailBundle = true; + const compilationInstance: AngularCompilation = { + update: jasmine.createSpy('update').and.resolveTo(), + initialize: jasmine + .createSpy('initialize') + .and.callFake(async (_tsconfig: string, hostOptions: AngularHostOptions) => { + await hostOptions.transformStylesheet('', compTs, compScss); + + return { + compilerOptions: { declarationMap: false, compilationMode: 'partial' }, + referencedFiles: [compTs], + }; + }), + emitAffectedFiles: () => [], + diagnoseFiles: async () => ({}), + }; + + const bundler = { + invalidate: jasmine.createSpy('invalidate'), + bundleFile: jasmine.createSpy('bundleFile').and.callFake(async () => { + if (shouldFailBundle) { + return createStyleResult({ + errors: [{ text: 'Sass error before reaching import' }], + referencedFiles: new Set([compScss]), + }); + } + + return createStyleResult({ + contents: '.ok {}', + referencedFiles: new Set([compScss, partialNew]), + }); + }), + bundleInline: jasmine.createSpy('bundleInline'), + } as unknown as ComponentStylesheetBundler; + + const state = createMockStateWithCompilation(compilationInstance); + // Record initial references from a prior successful build + state.addFileReferences(compScss, [compScss, partialOld]); + + // 1. Rebuild where stylesheet bundling fails: old reference (`partialOld`) must be preserved + const failedOutput = await compileLibrary( + createOptions(), + bundler, + state, + new Set([compTs, compScss]), + ); + await failedOutput.diagnosePromise; + state.recordCompilationOutput(failedOutput); + + expect(state.getAffectedFiles([partialOld])).toEqual(new Set([partialOld, compScss])); + + // 2. Rebuild where stylesheet bundling succeeds with `partialNew` instead of `partialOld` + state.resetExecutionResult(); + shouldFailBundle = false; + const successOutput = await compileLibrary( + createOptions(), + bundler, + state, + new Set([compTs, compScss]), + ); + await successOutput.diagnosePromise; + state.recordCompilationOutput(successOutput); + + // `partialOld` should no longer mark `compScss` as affected + expect(state.getAffectedFiles([partialOld])).toEqual(new Set([partialOld])); + // `partialNew` should now mark `compScss` as affected + expect(state.getAffectedFiles([partialNew])).toEqual(new Set([partialNew, compScss])); + }); + + it('should mark .d.ts as changed in changedDtsFiles when .d.ts.map changes', async () => { + const dtsPath = '/workspace/projects/my-lib/src/lib/lib.component.d.ts'; + const dtsMapPath = '/workspace/projects/my-lib/src/lib/lib.component.d.ts.map'; + const jsPath = '/workspace/projects/my-lib/src/lib/lib.component.js'; + + const esmFiles = new Map([[jsPath, 'export class LibComponent {}']]); + const dtsFiles = new Map([ + [dtsPath, 'export declare class LibComponent {}\n'], + [dtsMapPath, '{"version":3,"mappings":"AAAA"}'], + ]); + + let emitted: EmitFileResult[] = [ + { filename: jsPath, contents: 'export class LibComponent { static ɵcmp = 1; }' }, + { filename: dtsPath, contents: 'export declare class LibComponent {}\n' }, + { filename: dtsMapPath, contents: '{"version":3,"mappings":"AACA"}' }, + ]; + + const compilationInstance: AngularCompilation = { + initialize: async () => ({ + compilerOptions: { declarationMap: true, compilationMode: 'partial' }, + referencedFiles: ['/workspace/projects/my-lib/src/lib/lib.component.ts'], + }), + emitAffectedFiles: () => emitted, + diagnoseFiles: async () => ({}), + }; + + const bundler = { + invalidate: jasmine.createSpy('invalidate'), + bundleFile: jasmine.createSpy('bundleFile'), + bundleInline: jasmine.createSpy('bundleInline'), + } as unknown as ComponentStylesheetBundler; + + const state = createMockStateWithCompilation(compilationInstance, esmFiles, dtsFiles); + + // Case 1: .d.ts is identical, only .d.ts.map and .js changed + const output1 = await compileLibrary(createOptions(), bundler, state); + await output1.diagnosePromise; + + expect(output1.changedEsmFiles).toEqual(new Set([jsPath])); + expect(output1.changedDtsFiles).toEqual(new Set([dtsPath])); + expect(output1.dtsFiles.get(dtsMapPath)).toBe('{"version":3,"mappings":"AACA"}'); + + // Case 2: neither .d.ts nor .d.ts.map changed + emitted = [ + { filename: dtsPath, contents: 'export declare class LibComponent {}\n' }, + { filename: dtsMapPath, contents: '{"version":3,"mappings":"AACA"}' }, + ]; + const output2 = await compileLibrary(createOptions(), bundler, state); + await output2.diagnosePromise; + + expect(output2.changedDtsFiles.size).toBe(0); + }); +}); diff --git a/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts index be6c1d3d3f94..e6c3ac81e210 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts @@ -75,6 +75,12 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }); it('should not log EMPTY_BUNDLE warnings for type-only entry points', async () => { + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.target = 'ES2022'; + + return JSON.stringify(tsconfig, null, 2); + }); await harness.writeFile( 'projects/lib/src/public-api.ts', `export interface OnlyType { id: string; }`, diff --git a/packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts index de8aa7bf1724..272798cb28ab 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/styles_spec.ts @@ -141,5 +141,32 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); expect(fesm).toContain('data:image/svg+xml'); }); + + it('should compile inline
Styled
', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from "@angular/core"; + + @Component({ + selector: "lib-html-style", + templateUrl: "./lib.component.html", + }) + export class LibComponent {} + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Sass, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const fesm = harness.readFile('dist/lib/fesm2022/lib.mjs'); + expect(fesm).toMatch(/color:\s*#ff5722/); + }); }); }); 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 b648c690951a..e117f8efe9f8 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 @@ -1058,5 +1058,105 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }, ]); }); + + it('should preserve template diagnostics across watch rebuilds after a stylesheet error', async () => { + await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.angularCompilerOptions = { + ...tsconfig.angularCompilerOptions, + strictTemplates: true, + }; + + return JSON.stringify(tsconfig, null, 2); + }); + + await harness.writeFiles({ + 'projects/lib/src/lib/other.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-other', + template: '{{ validProp }}', + }) + export class OtherComponent { + validProp = 'ok'; + } + `, + 'projects/lib/src/lib/lib.component.scss': '.valid { color: blue; }', + 'projects/lib/src/lib/lib.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-primary', + template: 'Primary', + styleUrl: './lib.component.scss', + }) + export class LibComponent {} + `, + 'projects/lib/src/public-api.ts': ` + export * from './lib/lib.component'; + export * from './lib/other.component'; + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Introduce both a stylesheet error in LibComponent and a template error in OtherComponent + await harness.writeFiles({ + 'projects/lib/src/lib/lib.component.scss': '.broken { color: $undefined_var; }', + 'projects/lib/src/lib/other.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'lib-other', + template: '{{ unknownProp }}', + }) + export class OtherComponent {} + `, + }); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining('Undefined variable'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining( + "Property 'unknownProp' does not exist on type 'OtherComponent'", + ), + }), + ); + + // Fix only the stylesheet error in LibComponent while OtherComponent still has the template error + await harness.writeFile( + 'projects/lib/src/lib/lib.component.scss', + '.valid { color: green; }', + ); + }, + ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining( + "Property 'unknownProp' does not exist on type 'OtherComponent'", + ), + }), + ); + }, + ]); + }); }); });