Skip to content

Commit 30ddadc

Browse files
committed
fix(ivy): always re-analyze the program during incremental rebuilds (#33862)
Previously, the ngtsc compiler attempted to reuse analysis work from the previous program during an incremental build. To do this, it had to prove that the work was safe to reuse - that no changes made to the new program would invalidate the previous analysis. The implementation of this had a significant design flaw: if the previous program had errors, the previous analysis would be missing significant information, and the dependency graph extracted from it would not be sufficient to determine which files should be re-analyzed to fill in the gaps. This often meant that the build output after an error was resolved would be wholly incorrect. This commit switches ngtsc to take a simpler approach to incremental rebuilds. Instead of attempting to reuse prior analysis work, the entire program is re-analyzed with each compilation. This is actually not as expensive as one might imagine - analysis is a fairly small part of overall compilation time. Based on the dependency graph extracted during this analysis, the compiler then can make accurate decisions on whether to emit specific files. A new suite of tests is added to validate behavior in the presence of source code level errors. This new approach is dramatically simpler than the previous algorithm, and should always produce correct results for a semantically correct program.s Fixes #32388 Fixes #32214 PR Close #33862
1 parent dc8e300 commit 30ddadc

File tree

8 files changed

+340
-169
lines changed

8 files changed

+340
-169
lines changed

packages/compiler-cli/src/ngtsc/incremental/src/README.md

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
This package contains logic related to incremental compilation in ngtsc.
44

5-
In particular, it tracks metadata for `ts.SourceFile`s in between compilations, so the compiler can make intelligent decisions about when to skip certain operations and rely on previously analyzed data.
5+
In particular, it tracks dependencies between `ts.SourceFile`s, so the compiler can make intelligent decisions about when it's safe to skip certain operations.
66

77
# How does incremental compilation work?
88

@@ -14,30 +14,36 @@ This information is leveraged in two major ways:
1414

1515
1) The previous `ts.Program` itself is used to create the next `ts.Program`, allowing TypeScript internally to leverage information from the previous compile in much the same way.
1616

17-
2) An `IncrementalState` instance is constructed from the previous compilation's `IncrementalState` and its `ts.Program`.
17+
2) An `IncrementalState` instance is constructed from the old and new `ts.Program`s.
1818

19-
After this initialization, the `IncrementalState` contains the knowledge from the previous compilation which will be used to optimize the next one.
19+
The compiler then proceeds normally, analyzing all of the Angular code within the program. As a part of this process, the compiler maps out all of the dependencies between files in the `IncrementalState`.
2020

21-
# What optimizations can be made?
21+
# What optimizations are made?
2222

23-
Currently, ngtsc makes a decision to skip the emit of a file if it can prove that the contents of the file will not have changed. To prove this, two conditions must be true.
23+
ngtsc makes a decision to skip the emit of a file if it can prove that the contents of the file will not have changed. To prove this, two conditions must be true.
2424

2525
* The input file itself must not have changed since the previous compilation.
2626

27-
* As a result of analyzing the file, no dependencies must exist where the output of compilation could vary depending on the contents of any other file.
27+
* None of the files on which the input file is dependent have changed since the previous compilation.
2828

29-
The second condition is challenging, as Angular allows statically evaluated expressions in lots of contexts that could result in changes from file to file. For example, the `name` of an `@Pipe` could be a reference to a constant in a different file.
29+
The second condition is challenging to prove, as Angular allows statically evaluated expressions in lots of contexts that could result in changes from file to file. For example, the `name` of an `@Pipe` could be a reference to a constant in a different file. As part of analyzing the program, the compiler keeps track of such dependencies in order to answer this question.
3030

31-
Therefore, only two types of files meet these conditions and can be optimized today:
32-
33-
* Files with no Angular decorated classes at all.
34-
35-
* Files with only `@Injectable`s.
31+
The emit of a file is the most expensive part of TypeScript/Angular compilation, so skipping emits when they are not necessary is one of the most valuable things the compiler can do to improve incremental build performance.
3632

3733
# What optimizations are possible in the future?
3834

3935
There is plenty of room for improvement here, with diminishing returns for the work involved.
4036

