Skip to content

Commit 07845f0

Browse files
authored
perf(@angular/build): optimize incremental watch rebuilds in AOT compilation
- Pre-populate `.d.ts` shape signatures on the previous `BuilderProgram` for modified files prior to diffing when type-checking or TypeScript transpilation is active, preventing false-positive `.d.ts` cascades on incremental watch edits. - Synchronize `typeScriptProgram` with `angularCompiler.getCurrentProgram()` after `collectDiagnostics()` and clear uncommitted `changedFilesSet` and `oldSignatures` entries so subsequent incremental builds preserve `.d.ts` signatures and do not treat `.ngtypecheck.ts` shims as newly added files. - Detect affected dependent `.ngtypecheck.ts` shims in `isolatedModules` / `verbatimModuleSyntax` mode via TypeScript's `state.hasCalledUpdateShapeSignature`, ensuring cross-component template diagnostics are re-evaluated when a dependency's `.d.ts` shape changes without cascading on private edits. - Avoid `filter().flatMap()` allocations and skip `getResourceDependencies()` for declaration files when collecting `referencedFiles`.
1 parent 2c518a6 commit 07845f0

4 files changed

Lines changed: 613 additions & 108 deletions

File tree

‎packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts‎

Lines changed: 230 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,6 @@ import {
1515
expectNoLog,
1616
} from '../setup';
1717

