Skip to content

Commit c7c5b2f

Browse files
alxhubmhevery
authored andcommitted
fix(compiler-cli): correct incremental behavior even with broken imports (#39923)
When the compiler is invoked via ngc or the Angular CLI, its APIs are used under the assumption that Angular analysis/diagnostics are only requested if the program has no TypeScript-level errors. A result of this assumption is that the incremental engine has not needed to resolve changes via its dependency graph when the program contained broken imports, since broken imports are a TypeScript error. The Angular Language Service for Ivy is using the compiler as a backend, and exercising its incremental compilation APIs without enforcing this assumption. As a result, the Language Service has run into issues where broken imports cause incremental compilation to fail and produce incorrect results. This commit introduces a mechanism within the compiler to keep track of files for which dependency analysis has failed, and to always treat such files as potentially affected by future incremental steps. This is tested via the Language Service infrastructure to ensure that the compiler is doing the right thing in the case of invalid imports. PR Close #39923
1 parent a6c8cc3 commit c7c5b2f

File tree

7 files changed

+114
-3
lines changed

7 files changed

+114
-3
lines changed

packages/compiler-cli/ngcc/src/analysis/util.ts

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class NoopDependencyTracker implements DependencyTracker {
1818
addResourceDependency(): void {}
1919
addTransitiveDependency(): void {}
2020
addTransitiveResources(): void {}
21+
recordDependencyAnalysisFailure(): void {}
2122
}
2223

2324
export const NOOP_DEPENDENCY_TRACKER: DependencyTracker = new NoopDependencyTracker();

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

+8
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,12 @@ export interface DependencyTracker<T extends {fileName: string} = ts.SourceFile>
6464
* `resourcesOf` they will not automatically be added to `from`.
6565
*/
6666
addTransitiveResources(from: T, resourcesOf: T): void;
67+
68+
/**
69+
* Record that the given file contains unresolvable dependencies.
70+
*
71+
* In practice, this means that the dependency graph cannot provide insight into the effects of
72+
* future changes on that file.
73+
*/
74+
recordDependencyAnalysisFailure(file: T): void;
6775
}

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

+13
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ export class FileDependencyGraph<T extends {fileName: string} = ts.SourceFile> i
5353
}
5454
}
5555