41-
* The compiler could track the dependencies of each file being compiled, and know whether an `@Pipe` gets its name from a second file, for example. This is sufficient to skip the analysis and emit of more files when none of the dependencies have changed.
37+
## Optimization of re-analysis
38+
39+
Currently, the compiler re-analyzes the entire `ts.Program` on each compilation. Under certain circumstances it may be possible for the compiler to reuse parts of the previous compilation's analysis rather than repeat the work, if it can be proven to be safe.
40+
41+
## Semantic dependency tracking
42+
43+
Currently the compiler tracks dependencies only at the file level, and will re-emit dependent files if they _may_ have been affected by a change. Often times a change though does _not_ require updates to dependent files.
44+
45+
For example, today a component's `NgModule` and all of the other components which consume that module's export scope are considered to depend on the component file itself. If the component's template changes, this triggers a re-emit of not only the component's file, but the entire chain of its NgModule and that module's export scope. This happens even though the template of a component _does not have any impact_ on any components which consume it - these other emits are deeply unnecessary.
46+
47+
In contrast, if the component's _selector_ changes, then all those dependent files do need to be updated since their `directiveDefs` might have changed.
4248

43-
* The compiler could also perform analysis on files which _have_ changed dependencies, and skip emit if the analysis indicates nothing has changed which would affect the file being emitted.
49+
Currently the compiler does not distinguish these two cases, and conservatively always re-emits the entire NgModule chain. It would be possible to break the dependency graph down into finer-grained nodes and distinguish between updates that affect the component, vs updates that affect its dependents. This would be a huge win, but is exceedingly complex.

packages/compiler-cli/src/ngtsc/incremental/src/state.ts

Lines changed: 14 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -8,47 +8,33 @@
88

99
import * as ts from 'typescript';
1010

11-
import {Reference} from '../../imports';
12-
import {DirectiveMeta, MetadataReader, MetadataRegistry, NgModuleMeta, PipeMeta} from '../../metadata';
1311
import {DependencyTracker} from '../../partial_evaluator';
14-
import {ClassDeclaration} from '../../reflection';
15-
import {ComponentScopeReader, ComponentScopeRegistry, LocalModuleScope} from '../../scope';
1612
import {ResourceDependencyRecorder} from '../../util/src/resource_recorder';
1713

