Skip to content

Commit 563dbd9

Browse files
SkyZeroZxAndrewKushnir
authored andcommitted
feat(compiler-cli): Adds diagnostic for misconfigured @defer triggers (#64069)
Warns when @defer blocks define unreachable or redundant triggers, such as multiple main triggers, ineffective prefetches, or timer delays not scheduled before rendering. PR Close #64069
1 parent eee8eab commit 563dbd9

File tree

12 files changed

+577
-0
lines changed

12 files changed

+577
-0
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Defer Trigger Misconfiguration
2+
3+
This diagnostic detects unreachable or redundant triggers in `@defer` blocks.
4+
5+
```typescript
6+
7+
import {Component} from '@angular/core';
8+
9+
@Component({
10+
template: `
11+
@defer (on immediate; on timer(500ms)) {
12+
<large-component />
13+
}
14+
`
15+
})
16+
class MyComponent {}
17+
```
18+
19+
## What's wrong with that?
20+
21+
The diagnostic identifies several problematic patterns in defer trigger configuration that lead to:
22+
23+
- **Unnecessary code** that never affects behavior
24+
- **Missed optimization opportunities** for better performance
25+
- **Unreachable prefetch triggers** that will never execute
26+
27+
28+
## Diagnostic warning cases
29+
30+
This diagnostic flags the following problematic patterns:
31+
32+
### `immediate` with prefetch triggers
33+
34+
**Bad — prefetch never runs**
35+
36+
```typescript
37+
@Component({
38+
template: `
39+
@defer (on immediate; prefetch on idle) {
40+
<my-cmp />
41+
}
42+
`
43+
})
44+
class MyComponent {}
45+
```
46+
47+
**Good — remove redundant prefetch**
48+
49+
```typescript
50+
@Component({
51+
template: `
52+
@defer (on immediate) {
53+
<my-cmp />
54+
}
55+
`
56+
})
57+
class MyComponent {}
58+
```
59+
60+
### Prefetch timer not earlier than main timer
61+
62+
**Bad — prefetch is later than main**
63+
64+
```typescript
65+
@Component({
66+
template: `
67+
@defer (on timer(100ms); prefetch on timer(3000ms)) {
68+
<my-cmp />
69+
}
70+
`
71+
})
72+
class MyComponent {}
73+
```
74+
75+
**Bad — equal timing provides no benefit**
76+
77+
```typescript
78+
@Component({
79+
template: `
80+
@defer (on timer(500ms); prefetch on timer(500ms)) {
81+
<my-cmp />
82+
}
83+
`
84+
})
85+
class MyComponent {}
86+
```
87+
88+
**Good — prefetch fires earlier**
89+
90+
```typescript
91+
@Component({
92+
template: `
93+
@defer (on timer(1000ms); prefetch on timer(500ms)) {
94+
<large-component />
95+
}
96+
`
97+
})
98+
class MyComponent {}
99+
```
100+
101+
### Identical prefetch and main triggers
102+
103+
**Bad — identical viewport trigger**
104+
105+
```typescript
106+
@Component({
107+
template: `
108+
@defer (on viewport; prefetch on viewport) {
109+
<my-cmp />
110+
}
111+
`
112+
})
113+
class MyComponent {}
114+
```
115+
116+
**Bad — identical interaction target**
117+
118+
```typescript
119+
@Component({
120+
template: `
121+
<button #loadBtn>Load</button>
122+
@defer (on interaction(loadBtn); prefetch on interaction(loadBtn)) {
123+
<large-component />
124+
}
125+
`
126+
})
127+
class MyComponent {}
128+
```
129+
130+
**Good — remove redundant prefetch**
131+
132+
```typescript
133+
@Component({
134+
template: `
135+
<button #loadBtn>Load</button>
136+
@defer (on interaction(loadBtn)) {
137+
<large-component />
138+
}
139+
`
140+
})
141+
class MyComponent {}
142+
```
143+
144+
**Good — use different targets for prefetch and main**
145+
146+
```typescript
147+
@Component({
148+
template: `
149+
<div #hoverArea>Hover to prefetch</div>
150+
<button #clickBtn>Click to load</button>
151+
@defer (on interaction(clickBtn); prefetch on hover(hoverArea)) {
152+
<large-component />
153+
}
154+
`
155+
})
156+
class MyComponent {}
157+
```
158+
159+
160+
## Configuration requirements
161+
162+
[`strictTemplates`](tools/cli/template-typecheck#strict-mode) must be enabled for any extended diagnostic to emit.
163+
`deferTriggerMisconfiguration` has no additional requirements beyond `strictTemplates`.
164+
165+
## What if I can't avoid this?
166+
167+
This diagnostic can be disabled by editing the project's `tsconfig.json` file:
168+
169+
```json
170+
{
171+
"angularCompilerOptions": {
172+
"extendedDiagnostics": {
173+
"checks": {
174+
"deferTriggerMisconfiguration": "suppress"
175+
}
176+
}
177+
}
178+
}
179+
```
180+
181+
See [extended diagnostic configuration](extended-diagnostics#configuration) for more info.

adev/src/content/reference/extended-diagnostics/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Currently, Angular supports the following extended diagnostics:
2525
| `NG8115` | [`uninvokedTrackFunction`](extended-diagnostics/NG8115) |
2626
| `NG8116` | [`missingStructuralDirective`](extended-diagnostics/NG8116) |
2727
| `NG8117` | [`uninvokedFunctionInTextInterpolation`](extended-diagnostics/NG8117) |
28+
| `NG8021` | [`deferTriggerMisconfiguration`](extended-diagnostics/NG8021) |
2829

2930
## Configuration
3031

goldens/public-api/compiler-cli/error_code.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export enum ErrorCode {
4141
DECORATOR_UNEXPECTED = 1005,
4242
DEFER_IMPLICIT_TRIGGER_INVALID_PLACEHOLDER = 8020,
4343
DEFER_IMPLICIT_TRIGGER_MISSING_PLACEHOLDER = 8019,
44+
DEFER_TRIGGER_MISCONFIGURATION = 8021,
4445
DEFERRED_DEPENDENCY_IMPORTED_EAGERLY = 8014,
4546
DEFERRED_DIRECTIVE_USED_EAGERLY = 8013,
4647
DEFERRED_PIPE_USED_EAGERLY = 8012,

goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export enum ExtendedTemplateDiagnosticName {
99
// (undocumented)
1010
CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION = "controlFlowPreventingContentProjection",
1111
// (undocumented)
12+
DEFER_TRIGGER_MISCONFIGURATION = "deferTriggerMisconfiguration",
13+
// (undocumented)
1214
INTERPOLATED_SIGNAL_NOT_INVOKED = "interpolatedSignalNotInvoked",
1315
// (undocumented)
1416
INVALID_BANANA_IN_BOX = "invalidBananaInBox",

packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,13 @@ export enum ErrorCode {
423423
*/
424424
DEFER_IMPLICIT_TRIGGER_INVALID_PLACEHOLDER = 8020,
425425

426+
/**
427+
* Raised when an `@defer` block defines unreachable or redundant triggers.
428+
* Examples: multiple main triggers, 'on immediate' together with other mains or any prefetch,
429+
* prefetch timer delay that is not earlier than the main timer, or an identical prefetch
430+
*/
431+
DEFER_TRIGGER_MISCONFIGURATION = 8021,
432+
426433
/**
427434
* A two way binding in a template has an incorrect syntax,
428435
* parentheses outside brackets. For example:

packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@ export enum ExtendedTemplateDiagnosticName {
3333
UNUSED_STANDALONE_IMPORTS = 'unusedStandaloneImports',
3434
UNPARENTHESIZED_NULLISH_COALESCING = 'unparenthesizedNullishCoalescing',
3535
UNINVOKED_FUNCTION_IN_TEXT_INTERPOLATION = 'uninvokedFunctionInTextInterpolation',
36+
DEFER_TRIGGER_MISCONFIGURATION = 'deferTriggerMisconfiguration',
3637
}

packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ts_project(
1313
"//packages/compiler-cli/src/ngtsc/diagnostics",
1414
"//packages/compiler-cli/src/ngtsc/typecheck/api",
1515
"//packages/compiler-cli/src/ngtsc/typecheck/extended/api",
16+
"//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/defer_trigger_misconfiguration",
1617
"//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked",
1718
"//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/invalid_banana_in_box",
1819
"//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_control_flow_directive",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
load("//tools:defaults.bzl", "ts_project")
2+
3+
ts_project(
4+
name = "defer_trigger_misconfiguration",
5+
srcs = ["index.ts"],
6+
visibility = [
7+
"//packages/compiler-cli/src/ngtsc:__subpackages__",
8+
"//packages/compiler-cli/test/ngtsc:__pkg__",
9+
],
10+
deps = [
11+
"//:node_modules/typescript",
12+
"//packages/compiler",
13+
"//packages/compiler-cli/src/ngtsc/diagnostics",
14+
"//packages/compiler-cli/src/ngtsc/typecheck/api",
15+
"//packages/compiler-cli/src/ngtsc/typecheck/extended/api",
16+
],
17+
)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import ts from 'typescript';
10+
11+
import {
12+
TmplAstDeferredBlock,
13+
TmplAstDeferredTrigger,
14+
TmplAstHoverDeferredTrigger,
15+
TmplAstImmediateDeferredTrigger,
16+
TmplAstInteractionDeferredTrigger,
17+
TmplAstTimerDeferredTrigger,
18+
TmplAstViewportDeferredTrigger,
19+
} from '@angular/compiler';
20+
21+
import {ErrorCode, ExtendedTemplateDiagnosticName} from '../../../../diagnostics';
22+
import {NgTemplateDiagnostic} from '../../../api';
23+
import {TemplateCheckFactory, TemplateCheckWithVisitor, TemplateContext} from '../../api';
24+
25+
/**
26+
* This check implements warnings for unreachable or redundant @defer triggers.
27+
* Emits ErrorCode.DEFER_TRIGGER_MISCONFIGURATION with messages matching the project's
28+
* expected text.
29+
*/
30+
class DeferTriggerMisconfiguration extends TemplateCheckWithVisitor<ErrorCode.DEFER_TRIGGER_MISCONFIGURATION> {
31+
override code = ErrorCode.DEFER_TRIGGER_MISCONFIGURATION as const;
32+
33+
override visitNode(
34+
ctx: TemplateContext<ErrorCode.DEFER_TRIGGER_MISCONFIGURATION>,
35+
component: ts.ClassDeclaration,
36+
node: any,
37+
): NgTemplateDiagnostic<ErrorCode.DEFER_TRIGGER_MISCONFIGURATION>[] {
38+
if (!(node instanceof TmplAstDeferredBlock)) return [];
39+
40+
const mainKeys = Object.keys(node.triggers) as Array<keyof typeof node.triggers>;
41+
const prefetchKeys = Object.keys(node.prefetchTriggers) as Array<
42+
keyof typeof node.prefetchTriggers
43+
>;
44+
45+
// Gather actual trigger objects for mains and prefetch (only defined ones)
46+
const mains = mainKeys
47+
.map((k) => node.triggers[k])
48+
.filter((t): t is TmplAstDeferredTrigger => t !== undefined && t !== null);
49+
50+
const prefetches = prefetchKeys
51+
.map((k) => node.prefetchTriggers[k])
52+
.filter((t): t is TmplAstDeferredTrigger => t !== undefined && t !== null);
53+
54+
const diags: NgTemplateDiagnostic<ErrorCode.DEFER_TRIGGER_MISCONFIGURATION>[] = [];
55+
56+
// 'on immediate' dominance
57+
const hasImmediateMain = mains.some((t) => t instanceof TmplAstImmediateDeferredTrigger);
58+
if (hasImmediateMain) {
59+
if (mains.length > 1) {
60+
const msg = `The 'immediate' trigger makes additional triggers redundant.`;
61+
diags.push(ctx.makeTemplateDiagnostic(node.sourceSpan, msg));
62+
}
63+
if (prefetches.length > 0) {
64+
const msg = `Prefetch triggers have no effect because 'immediate' executes earlier.`;
65+
diags.push(ctx.makeTemplateDiagnostic(node.sourceSpan, msg));
66+
}
67+
}
68+
69+
// If there is exactly one main and at least one prefetch, compare them.
70+
if (mains.length === 1 && prefetches.length > 0) {
71+
const main = mains[0];
72+
73+
for (const pre of prefetches) {
74+
// Timer vs Timer: warn when prefetch delay >= main delay
75+
const isTimerTriggger =
76+
main instanceof TmplAstTimerDeferredTrigger && pre instanceof TmplAstTimerDeferredTrigger;
77+
if (isTimerTriggger) {
78+
const mainDelay = main.delay;
79+
const preDelay = pre.delay;
80+
if (preDelay >= mainDelay) {
81+
const msg = `The Prefetch 'timer(${preDelay}ms)' is not scheduled before the main 'timer(${mainDelay}ms)', so it won’t run prior to rendering. Lower the prefetch delay or remove it.`;
82+
diags.push(ctx.makeTemplateDiagnostic(pre.sourceSpan ?? node.sourceSpan, msg));
83+
}
84+
}
85+
86+
// Reference-based triggers (hover/interaction/viewport): only warn if both
87+
// have a reference and the references are identical. If references differ
88+
// (or one is missing), the prefetch targets a different element and
89+
// provides potential value.
90+
91+
const isHoverTrigger =
92+
main instanceof TmplAstHoverDeferredTrigger && pre instanceof TmplAstHoverDeferredTrigger;
93+
94+
const isInteractionTrigger =
95+
main instanceof TmplAstInteractionDeferredTrigger &&
96+
pre instanceof TmplAstInteractionDeferredTrigger;
97+
98+
const isViewportTrigger =
99+
main instanceof TmplAstViewportDeferredTrigger &&
100+
pre instanceof TmplAstViewportDeferredTrigger;
101+
102+
if (isHoverTrigger || isInteractionTrigger || isViewportTrigger) {
103+
const mainRef = main.reference;
104+
const preRef = pre.reference;
105+
if (mainRef && preRef && mainRef === preRef) {
106+
const kindName = main.constructor.name.replace('DeferredTrigger', '').toLowerCase();
107+
const msg = `Prefetch '${kindName}' matches the main trigger and provides no benefit. Remove the prefetch modifier.`;
108+
diags.push(ctx.makeTemplateDiagnostic(pre.sourceSpan ?? node.sourceSpan, msg));
109+
}
110+
// otherwise, different references or missing reference => no warning
111+
continue;
112+
}
113+
114+
// Syntactic identical: same class for immediate/idle/never etc. (timers handled above)
115+
if (
116+
main.constructor === pre.constructor &&
117+
!(main instanceof TmplAstTimerDeferredTrigger)
118+
) {
119+
const kind =
120+
main instanceof TmplAstImmediateDeferredTrigger
121+
? 'immediate'
122+
: main.constructor.name.replace('DeferredTrigger', '').toLowerCase();
123+
const msg = `Prefetch '${kind}' matches the main trigger and provides no benefit. Remove the prefetch modifier.`;
124+
diags.push(ctx.makeTemplateDiagnostic(pre.sourceSpan ?? node.sourceSpan, msg));
125+
}
126+
}
127+
}
128+
129+
return diags;
130+
}
131+
}
132+
133+
export const factory: TemplateCheckFactory<
134+
ErrorCode.DEFER_TRIGGER_MISCONFIGURATION,
135+
ExtendedTemplateDiagnosticName.DEFER_TRIGGER_MISCONFIGURATION
136+
> = {
137+
code: ErrorCode.DEFER_TRIGGER_MISCONFIGURATION,
138+
name: ExtendedTemplateDiagnosticName.DEFER_TRIGGER_MISCONFIGURATION,
139+
create: () => new DeferTriggerMisconfiguration(),
140+
};

0 commit comments

Comments
 (0)