diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts index 980eb03c50..f68f4bc21d 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/component.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/component.ts @@ -71,6 +71,16 @@ export interface ComponentAnalysisData { resources: ComponentResources; + /** + * The literal `styleUrls` extracted from the decorator, if present. + */ + styleUrls: string[]|null; + + /** + * Inline stylesheets extracted from the decorator, if present. + */ + inlineStyles: string[]|null; + isPoisoned: boolean; } @@ -275,9 +285,11 @@ export class ComponentDecoratorHandler implements } } } + let inlineStyles: string[]|null = null; if (component.has('styles')) { const litStyles = parseFieldArrayValue(component, 'styles', this.evaluator); if (litStyles !== null) { + inlineStyles = [...litStyles]; if (styles === null) { styles = litStyles; } else { @@ -333,6 +345,8 @@ export class ComponentDecoratorHandler implements template, providersRequiringFactory, viewProvidersRequiringFactory, + inlineStyles, + styleUrls, resources: { styles: styleResources, template: templateResource, @@ -581,6 +595,37 @@ export class ComponentDecoratorHandler implements return {data}; } + updateResources(node: ClassDeclaration, analysis: ComponentAnalysisData): void { + const containingFile = node.getSourceFile().fileName; + + // If the template is external, re-parse it. + const templateDecl = analysis.template.declaration; + if (!templateDecl.isInline) { + analysis.template = this.extractTemplate(node, templateDecl); + } + + // Update any external stylesheets and rebuild the combined 'styles' list. + // TODO(alxhub): write tests for styles when the primary compiler uses the updateResources path + let styles: string[] = []; + if (analysis.styleUrls !== null) { + for (const styleUrl of analysis.styleUrls) { + const resolvedStyleUrl = this.resourceLoader.resolve(styleUrl, containingFile); + const styleText = this.resourceLoader.load(resolvedStyleUrl); + styles.push(styleText); + } + } + if (analysis.inlineStyles !== null) { + for (const styleText of analysis.inlineStyles) { + styles.push(styleText); + } + } + for (const styleText of analysis.template.styles) { + styles.push(styleText); + } + + analysis.meta.styles = styles; + } + compileFull( node: ClassDeclaration, analysis: Readonly, resolution: Readonly, pool: ConstantPool): CompileResult[] { diff --git a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts index 687872955d..3f55b98f97 100644 --- a/packages/compiler-cli/src/ngtsc/core/src/compiler.ts +++ b/packages/compiler-cli/src/ngtsc/core/src/compiler.ts @@ -63,6 +63,7 @@ interface LazyCompilationState { export enum CompilationTicketKind { Fresh, IncrementalTypeScript, + IncrementalResource, } /** @@ -93,6 +94,12 @@ export interface IncrementalTypeScriptCompilationTicket { usePoisonedData: boolean; } +export interface IncrementalResourceCompilationTicket { + kind: CompilationTicketKind.IncrementalResource; + compiler: NgCompiler; + modifiedResourceFiles: Set; +} + /** * A request to begin Angular compilation, either starting from scratch or from a known prior state. * @@ -100,7 +107,8 @@ export interface IncrementalTypeScriptCompilationTicket { * Angular compiler. They abstract the starting state of compilation and allow `NgCompiler` to be * managed independently of any incremental compilation lifecycle. */ -export type CompilationTicket = FreshCompilationTicket|IncrementalTypeScriptCompilationTicket; +export type CompilationTicket = FreshCompilationTicket|IncrementalTypeScriptCompilationTicket| + IncrementalResourceCompilationTicket; /** * Create a `CompilationTicket` for a brand new compilation, using no prior state. @@ -180,6 +188,15 @@ export function incrementalFromDriverTicket( }; } +export function resourceChangeTicket(compiler: NgCompiler, modifiedResourceFiles: Set): + IncrementalResourceCompilationTicket { + return { + kind: CompilationTicketKind.IncrementalResource, + compiler, + modifiedResourceFiles, + }; +} + /** * The heart of the Angular Ivy compiler. @@ -260,6 +277,10 @@ export class NgCompiler { ticket.usePoisonedData, perfRecorder, ); + case CompilationTicketKind.IncrementalResource: + const compiler = ticket.compiler; + compiler.updateWithChangedResources(ticket.modifiedResourceFiles); + return compiler; } } @@ -306,6 +327,36 @@ export class NgCompiler { this.ignoreForEmit = this.adapter.ignoreForEmit; } + private updateWithChangedResources(changedResources: Set): void { + if (this.compilation === null) { + // Analysis hasn't happened yet, so no update is necessary - any changes to resources will be + // captured by the inital analysis pass itself. + return; + } + + this.resourceManager.invalidate(); + + const classesToUpdate = new Set(); + for (const resourceFile of changedResources) { + for (const templateClass of this.getComponentsWithTemplateFile(resourceFile)) { + classesToUpdate.add(templateClass); + } + + for (const styleClass of this.getComponentsWithStyleFile(resourceFile)) { + classesToUpdate.add(styleClass); + } + } + + for (const clazz of classesToUpdate) { + this.compilation.traitCompiler.updateResources(clazz); + if (!ts.isClassDeclaration(clazz)) { + continue; + } + + this.compilation.templateTypeChecker.invalidateClass(clazz); + } + } + /** * Get the resource dependencies of a file. * diff --git a/packages/compiler-cli/src/ngtsc/core/test/compiler_test.ts b/packages/compiler-cli/src/ngtsc/core/test/compiler_test.ts index f71982ed2f..785499527d 100644 --- a/packages/compiler-cli/src/ngtsc/core/test/compiler_test.ts +++ b/packages/compiler-cli/src/ngtsc/core/test/compiler_test.ts @@ -17,7 +17,7 @@ import {OptimizeFor, TypeCheckingProgramStrategy} from '../../typecheck/api'; import {NgCompilerOptions} from '../api'; -import {freshCompilationTicket, NgCompiler} from '../src/compiler'; +import {freshCompilationTicket, NgCompiler, resourceChangeTicket} from '../src/compiler'; import {NgCompilerHost} from '../src/host'; function makeFreshCompiler( @@ -111,6 +111,7 @@ runInEachFileSystem(() => { const program = ts.createProgram({host, options, rootNames: host.inputFiles}); const CmpA = getClass(getSourceFileOrError(program, cmpAFile), 'CmpA'); const CmpC = getClass(getSourceFileOrError(program, cmpCFile), 'CmpC'); + const compiler = makeFreshCompiler( host, options, program, new ReusedProgramStrategy(program, host, options, []), new NoopIncrementalBuildStrategy(), /** enableTemplateTypeChecker */ false, @@ -278,6 +279,53 @@ runInEachFileSystem(() => { ])); }); }); + + describe('resource-only changes', () => { + it('should reuse the full compilation state for a resource-only change', () => { + const COMPONENT = _('/cmp.ts'); + const TEMPLATE = _('/template.html'); + fs.writeFile(COMPONENT, ` + import {Component} from '@angular/core'; + @Component({ + selector: 'test-cmp', + templateUrl: './template.html', + }) + export class Cmp {} + `); + fs.writeFile(TEMPLATE, `

Resource

`); + + const options: NgCompilerOptions = { + strictTemplates: true, + }; + const baseHost = new NgtscCompilerHost(getFileSystem(), options); + const host = NgCompilerHost.wrap(baseHost, [COMPONENT], options, /* oldProgram */ null); + const program = ts.createProgram({host, options, rootNames: host.inputFiles}); + const compilerA = makeFreshCompiler( + host, options, program, new ReusedProgramStrategy(program, host, options, []), + new NoopIncrementalBuildStrategy(), /** enableTemplateTypeChecker */ false, + /* usePoisonedData */ false); + + const componentSf = getSourceFileOrError(program, COMPONENT); + + // There should be no diagnostics for the component. + expect(compilerA.getDiagnosticsForFile(componentSf, OptimizeFor.WholeProgram).length) + .toBe(0); + + // Change the resource file and introduce an error. + fs.writeFile(TEMPLATE, `

Resource

`); + + // Perform a resource-only incremental step. + const resourceTicket = resourceChangeTicket(compilerA, new Set([TEMPLATE])); + const compilerB = NgCompiler.fromTicket(resourceTicket, host); + + // A resource-only update should reuse the same compiler instance. + expect(compilerB).toBe(compilerA); + + // The new template error should be reported in component diagnostics. + expect(compilerB.getDiagnosticsForFile(componentSf, OptimizeFor.WholeProgram).length) + .toBe(1); + }); + }); }); }); diff --git a/packages/compiler-cli/src/ngtsc/resource/src/loader.ts b/packages/compiler-cli/src/ngtsc/resource/src/loader.ts index 62e89ef784..d4abec76ff 100644 --- a/packages/compiler-cli/src/ngtsc/resource/src/loader.ts +++ b/packages/compiler-cli/src/ngtsc/resource/src/loader.ts @@ -113,6 +113,13 @@ export class AdapterResourceLoader implements ResourceLoader { return result; } + /** + * Invalidate the entire resource cache. + */ + invalidate(): void { + this.cache.clear(); + } + /** * Attempt to resolve `url` in the context of `fromFile`, while respecting the rootDirs * option from the tsconfig. First, normalize the file name. diff --git a/packages/compiler-cli/src/ngtsc/transform/src/api.ts b/packages/compiler-cli/src/ngtsc/transform/src/api.ts index fe09717966..f8ed438530 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/api.ts @@ -128,6 +128,12 @@ export interface DecoratorHandler { analyze(node: ClassDeclaration, metadata: Readonly, handlerFlags?: HandlerFlags): AnalysisOutput; + /** + * React to a change in a resource file by updating the `analysis` or `resolution`, under the + * assumption that nothing in the TypeScript code has changed. + */ + updateResources?(node: ClassDeclaration, analysis: A, resolution: R): void; + /** * Post-process the analysis of a decorator/class combination and record any necessary information * in the larger compilation. diff --git a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts index a3347323ef..7e3ded3822 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts @@ -453,6 +453,20 @@ export class TraitCompiler implements ProgramTypeCheckAdapter { } } + updateResources(clazz: DeclarationNode): void { + if (!this.reflector.isClass(clazz) || !this.classes.has(clazz)) { + return; + } + const record = this.classes.get(clazz)!; + for (const trait of record.traits) { + if (trait.state !== TraitState.Resolved || trait.handler.updateResources === undefined) { + continue; + } + + trait.handler.updateResources(clazz, trait.analysis, trait.resolution); + } + } + compile(clazz: DeclarationNode, constantPool: ConstantPool): CompileResult[]|null { const original = ts.getOriginalNode(clazz) as typeof clazz; if (!this.reflector.isClass(clazz) || !this.reflector.isClass(original) || diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index c65016b284..06f55664f7 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -165,6 +165,12 @@ export interface TemplateTypeChecker { * Retrieve the type checking engine's metadata for the given directive class, if available. */ getDirectiveMetadata(dir: ts.ClassDeclaration): TypeCheckableDirectiveMeta|null; + + /** + * Reset the `TemplateTypeChecker`'s state for the given class, so that it will be recomputed on + * the next request. + */ + invalidateClass(clazz: ts.ClassDeclaration): void; } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index 2cd5cfffa4..68ff907a02 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -318,6 +318,25 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { return engine.getExpressionCompletionLocation(ast); } + invalidateClass(clazz: ts.ClassDeclaration): void { + this.completionCache.delete(clazz); + this.symbolBuilderCache.delete(clazz); + this.scopeCache.delete(clazz); + this.elementTagCache.delete(clazz); + + const sf = clazz.getSourceFile(); + const sfPath = absoluteFromSourceFile(sf); + const shimPath = this.typeCheckingStrategy.shimPathForComponent(clazz); + const fileData = this.getFileData(sfPath); + const templateId = fileData.sourceManager.getTemplateId(clazz); + + fileData.shimData.delete(shimPath); + fileData.isComplete = false; + fileData.templateOverrides?.delete(templateId); + + this.isComplete = false; + } + private getOrCreateCompletionEngine(component: ts.ClassDeclaration): CompletionEngine|null { if (this.completionCache.has(component)) { return this.completionCache.get(component)!;