Skip to content

Commit 4d9c456

Browse files
JoostKcrisbeto
authored andcommitted
fix(compiler-cli): ensure component import diagnostics are reported within the imports expression
PR #60455 improved error reporting for `@Component.imports` by scoping the diagnostic to an individual element within the `imports` array, but this may introduce hard to track diagnostics when it ends up being reported (far) away from the component itself. This can be even more problematic when the diagnostic would end up being reported in a declaration file, as happened in issue #65686; the declaration files of an imported library contained syntax that the static interpreter did not support, hence the `@Component.imports` was rejected with a diagnostic reported in the library's declaration file. This diagnostic isn't guaranteed to be reported (e.g. the CLI only gathers Angular-specific diagnostics for Angular-compiled files, which excludes declaration files). This commit changes the diagnostic location to ensure it is being reported within the `@Component.imports` expression, in most cases retaining the desirable effect of #60455 while avoiding out-of-band diagnostics. (cherry picked from commit 106ba63)
1 parent aa3b028 commit 4d9c456

File tree

2 files changed

+89
-15
lines changed

2 files changed

+89
-15
lines changed

packages/compiler-cli/src/ngtsc/annotations/component/src/util.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,18 @@ export function validateAndFlattenComponentImports(
9999

100100
for (let i = 0; i < imports.length; i++) {
101101
const ref = imports[i];
102+
let refExpr = expr;
103+
if (
104+
ts.isArrayLiteralExpression(expr) &&
105+
expr.elements.length === imports.length &&
106+
!expr.elements.some(ts.isSpreadAssignment)
107+
) {
108+
refExpr = expr.elements[i];
109+
}
102110

103111
if (Array.isArray(ref)) {
104112
const {imports: childImports, diagnostics: childDiagnostics} =
105-
validateAndFlattenComponentImports(ref, expr, isDeferred);
113+
validateAndFlattenComponentImports(ref, refExpr, isDeferred);
106114
flattened.push(...childImports);
107115
diagnostics.push(...childDiagnostics);
108116
} else if (ref instanceof Reference) {
@@ -138,20 +146,16 @@ export function validateAndFlattenComponentImports(
138146
let diagnosticNode: ts.Node;
139147
let diagnosticValue: ResolvedValue;
140148

141-
if (ref instanceof DynamicValue) {
149+
// Reporting a diagnostic on the entire array can be noisy, especially if the user has a
150+
// large array. Attempt to determine the most accurate position within the `imports` expression to report the
151+
// diagnostic on.
152+
if (ref instanceof DynamicValue && isWithinExpression(ref.node, expr)) {
153+
// Use the dynamic value position itself if it occurs within the `imports` expression.
142154
diagnosticNode = ref.node;
143155
diagnosticValue = ref;
144-
} else if (
145-
ts.isArrayLiteralExpression(expr) &&
146-
expr.elements.length === imports.length &&
147-
!expr.elements.some(ts.isSpreadAssignment) &&
148-
!imports.some(Array.isArray)
149-
) {
150-
// Reporting a diagnostic on the entire array can be noisy, especially if the user has a
151-
// large array. The most common case is that the array will be static so we can reliably
152-
// trace back a `ResolvedValue` back to its node using its index. This allows us to report
153-
// the exact node that caused the issue.
154-
diagnosticNode = expr.elements[i];
156+
} else if (refExpr !== expr) {
157+
// The reference comes from a specific element in `expr`, so use that element to report the diagnostic on.
158+
diagnosticNode = refExpr;
155159
diagnosticValue = ref;
156160
} else {
157161
diagnosticNode = expr;
@@ -167,6 +171,17 @@ export function validateAndFlattenComponentImports(
167171
return {imports: flattened, diagnostics};
168172
}
169173

174+
function isWithinExpression(node: ts.Node, expr: ts.Expression): boolean {
175+
let current: ts.Node | undefined = node;
176+
while (current !== undefined) {
177+
if (current === expr) {
178+
return true;
179+
}
180+
current = current.parent;
181+
}
182+
return false;
183+
}
184+
170185
/**
171186
* Inspects `value` to determine if it resembles a `ModuleWithProviders` value. This is an
172187
* approximation only suitable for error reporting as any resolved object with an `ngModule`

packages/compiler-cli/test/ngtsc/ngtsc_spec.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2589,12 +2589,70 @@ runInEachFileSystem((os: string) => {
25892589
export class HelloDir {}
25902590
25912591
const someVar = {} as any;
2592+
const tuple = [() => {}] as const;
25922593
25932594
@Component({
25942595
template: '<div hello></div>',
25952596
imports: [
25962597
someVar,
25972598
HelloDir,
2599+
'invalid',
2600+
tuple,
2601+
]
2602+
})
2603+
export class TestCmp {}
2604+
`,
2605+
);
2606+
2607+
const diags = env.driveDiagnostics();
2608+
expect(diags.length).toBe(3);
2609+
{
2610+
const message = ts.flattenDiagnosticMessageText(diags[0].messageText, '\n');
2611+
expect(getDiagnosticSourceCode(diags[0])).toBe('someVar');
2612+
expect(message).toContain(
2613+
`'imports' must be an array of components, directives, pipes, or NgModules.`,
2614+
);
2615+
expect(message).toContain(`Value is of type '{}'`);
2616+
}
2617+
{
2618+
const message = ts.flattenDiagnosticMessageText(diags[1].messageText, '\n');
2619+
expect(getDiagnosticSourceCode(diags[1])).toBe(`'invalid'`);
2620+
expect(message).toContain(
2621+
`'imports' must be an array of components, directives, pipes, or NgModules.`,
2622+
);
2623+
expect(message).toContain(`Value is of type 'string'`);
2624+
}
2625+
{
2626+
const message = ts.flattenDiagnosticMessageText(diags[2].messageText, '\n');
2627+
expect(getDiagnosticSourceCode(diags[2])).toBe('tuple');
2628+
expect(message).toContain(
2629+
`'imports' must be an array of components, directives, pipes, or NgModules.`,
2630+
);
2631+
expect(message).toContain(`Value is of type '[(not statically analyzable)]'.`);
2632+
}
2633+
});
2634+
2635+
it('should report imports diagnostic for declaration file in original expression', () => {
2636+
env.write(
2637+
'node_modules/external/index.d.ts',
2638+
`
2639+
export declare const UNRESOLVED_ITEM: readonly [unresolved];
2640+
`,
2641+
);
2642+
env.write(
2643+
'test.ts',
2644+
`
2645+
import {Component, Directive} from '@angular/core';
2646+
import {UNRESOLVED_ITEM as Unresolved} from 'external';
2647+
2648+
@Directive({selector: '[hello]'})
2649+
export class HelloDir {}
2650+
2651+
@Component({
2652+
template: '<div hello></div>',
2653+
imports: [
2654+
[Unresolved],
2655+
HelloDir,
25982656
]
25992657
})
26002658
export class TestCmp {}
@@ -2606,11 +2664,12 @@ runInEachFileSystem((os: string) => {
26062664
? ts.flattenDiagnosticMessageText(diags[0].messageText, '\n')
26072665
: '';
26082666
expect(diags.length).toBe(1);
2609-
expect(getDiagnosticSourceCode(diags[0])).toBe('someVar');
2667+
expect(diags[0].file!.fileName).toContain('test.ts');
2668+
expect(getDiagnosticSourceCode(diags[0])).toBe('Unresolved');
26102669
expect(message).toContain(
26112670
`'imports' must be an array of components, directives, pipes, or NgModules.`,
26122671
);
2613-
expect(message).toContain(`Value is of type '{}'`);
2672+
expect(message).toContain(`Value is of type '[(not statically analyzable)]'.`);
26142673
});
26152674

26162675
describe('empty and missing selectors', () => {

0 commit comments

Comments
 (0)