1814
/**
1915
* Accumulates state between compilations.
2016
*/
21-
export class IncrementalState implements DependencyTracker, MetadataReader, MetadataRegistry,
22-
ResourceDependencyRecorder, ComponentScopeRegistry, ComponentScopeReader {
17+
export class IncrementalState implements DependencyTracker, ResourceDependencyRecorder {
2318
private constructor(
2419
private unchangedFiles: Set<ts.SourceFile>,
2520
private metadata: Map<ts.SourceFile, FileMetadata>,
2621
private modifiedResourceFiles: Set<string>|null) {}
2722

2823
static reconcile(
29-
previousState: IncrementalState, oldProgram: ts.Program, newProgram: ts.Program,
24+
oldProgram: ts.Program, newProgram: ts.Program,
3025
modifiedResourceFiles: Set<string>|null): IncrementalState {
3126
const unchangedFiles = new Set<ts.SourceFile>();
3227
const metadata = new Map<ts.SourceFile, FileMetadata>();
3328
const oldFiles = new Set<ts.SourceFile>(oldProgram.getSourceFiles());
34-
const newFiles = new Set<ts.SourceFile>(newProgram.getSourceFiles());
3529

3630
// Compute the set of files that are unchanged (both in themselves and their dependencies).
3731
for (const newFile of newProgram.getSourceFiles()) {
38-
if (oldFiles.has(newFile)) {
39-
const oldDeps = previousState.getFileDependencies(newFile);
40-
if (oldDeps.every(oldDep => newFiles.has(oldDep))) {
41-
// The file and its dependencies are unchanged.
42-
unchangedFiles.add(newFile);
43-
// Copy over its metadata too
44-
const meta = previousState.metadata.get(newFile);
45-
if (meta) {
46-
metadata.set(newFile, meta);
47-
}
48-
}
49-
} else if (newFile.isDeclarationFile) {
50-
// A typings file has changed so trigger a full rebuild of the Angular analyses
32+
if (newFile.isDeclarationFile && !oldFiles.has(newFile)) {
33+
// Bail out and re-emit everything if a .d.ts file has changed - currently the compiler does
34+
// not track dependencies into .d.ts files very well.
5135
return IncrementalState.fresh();
36+
} else if (oldFiles.has(newFile)) {
37+
unchangedFiles.add(newFile);
5238
}
5339
}
5440

@@ -60,8 +46,13 @@ export class IncrementalState implements DependencyTracker, MetadataReader, Meta
6046
new Set<ts.SourceFile>(), new Map<ts.SourceFile, FileMetadata>(), null);
6147
}
6248

63-
safeToSkip(sf: ts.SourceFile): boolean|Promise<boolean> {
64-
return this.unchangedFiles.has(sf) && !this.hasChangedResourceDependencies(sf);
49+
safeToSkip(sf: ts.SourceFile): boolean {
50+
// It's safe to skip emitting a file if:
51+
// 1) it hasn't changed
52+
// 2) none if its resource dependencies have changed
53+
// 3) none of its source dependencies have changed
54+
return this.unchangedFiles.has(sf) && !this.hasChangedResourceDependencies(sf) &&
55+
this.getFileDependencies(sf).every(dep => this.unchangedFiles.has(dep));
6556
}
6657

6758
trackFileDependency(dep: ts.SourceFile, src: ts.SourceFile) {
@@ -84,93 +75,11 @@ export class IncrementalState implements DependencyTracker, MetadataReader, Meta
8475
return Array.from(meta.fileDependencies);
8576
}
8677

87-
getNgModuleMetadata(ref: Reference<ClassDeclaration>): NgModuleMeta|null {
88-
if (!this.metadata.has(ref.node.getSourceFile())) {
89-
return null;
90-
}
91-
const metadata = this.metadata.get(ref.node.getSourceFile()) !;
92-
if (!metadata.ngModuleMeta.has(ref.node)) {
93-
return null;
94-
}
95-
return metadata.ngModuleMeta.get(ref.node) !;
96-
}
97-
98-
registerNgModuleMetadata(meta: NgModuleMeta): void {
99-
const metadata = this.ensureMetadata(meta.ref.node.getSourceFile());
100-
metadata.ngModuleMeta.set(meta.ref.node, meta);
101-
}
102-
103-
getDirectiveMetadata(ref: Reference<ClassDeclaration>): DirectiveMeta|null {
104-
if (!this.metadata.has(ref.node.getSourceFile())) {
105-
return null;
106-
}
107-
const metadata = this.metadata.get(ref.node.getSourceFile()) !;
108-
if (!metadata.directiveMeta.has(ref.node)) {
109-
return null;
110-
}
111-
return metadata.directiveMeta.get(ref.node) !;
112-
}
113-
114-
registerDirectiveMetadata(meta: DirectiveMeta): void {
115-
const metadata = this.ensureMetadata(meta.ref.node.getSourceFile());
116-
metadata.directiveMeta.set(meta.ref.node, meta);
117-
}
118-
119-
getPipeMetadata(ref: Reference<ClassDeclaration>): PipeMeta|null {
120-
if (!this.metadata.has(ref.node.getSourceFile())) {
121-
return null;
122-
}
123-
const metadata = this.metadata.get(ref.node.getSourceFile()) !;
124-
if (!metadata.pipeMeta.has(ref.node)) {
125-
return null;
126-
}
127-
return metadata.pipeMeta.get(ref.node) !;
128-
}
129-
130-
registerPipeMetadata(meta: PipeMeta): void {
131-
const metadata = this.ensureMetadata(meta.ref.node.getSourceFile());
132-
metadata.pipeMeta.set(meta.ref.node, meta);
133-
}
134-
13578
recordResourceDependency(file: ts.SourceFile, resourcePath: string): void {
13679
const metadata = this.ensureMetadata(file);
13780
metadata.resourcePaths.add(resourcePath);
13881
}
13982

140-
registerComponentScope(clazz: ClassDeclaration, scope: LocalModuleScope): void {
141-
const metadata = this.ensureMetadata(clazz.getSourceFile());
142-
metadata.componentScope.set(clazz, scope);
143-
}
144-
145-
getScopeForComponent(clazz: ClassDeclaration): LocalModuleScope|null {
146-
if (!this.metadata.has(clazz.getSourceFile())) {
147-
return null;
148-
}
149-
const metadata = this.metadata.get(clazz.getSourceFile()) !;
150-
if (!metadata.componentScope.has(clazz)) {
151-
return null;
152-
}
153-
return metadata.componentScope.get(clazz) !;
154-
}
155-
156-
setComponentAsRequiringRemoteScoping(clazz: ClassDeclaration): void {
157-
const metadata = this.ensureMetadata(clazz.getSourceFile());
158-
metadata.remoteScoping.add(clazz);
159-
}
160-
161-
getRequiresRemoteScope(clazz: ClassDeclaration): boolean|null {
162-
// TODO: https://angular-team.atlassian.net/browse/FW-1501
163-
// Handle the incremental build case where a component requires remote scoping.
164-
// This means that if the the component's template changes, it requires the module to be
165-
// re-emitted.
166-
// Also, we need to make sure the cycle detector works well across rebuilds.
167-
if (!this.metadata.has(clazz.getSourceFile())) {
168-
return null;
169-
}
170-
const metadata = this.metadata.get(clazz.getSourceFile()) !;
171-
return metadata.remoteScoping.has(clazz);
172-
}
173-
17483
private ensureMetadata(sf: ts.SourceFile): FileMetadata {
17584
const metadata = this.metadata.get(sf) || new FileMetadata();
17685
this.metadata.set(sf, metadata);
@@ -194,9 +103,4 @@ class FileMetadata {
194103
/** A set of source files that this file depends upon. */
195104
fileDependencies = new Set<ts.SourceFile>();
196105
resourcePaths = new Set<string>();
197-
directiveMeta = new Map<ClassDeclaration, DirectiveMeta>();
198-
ngModuleMeta = new Map<ClassDeclaration, NgModuleMeta>();
199-
pipeMeta = new Map<ClassDeclaration, PipeMeta>();
200-
componentScope = new Map<ClassDeclaration, LocalModuleScope>();
201-
remoteScoping = new Set<ClassDeclaration>();
202106
}

packages/compiler-cli/src/ngtsc/program.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {NOOP_PERF_RECORDER, PerfRecorder, PerfTracker} from './perf';
2828
import {TypeScriptReflectionHost} from './reflection';
2929
import {HostResourceLoader} from './resource_loader';
3030
import {NgModuleRouteAnalyzer, entryPointKeyFor} from './routing';
31-
import {CompoundComponentScopeReader, LocalModuleScopeRegistry, MetadataDtsModuleScopeResolver} from './scope';
31+
import {ComponentScopeReader, CompoundComponentScopeReader, LocalModuleScopeRegistry, MetadataDtsModuleScopeResolver} from './scope';
3232
import {FactoryGenerator, FactoryInfo, GeneratedShimsHostWrapper, ShimGenerator, SummaryGenerator, TypeCheckShimGenerator, generatedFactoryTransform} from './shims';
3333
import {ivySwitchTransform} from './switch';
3434
import {IvyCompilation, declarationTransformFactory, ivyTransformFactory} from './transform';
@@ -186,8 +186,7 @@ export class NgtscProgram implements api.Program {
186186
this.incrementalState = IncrementalState.fresh();
187187
} else {
188188
this.incrementalState = IncrementalState.reconcile(
189-
oldProgram.incrementalState, oldProgram.reuseTsProgram, this.tsProgram,
190-
this.modifiedResourceFiles);
189+
oldProgram.reuseTsProgram, this.tsProgram, this.modifiedResourceFiles);
191190
}
192191
}
193192

@@ -309,13 +308,11 @@ export class NgtscProgram implements api.Program {
309308
if (this.compilation === undefined) {
310309
const analyzeSpan = this.perfRecorder.start('analyze');
311310
this.compilation = this.makeCompilation();
312-
this.tsProgram.getSourceFiles()
313-
.filter(file => !file.fileName.endsWith('.d.ts'))
314-
.forEach(file => {
315-
const analyzeFileSpan = this.perfRecorder.start('analyzeFile', file);
316-
this.compilation !.analyzeSync(file);
317-
this.perfRecorder.stop(analyzeFileSpan);
318-
});
311+
this.tsProgram.getSourceFiles().filter(file => !file.isDeclarationFile).forEach(file => {
312+
const analyzeFileSpan = this.perfRecorder.start('analyzeFile', file);
313+
this.compilation !.analyzeSync(file);
314+
this.perfRecorder.stop(analyzeFileSpan);
315+
});
319316
this.perfRecorder.stop(analyzeSpan);
320317
this.compilation.resolve();
321318
}
@@ -576,13 +573,12 @@ export class NgtscProgram implements api.Program {
576573
const evaluator = new PartialEvaluator(this.reflector, checker, this.incrementalState);
577574
const dtsReader = new DtsMetadataReader(checker, this.reflector);
578575
const localMetaRegistry = new LocalMetadataRegistry();
579-
const localMetaReader = new CompoundMetadataReader([localMetaRegistry, this.incrementalState]);
576+
const localMetaReader: MetadataReader = localMetaRegistry;
580577
const depScopeReader = new MetadataDtsModuleScopeResolver(dtsReader, this.aliasingHost);
581578
const scopeRegistry = new LocalModuleScopeRegistry(
582-
localMetaReader, depScopeReader, this.refEmitter, this.aliasingHost, this.incrementalState);
583-
const scopeReader = new CompoundComponentScopeReader([scopeRegistry, this.incrementalState]);
584-
const metaRegistry =
585-
new CompoundMetadataRegistry([localMetaRegistry, scopeRegistry, this.incrementalState]);
579+
localMetaReader, depScopeReader, this.refEmitter, this.aliasingHost);
580+
const scopeReader: ComponentScopeReader = scopeRegistry;
581+
const metaRegistry = new CompoundMetadataRegistry([localMetaRegistry, scopeRegistry]);
586582

587583
this.metaReader = new CompoundMetadataReader([localMetaReader, dtsReader]);
588584

packages/compiler-cli/src/ngtsc/scope/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
*/
88

99
export {ExportScope, ScopeData} from './src/api';
10-
export {ComponentScopeReader, ComponentScopeRegistry, CompoundComponentScopeReader} from './src/component_scope';
10+
export {ComponentScopeReader, CompoundComponentScopeReader} from './src/component_scope';
1111
export {DtsModuleScopeResolver, MetadataDtsModuleScopeResolver} from './src/dependency';
1212
export {LocalModuleScope, LocalModuleScopeRegistry, LocalNgModuleData} from './src/local';

packages/compiler-cli/src/ngtsc/scope/src/component_scope.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,6 @@
88
import {ClassDeclaration} from '../../reflection';
99
import {LocalModuleScope} from './local';
1010

11-
/**
12-
* Register information about the compilation scope of components.
13-
*/
14-
export interface ComponentScopeRegistry {
15-
registerComponentScope(clazz: ClassDeclaration, scope: LocalModuleScope): void;
16-
setComponentAsRequiringRemoteScoping(clazz: ClassDeclaration): void;
17-
}
18-
1911
/**
2012
* Read information about the compilation scope of components.
2113
*/
@@ -24,17 +16,6 @@ export interface ComponentScopeReader {
2416
getRequiresRemoteScope(clazz: ClassDeclaration): boolean|null;
2517
}
2618

27-
/**
28-
* A noop registry that doesn't do anything.
29-
*
30-
* This can be used in tests and cases where we don't care about the compilation scopes
31-
* being registered.
32-
*/
33-
export class NoopComponentScopeRegistry implements ComponentScopeRegistry {
34-
registerComponentScope(clazz: ClassDeclaration, scope: LocalModuleScope): void {}
35-
setComponentAsRequiringRemoteScoping(clazz: ClassDeclaration): void {}
36-
}
37-
3819
/**
3920
* A `ComponentScopeReader` that reads from an ordered set of child readers until it obtains the
4021
* requested scope.

packages/compiler-cli/src/ngtsc/scope/src/local.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {ClassDeclaration} from '../../reflection';
1616
import {identifierOfNode, nodeNameForError} from '../../util/src/typescript';
1717

1818
import {ExportScope, ScopeData} from './api';
19-
import {ComponentScopeReader, ComponentScopeRegistry, NoopComponentScopeRegistry} from './component_scope';
19+
import {ComponentScopeReader} from './component_scope';
2020
import {DtsModuleScopeResolver} from './dependency';
2121

2222
export interface LocalNgModuleData {
@@ -104,8 +104,7 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
104104

105105
constructor(
106106
private localReader: MetadataReader, private dependencyScopeReader: DtsModuleScopeResolver,
107-
private refEmitter: ReferenceEmitter, private aliasingHost: AliasingHost|null,
108-
private componentScopeRegistry: ComponentScopeRegistry = new NoopComponentScopeRegistry()) {}
107+
private refEmitter: ReferenceEmitter, private aliasingHost: AliasingHost|null) {}
109108

110109
/**
111110
* Add an NgModule's data to the registry.
@@ -126,9 +125,6 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
126125
const scope = !this.declarationToModule.has(clazz) ?
127126
null :
128127
this.getScopeOfModule(this.declarationToModule.get(clazz) !);
129-
if (scope !== null) {
130-
this.componentScopeRegistry.registerComponentScope(clazz, scope);
131-
}
132128
return scope;
133129
}
134130

@@ -357,7 +353,6 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
357353
*/
358354
setComponentAsRequiringRemoteScoping(node: ClassDeclaration): void {
359355
this.remoteScoping.add(node);
360-
this.componentScopeRegistry.setComponentAsRequiringRemoteScoping(node);
361356
}
362357

363358
/**

packages/compiler-cli/src/ngtsc/transform/src/compilation.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,6 @@ export class IvyCompilation {
173173
private analyze(sf: ts.SourceFile, preanalyze: true): Promise<void>|undefined;
174174
private analyze(sf: ts.SourceFile, preanalyze: boolean): Promise<void>|undefined {
175175
const promises: Promise<void>[] = [];
176-
if (this.incrementalState.safeToSkip(sf)) {
177-
return;
178-
}
179176
const analyzeClass = (node: ClassDeclaration): void => {
180177
const ivyClass = this.detectHandlersForClass(node);
181178

0 commit comments

Comments
 (0)