18-
/**
19-
* Maximum time in milliseconds for single build/rebuild
20-
* This accounts for CI variability.
21-
*/
22-
export const BUILD_TIMEOUT = 30_000;
23-
2418
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
2519
describe('Behavior: "Rebuild Error Detection"', () => {
2620
it('detects template errors with no AOT codegen or TS emit differences', async () => {
@@ -128,6 +122,235 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
128122
);
129123
});
130124

125+
it('detects template errors across components on watch rebuild when isolatedModules is enabled', async () => {
126+
harness.useTarget('build', {
127+
...BASE_OPTIONS,
128+
watch: true,
129+
});
130+
131+
await harness.modifyFile('tsconfig.json', (content) => {
132+
const tsconfig = JSON.parse(content);
133+
tsconfig.compilerOptions = {
134+
...tsconfig.compilerOptions,
135+
isolatedModules: true,
136+
};
137+
138+
return JSON.stringify(tsconfig);
139+
});
140+
141+
const goodChildComponentContents = `
142+
import { Component, Input } from '@angular/core';
143+
@Component({
144+
selector: 'child',
145+
standalone: true,
146+
template: '<p>{{ value }}</p>',
147+
})
148+
export class ChildComponent {
149+
@Input() value!: number;
150+
}
151+
`;
152+
153+
const typeErrorText = `Type 'number' is not assignable to type 'string'.`;
154+
155+
await harness.writeFiles({
156+
'src/app/child.component.ts': goodChildComponentContents,
157+
'src/app/app.module.ts': `
158+
import { NgModule } from '@angular/core';
159+
import { BrowserModule } from '@angular/platform-browser';
160+
import { AppComponent } from './app.component';
161+
@NgModule({
162+
imports: [
163+
BrowserModule,
164+
AppComponent,
165+
],
166+
providers: [],
167+
bootstrap: [AppComponent]
168+
})
169+
export class AppModule { }
170+
`,
171+
'src/app/app.component.ts': `
172+
import { Component } from '@angular/core';
173+
import { ChildComponent } from './child.component';
174+
@Component({
175+
selector: 'app-root',
176+
standalone: true,
177+
imports: [ChildComponent],
178+
template: '<child [value]="123" />',
179+
})
180+
export class AppComponent {}
181+
`,
182+
});
183+
184+
await harness.executeWithCases(
185+
[
186+
async ({ result }) => {
187+
expect(result?.success).toBeTrue();
188+
189+
// Update child component to change input type from number to string
190+
await harness.writeFile(
191+
'src/app/child.component.ts',
192+
`
193+
import { Component, Input } from '@angular/core';
194+
@Component({
195+
selector: 'child',
196+
standalone: true,
197+
template: '<p>{{ value }}</p>',
198+
})
199+
export class ChildComponent {
200+
@Input() value!: string;
201+
}
202+
`,
203+
);
204+
},
205+
async ({ result, logs }) => {
206+
expect(result?.success).toBeFalse();
207+
expectLog(logs, typeErrorText);
208+
209+
// Make an unrelated change to verify error persists
210+
await harness.modifyFile('src/main.ts', (content) => content + '\n');
211+
},
212+
async ({ result, logs }) => {
213+
expect(result?.success).toBeFalse();
214+
expectLog(logs, typeErrorText);
215+
216+
// Revert back to number
217+
await harness.writeFile('src/app/child.component.ts', goodChildComponentContents);
218+
},
219+
async ({ result, logs }) => {
220+
expect(result?.success).toBeTrue();
221+
expectNoLog(logs, typeErrorText);
222+
223+
// Make an unrelated change to verify error cache cleared
224+
await harness.modifyFile('src/main.ts', (content) => content + '\n');
225+
},
226+
({ result, logs }) => {
227+
expect(result?.success).toBeTrue();
228+
expectNoLog(logs, typeErrorText);
229+
},
230+
],
231+
{ outputLogsOnFailure: false },
232+
);
233+
});
234+
235+
it('detects template errors when an input becomes required or a selector is renamed on watch rebuild', async () => {
236+
harness.useTarget('build', {
237+
...BASE_OPTIONS,
238+
watch: true,
239+
});
240+
241+
await harness.modifyFile('tsconfig.json', (content) => {
242+
const tsconfig = JSON.parse(content);
243+
tsconfig.compilerOptions = {
244+
...tsconfig.compilerOptions,
245+
isolatedModules: true,
246+
};
247+
248+
return JSON.stringify(tsconfig);
249+
});
250+
251+
const goodChildComponentContents = `
252+
import { Component, input } from '@angular/core';
253+
@Component({
254+
selector: 'app-child',
255+
standalone: true,
256+
template: '<p>{{ label() }}</p>',
257+
})
258+
export class ChildComponent {
259+
label = input('');
260+
}
261+
`;
262+
263+
const requiredInputErrorText = `Required input 'label' from component ChildComponent must be specified.`;
264+
const unknownElementErrorText = `'app-child' is not a known element`;
265+
266+
await harness.writeFiles({
267+
'src/app/child.component.ts': goodChildComponentContents,
268+
'src/app/app.module.ts': `
269+
import { NgModule } from '@angular/core';
270+
import { BrowserModule } from '@angular/platform-browser';
271+
import { AppComponent } from './app.component';
272+
@NgModule({
273+
imports: [
274+
BrowserModule,
275+
AppComponent,
276+
],
277+
providers: [],
278+
bootstrap: [AppComponent]
279+
})
280+
export class AppModule { }
281+
`,
282+
'src/app/app.component.ts': `
283+
import { Component } from '@angular/core';
284+
import { ChildComponent } from './child.component';
285+
@Component({
286+
selector: 'app-root',
287+
standalone: true,
288+
imports: [ChildComponent],
289+
template: '<app-child />',
290+
})
291+
export class AppComponent {}
292+
`,
293+
});
294+
295+
await harness.executeWithCases(
296+
[
297+
async ({ result }) => {
298+
expect(result?.success).toBeTrue();
299+
300+
// Make input required using signal input.required() (which has the same TS .d.ts type InputSignal<string>)
301+
await harness.writeFile(
302+
'src/app/child.component.ts',
303+
`
304+
import { Component, input } from '@angular/core';
305+
@Component({
306+
selector: 'app-child',
307+
standalone: true,
308+
template: '<p>{{ label() }}</p>',
309+
})
310+
export class ChildComponent {
311+
label = input.required<string>();
312+
}
313+
`,
314+
);
315+
},
316+
async ({ result, logs }) => {
317+
expect(result?.success).toBeFalse();
318+
expectLog(logs, requiredInputErrorText);
319+
320+
// Revert required input and rename selector instead
321+
await harness.writeFile(
322+
'src/app/child.component.ts',
323+
`
324+
import { Component, input } from '@angular/core';
325+
@Component({
326+
selector: 'app-child-renamed',
327+
standalone: true,
328+
template: '<p>{{ label() }}</p>',
329+
})
330+
export class ChildComponent {
331+
label = input('');
332+
}
333+
`,
334+
);
335+
},
336+
async ({ result, logs }) => {
337+
expect(result?.success).toBeFalse();
338+
expectNoLog(logs, requiredInputErrorText);
339+
expectLog(logs, unknownElementErrorText);
340+
341+
// Revert back to valid child component
342+
await harness.writeFile('src/app/child.component.ts', goodChildComponentContents);
343+
},
344+
({ result, logs }) => {
345+
expect(result?.success).toBeTrue();
346+
expectNoLog(logs, requiredInputErrorText);
347+
expectNoLog(logs, unknownElementErrorText);
348+
},
349+
],
350+
{ outputLogsOnFailure: false },
351+
);
352+
});
353+
131354
it('detects cumulative block syntax errors', async () => {
132355
harness.useTarget('build', {
133356
...BASE_OPTIONS,
@@ -234,7 +457,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
234457
await harness.appendToFile('src/app/app.component.html', '<div>Guten Tag</div>');
235458
},
236459
({ logs }) => {
237-
expectNoLog(logs, 'invalid-css-content');
460+
expectNoLog(logs, 'Unexpected character "EOF"');
238461

239462
harness.expectFile('dist/browser/main.js').content.toContain('Hello, world!');
240463
harness.expectFile('dist/browser/main.js').content.toContain('Guten Tag');

‎packages/angular/build/src/tools/angular/angular-host.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ export interface AngularHostOptions {
3535
* field. The `version` field is required when used with a TypeScript BuilderProgram.
3636
* @param program The TypeScript Program instance to patch.
3737
*/
38-
export function ensureSourceFileVersions(program: ts.Program): void {
38+
export function ensureSourceFileVersions(
39+
program: ts.Program,
40+
previousProgram?: ts.BuilderProgram,
41+
): void {
3942
const baseGetSourceFiles = program.getSourceFiles;
4043
// TODO: Update Angular compiler to add versions to all internal files and remove this
4144
program.getSourceFiles = function (...parameters) {
@@ -45,7 +48,11 @@ export function ensureSourceFileVersions(program: ts.Program): void {
4548

4649
for (const file of files) {
4750
if (file.version === undefined) {
48-
file.version = calculateHash(file.text);
51+
const previousFile: (ts.SourceFile & { version?: string }) | undefined =
52+
previousProgram && !file.fileName.endsWith('.ngtypecheck.ts')
53+
? previousProgram.getSourceFile(file.fileName)
54+
: undefined;
55+
file.version = previousFile?.version ?? calculateHash(file.text);
4956
}
5057
}
5158

0 commit comments

Comments
 (0)