56+
recordDependencyAnalysisFailure(file: T): void {
57+
this.nodeFor(file).failedAnalysis = true;
58+
}
59+
5660
getResourceDependencies(from: T): AbsoluteFsPath[] {
5761
const node = this.nodes.get(from);
5862

@@ -97,6 +101,7 @@ export class FileDependencyGraph<T extends {fileName: string} = ts.SourceFile> i
97101
this.nodes.set(sf, {
98102
dependsOn: new Set(node.dependsOn),
99103
usesResources: new Set(node.usesResources),
104+
failedAnalysis: false,
100105
});
101106
}
102107
}
@@ -109,6 +114,7 @@ export class FileDependencyGraph<T extends {fileName: string} = ts.SourceFile> i
109114
this.nodes.set(sf, {
110115
dependsOn: new Set<string>(),
111116
usesResources: new Set<AbsoluteFsPath>(),
117+
failedAnalysis: false,
112118
});
113119
}
114120
return this.nodes.get(sf)!;
@@ -122,6 +128,12 @@ export class FileDependencyGraph<T extends {fileName: string} = ts.SourceFile> i
122128
function isLogicallyChanged<T extends {fileName: string}>(
123129
sf: T, node: FileNode, changedTsPaths: ReadonlySet<string>, deletedTsPaths: ReadonlySet<string>,
124130
changedResources: ReadonlySet<AbsoluteFsPath>): boolean {
131+
// A file is assumed to have logically changed if its dependencies could not be determined
132+
// accurately.
133+
if (node.failedAnalysis) {
134+
return true;
135+
}
136+
125137
// A file is logically changed if it has physically changed itself (including being deleted).
126138
if (changedTsPaths.has(sf.fileName) || deletedTsPaths.has(sf.fileName)) {
127139
return true;
@@ -146,6 +158,7 @@ function isLogicallyChanged<T extends {fileName: string}>(
146158
interface FileNode {
147159
dependsOn: Set<string>;
148160
usesResources: Set<AbsoluteFsPath>;
161+
failedAnalysis: boolean;
149162
}
150163

151164
const EMPTY_SET: ReadonlySet<any> = new Set<any>();

packages/compiler-cli/src/ngtsc/partial_evaluator/src/interpreter.ts

+9
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,15 @@ export class StaticInterpreter {
220220
if (node.originalKeywordKind === ts.SyntaxKind.UndefinedKeyword) {
221221
return undefined;
222222
} else {
223+
// Check if the symbol here is imported.
224+
if (this.dependencyTracker !== null && this.host.getImportOfIdentifier(node) !== null) {
225+
// It was, but no declaration for the node could be found. This means that the dependency
226+
// graph for the current file cannot be properly updated to account for this (broken)
227+
// import. Instead, the originating file is reported as failing dependency analysis,
228+
// ensuring that future compilations will always attempt to re-resolve the previously
229+
// broken identifier.
230+
this.dependencyTracker.recordDependencyAnalysisFailure(context.originatingFile);
231+
}
223232
return DynamicValue.fromUnknownIdentifier(node);
224233
}
225234
}

packages/compiler-cli/src/ngtsc/partial_evaluator/test/evaluator_spec.ts

+1
Original file line numberDiff line numberDiff line change
@@ -991,4 +991,5 @@ const fakeDepTracker: DependencyTracker = {
991991
addResourceDependency: () => undefined,
992992
addTransitiveDependency: () => undefined,
993993
addTransitiveResources: () => undefined,
994+
recordDependencyAnalysisFailure: () => undefined,
994995
};

packages/language-service/ivy/test/compiler_spec.ts

+68
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,72 @@ describe('language-service/compiler integration', () => {
6060
expect(diags.map(diag => diag.messageText))
6161
.toContain(`Type 'number' is not assignable to type 'string'.`);
6262
});
63+
64+
it('should handle broken imports during incremental build steps', () => {
65+
// This test validates that the compiler's incremental APIs correctly handle a broken import
66+
// when invoked via the Language Service. Testing this via the LS is important as only the LS
67+
// requests Angular analysis in the presence of TypeScript-level errors. In the case of broken
68+
// imports this distinction is especially important: Angular's incremental analysis is
69+
// built on the the compiler's dependency graph, and this graph must be able to function even
70+
// with broken imports.
71+
//
72+
// The test works by creating a component/module pair where the module imports and declares a
73+
// component from a separate file. That component is initially not exported, meaning the
74+
// module's import is broken. Angular will correctly complain that the NgModule is declaring a
75+
// value which is not statically analyzable.
76+
//
77+
// Then, the component file is fixed to properly export the component class, and an incremental
78+
// build step is performed. The compiler should recognize that the module's previous analysis
79+
// is stale, even though it was not able to fully understand the import during the first pass.
80+
81+
const moduleFile = absoluteFrom('/mod.ts');
82+
const componentFile = absoluteFrom('/cmp.ts');
83+
84+
const componentSource = (isExported: boolean): string => `
85+
import {Component} from '@angular/core';
86+
87+
@Component({
88+
selector: 'some-cmp',
89+
template: 'Not important',
90+
})
91+
${isExported ? 'export' : ''} class Cmp {}
92+
`;
93+
94+
const env = LanguageServiceTestEnvironment.setup([
95+
{
96+
name: moduleFile,
97+
contents: `
98+
import {NgModule} from '@angular/core';
99+
100+
import {Cmp} from './cmp';
101+
102+
@NgModule({
103+
declarations: [Cmp],
104+
})
105+
export class Mod {}
106+
`,
107+
isRoot: true,
108+
},
109+
{
110+
name: componentFile,
111+
contents: componentSource(/* start with component not exported */ false),
112+
isRoot: true,
113+
}
114+
]);
115+
116+
// Angular should be complaining about the module not being understandable.
117+
const programBefore = env.tsLS.getProgram()!;
118+
const moduleSfBefore = programBefore.getSourceFile(moduleFile)!;
119+
const ngDiagsBefore = env.ngLS.compilerFactory.getOrCreate().getDiagnostics(moduleSfBefore);
120+
expect(ngDiagsBefore.length).toBe(1);
121+
122+
// Fix the import.
123+
env.updateFile(componentFile, componentSource(/* properly export the component */ true));
124+
125+
// Angular should stop complaining about the NgModule.
126+
const programAfter = env.tsLS.getProgram()!;
127+
const moduleSfAfter = programAfter.getSourceFile(moduleFile)!;
128+
const ngDiagsAfter = env.ngLS.compilerFactory.getOrCreate().getDiagnostics(moduleSfAfter);
129+
expect(ngDiagsAfter.length).toBe(0);
130+
});
63131
});

packages/language-service/ivy/test/env.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ export interface TemplateOverwriteResult {
5555

5656
export class LanguageServiceTestEnvironment {
5757
private constructor(
58-
private tsLS: ts.LanguageService, readonly ngLS: LanguageService,
59-
readonly host: MockServerHost) {}
58+
readonly tsLS: ts.LanguageService, readonly ngLS: LanguageService,
59+
readonly projectService: ts.server.ProjectService, readonly host: MockServerHost) {}
6060

6161
static setup(files: TestFile[], options: TestableOptions = {}): LanguageServiceTestEnvironment {
6262
const fs = getFileSystem();
@@ -106,7 +106,7 @@ export class LanguageServiceTestEnvironment {
106106
const tsLS = project.getLanguageService();
107107

108108
const ngLS = new LanguageService(project, tsLS);
109-
return new LanguageServiceTestEnvironment(tsLS, ngLS, host);
109+
return new LanguageServiceTestEnvironment(tsLS, ngLS, projectService, host);
110110
}
111111

112112
getClass(fileName: AbsoluteFsPath, className: string): ts.ClassDeclaration {
@@ -136,6 +136,17 @@ export class LanguageServiceTestEnvironment {
136136
return {cursor, nodes, component, text};
137137
}
138138

139+
updateFile(fileName: AbsoluteFsPath, contents: string): void {
140+
const scriptInfo = this.projectService.getScriptInfo(fileName);
141+
if (scriptInfo === undefined) {
142+
throw new Error(`Could not find a file named ${fileName}`);
143+
}
144+
145+
// Get the current contents to find the length
146+
const len = scriptInfo.getSnapshot().getLength();
147+
scriptInfo.editContent(0, len, contents);
148+
}
149+
139150
expectNoSourceDiagnostics(): void {
140151
const program = this.tsLS.getProgram();
141152
if (program === undefined) {

0 commit comments

Comments
 (0)