diff --git a/adev/src/content/reference/extended-diagnostics/NG8116.md b/adev/src/content/reference/extended-diagnostics/NG8116.md new file mode 100644 index 000000000000..a95210a97240 --- /dev/null +++ b/adev/src/content/reference/extended-diagnostics/NG8116.md @@ -0,0 +1,61 @@ +# Missing structural directive + +This diagnostic ensures that a standalone component using custom structural directives (e.g., `*select` or `*featureFlag`) in a template has the necessary imports for those directives. + + + +import {Component} from '@angular/core'; + +@Component({ + // Template uses `*select`, but no corresponding directive imported. + imports: [], + template: `

{{data}}

`, +}) +class MyComponent {} + +
+ +## What's wrong with that? + +Using a structural directive without importing it will fail at runtime, as Angular attempts to bind to a `select` property of the HTML element, which does not exist. + +## What should I do instead? + +Make sure that the corresponding structural directive is imported into the component: + + + +import {Component} from '@angular/core'; +import {SelectDirective} from 'my-directives'; + +@Component({ + // Add `SelectDirective` to the `imports` array to make it available in the template. + imports: [SelectDirective], + template: `

{{data}}

`, +}) +class MyComponent {} + +
+ +## Configuration requirements + +[`strictTemplates`](tools/cli/template-typecheck#strict-mode) must be enabled for any extended diagnostic to emit. +`missingStructuralDirective` has no additional requirements beyond `strictTemplates`. + +## What if I can't avoid this? + +This diagnostic can be disabled by editing the project's `tsconfig.json` file: + + +{ + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "missingStructuralDirective": "suppress" + } + } + } +} + + +See [extended diagnostic configuration](extended-diagnostics#configuration) for more info. \ No newline at end of file diff --git a/adev/src/content/reference/extended-diagnostics/overview.md b/adev/src/content/reference/extended-diagnostics/overview.md index a4d8f8bcfc44..92829d8094be 100644 --- a/adev/src/content/reference/extended-diagnostics/overview.md +++ b/adev/src/content/reference/extended-diagnostics/overview.md @@ -22,6 +22,7 @@ Currently, Angular supports the following extended diagnostics: | `NG8111` | [`uninvokedFunctionInEventBinding`](extended-diagnostics/NG8111) | | `NG8113` | [`unusedStandaloneImports`](extended-diagnostics/NG8113) | | `NG8114` | [`unparenthesizedNullishCoalescing`](extended-diagnostics/NG8114) | +| `NG8116` | [`missingStructuralDirective`](extended-diagnostics/NG8116) | ## Configuration diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index b5cb5c508ca2..25baef6ecde6 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -79,6 +79,7 @@ export enum ErrorCode { MISSING_PIPE = 8004, MISSING_REFERENCE_TARGET = 8003, MISSING_REQUIRED_INPUTS = 8008, + MISSING_STRUCTURAL_DIRECTIVE = 8116, NGMODULE_BOOTSTRAP_IS_STANDALONE = 6009, NGMODULE_DECLARATION_IS_STANDALONE = 6008, NGMODULE_DECLARATION_NOT_UNIQUE = 6007, diff --git a/goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md b/goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md index bf905da4198d..e6bc95240dff 100644 --- a/goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md +++ b/goldens/public-api/compiler-cli/extended_template_diagnostic_name.api.md @@ -17,6 +17,8 @@ export enum ExtendedTemplateDiagnosticName { // (undocumented) MISSING_NGFOROF_LET = "missingNgForOfLet", // (undocumented) + MISSING_STRUCTURAL_DIRECTIVE = "missingStructuralDirective", + // (undocumented) NULLISH_COALESCING_NOT_NULLABLE = "nullishCoalescingNotNullable", // (undocumented) OPTIONAL_CHAIN_NOT_NULLABLE = "optionalChainNotNullable", diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index 07a8c3dd3d39..7b8aa08e20c0 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -559,6 +559,11 @@ export enum ErrorCode { */ UNINVOKED_TRACK_FUNCTION = 8115, + /** + * A structural directive is used in a template, but the directive is not imported. + */ + MISSING_STRUCTURAL_DIRECTIVE = 8116, + /** * The template type-checking engine would need to generate an inline type check block for a * component, but the current type-checking environment doesn't support it. diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts index 600a4a4e4816..c0e8c08e213d 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/extended_template_diagnostic_name.ts @@ -20,6 +20,7 @@ export enum ExtendedTemplateDiagnosticName { NULLISH_COALESCING_NOT_NULLABLE = 'nullishCoalescingNotNullable', OPTIONAL_CHAIN_NOT_NULLABLE = 'optionalChainNotNullable', MISSING_CONTROL_FLOW_DIRECTIVE = 'missingControlFlowDirective', + MISSING_STRUCTURAL_DIRECTIVE = 'missingStructuralDirective', TEXT_ATTRIBUTE_NOT_BINDING = 'textAttributeNotBinding', UNINVOKED_FUNCTION_IN_EVENT_BINDING = 'uninvokedFunctionInEventBinding', MISSING_NGFOROF_LET = 'missingNgForOfLet', diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel b/packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel index 24ab4b81b4e9..32228438a17d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel @@ -16,6 +16,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/invalid_banana_in_box", "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_control_flow_directive", "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_ngforof_let", + "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive", "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/nullish_coalescing_not_nullable", "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/optional_chain_not_nullable", "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/skip_hydration_not_static", diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive/BUILD.bazel b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive/BUILD.bazel new file mode 100644 index 000000000000..a8e517f1d7c2 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive/BUILD.bazel @@ -0,0 +1,15 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "missing_structural_directive", + srcs = ["index.ts"], + visibility = ["//packages/compiler-cli/src/ngtsc:__subpackages__"], + deps = [ + "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/core:api", + "//packages/compiler-cli/src/ngtsc/diagnostics", + "//packages/compiler-cli/src/ngtsc/typecheck/api", + "//packages/compiler-cli/src/ngtsc/typecheck/extended/api", + "@npm//typescript", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive/index.ts new file mode 100644 index 000000000000..26d5a4d31af0 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive/index.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {AST, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; +import ts from 'typescript'; + +import {ErrorCode, ExtendedTemplateDiagnosticName} from '../../../../diagnostics'; +import {NgTemplateDiagnostic} from '../../../api'; +import {TemplateCheckFactory, TemplateCheckWithVisitor, TemplateContext} from '../../api'; + +/** + * The list of known control flow directives present in the `CommonModule`. + * + * If these control flow directives are missing they will be reported by a separate diagnostic. + */ +export const KNOWN_CONTROL_FLOW_DIRECTIVES = new Set([ + 'ngIf', + 'ngFor', + 'ngForOf', + 'ngForTrackBy', + 'ngSwitchCase', + 'ngSwitchDefault', +]); + +/** + * Ensures that there are no structural directives (something like *select or *featureFlag) + * used in a template of a *standalone* component without importing the directive. Returns + * diagnostics in case such a directive is detected. + * + * Note: this check only handles the cases when structural directive syntax is used (e.g. `*featureFlag`). + * Regular binding syntax (e.g. `[featureFlag]`) is handled separately in type checker and treated as a + * hard error instead of a warning. + */ +class MissingStructuralDirectiveCheck extends TemplateCheckWithVisitor { + override code = ErrorCode.MISSING_STRUCTURAL_DIRECTIVE as const; + + override run( + ctx: TemplateContext, + component: ts.ClassDeclaration, + template: TmplAstNode[], + ) { + const componentMetadata = ctx.templateTypeChecker.getDirectiveMetadata(component); + // Avoid running this check for non-standalone components. + if (!componentMetadata || !componentMetadata.isStandalone) { + return []; + } + return super.run(ctx, component, template); + } + + override visitNode( + ctx: TemplateContext, + component: ts.ClassDeclaration, + node: TmplAstNode | AST, + ): NgTemplateDiagnostic[] { + if (!(node instanceof TmplAstTemplate)) return []; + + const customStructuralDirective = node.templateAttrs.find( + (attr) => !KNOWN_CONTROL_FLOW_DIRECTIVES.has(attr.name), + ); + if (!customStructuralDirective) return []; + + const symbol = ctx.templateTypeChecker.getSymbolOfNode(node, component); + if (symbol?.directives.length) { + return []; + } + + const sourceSpan = customStructuralDirective.keySpan || customStructuralDirective.sourceSpan; + const errorMessage = + `A structural directive \`${customStructuralDirective.name}\` was used in the template ` + + `without a corresponding import in the component. ` + + `Make sure that the directive is included in the \`@Component.imports\` array of this component.`; + return [ctx.makeTemplateDiagnostic(sourceSpan, errorMessage)]; + } +} + +export const factory: TemplateCheckFactory< + ErrorCode.MISSING_STRUCTURAL_DIRECTIVE, + ExtendedTemplateDiagnosticName.MISSING_STRUCTURAL_DIRECTIVE +> = { + code: ErrorCode.MISSING_STRUCTURAL_DIRECTIVE, + name: ExtendedTemplateDiagnosticName.MISSING_STRUCTURAL_DIRECTIVE, + create: () => new MissingStructuralDirectiveCheck(), +}; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts index d1223e3043f1..f03074edeaa0 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts @@ -13,6 +13,7 @@ import {factory as interpolatedSignalNotInvoked} from './checks/interpolated_sig import {factory as invalidBananaInBoxFactory} from './checks/invalid_banana_in_box'; import {factory as missingControlFlowDirectiveFactory} from './checks/missing_control_flow_directive'; import {factory as missingNgForOfLetFactory} from './checks/missing_ngforof_let'; +import {factory as missingStructuralDirectiveFactory} from './checks/missing_structural_directive'; import {factory as nullishCoalescingNotNullableFactory} from './checks/nullish_coalescing_not_nullable'; import {factory as optionalChainNotNullableFactory} from './checks/optional_chain_not_nullable'; import {factory as skipHydrationNotStaticFactory} from './checks/skip_hydration_not_static'; @@ -35,6 +36,7 @@ export const ALL_DIAGNOSTIC_FACTORIES: readonly TemplateCheckFactory< missingControlFlowDirectiveFactory, textAttributeNotBindingFactory, missingNgForOfLetFactory, + missingStructuralDirectiveFactory, suffixNotSupportedFactory, interpolatedSignalNotInvoked, uninvokedFunctionInEventBindingFactory, diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/missing_structural_directive/BUILD.bazel b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/missing_structural_directive/BUILD.bazel new file mode 100644 index 000000000000..ee8b71e077cd --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/missing_structural_directive/BUILD.bazel @@ -0,0 +1,30 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = ["missing_structural_directive_spec.ts"], + deps = [ + "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/diagnostics", + "//packages/compiler-cli/src/ngtsc/file_system", + "//packages/compiler-cli/src/ngtsc/file_system/testing", + "//packages/compiler-cli/src/ngtsc/testing", + "//packages/compiler-cli/src/ngtsc/typecheck/api", + "//packages/compiler-cli/src/ngtsc/typecheck/extended", + "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive", + "//packages/compiler-cli/src/ngtsc/typecheck/testing", + "@npm//typescript", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["//tools/testing:node_no_angular"], + data = [ + "//packages/core:npm_package", + ], + deps = [ + ":test_lib", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/missing_structural_directive/missing_structural_directive_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/missing_structural_directive/missing_structural_directive_spec.ts new file mode 100644 index 000000000000..fa9968846176 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/missing_structural_directive/missing_structural_directive_spec.ts @@ -0,0 +1,341 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import ts from 'typescript'; + +import {ErrorCode, ngErrorCode} from '../../../../../diagnostics'; +import {absoluteFrom, getSourceFileOrError} from '../../../../../file_system'; +import {runInEachFileSystem} from '../../../../../file_system/testing'; +import {createNgCompilerForFile, getClass, setup} from '../../../../testing'; +import {factory as missingStructuralDirectiveCheck} from '../../../checks/missing_structural_directive'; +import {ExtendedTemplateCheckerImpl} from '../../../src/extended_template_checker'; +import {OptimizeFor} from '../../../../api'; + +runInEachFileSystem(() => { + describe('missingStructuralDirectiveCheck', () => { + it('should produce a warning for missing unknown structural directives in standalone components', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': `
`, + }, + declarations: [ + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + isStandalone: true, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [missingStructuralDirectiveCheck], + {strictNullChecks: true} /* options */, + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.MISSING_STRUCTURAL_DIRECTIVE)); + }); + + it('should produce a warning if ngTemplateOutlet is used without importing the directive', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': ` + Ahoj {{ person }}!`, + }, + declarations: [ + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + isStandalone: true, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [missingStructuralDirectiveCheck], + {strictNullChecks: true} /* options */, + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.MISSING_STRUCTURAL_DIRECTIVE)); + }); + + it('should *not* produce a warning for custom structural directives that are imported when there is a non-exported class', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': `
`, + 'BarCmp': '', + }, + source: ` + class BarCmp{} + + export class TestCmp {} + export class Foo {} + + `, + declarations: [ + { + type: 'directive', + name: 'Foo', + selector: `[foo]`, + }, + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + isStandalone: true, + }, + { + name: 'BarCmp', + type: 'directive', + selector: `[bar-cmp]`, + isStandalone: true, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [missingStructuralDirectiveCheck], + {strictNullChecks: true} /* options */, + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + // No diagnostic messages are expected. + expect(diags.length).toBe(0); + }); + + it('should *not* produce a warning for custom structural directives that are imported', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': `
`, + }, + source: ` + export class TestCmp {} + export class Foo {} + `, + declarations: [ + { + type: 'directive', + name: 'Foo', + selector: `[foo]`, + }, + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + isStandalone: true, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [missingStructuralDirectiveCheck], + {strictNullChecks: true} /* options */, + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + // No diagnostic messages are expected. + expect(diags.length).toBe(0); + }); + + it('should *not* produce a warning for non-standalone components', () => { + const fileName = absoluteFrom('/main.ts'); + + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': `
`, + }, + declarations: [ + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + isStandalone: false, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [missingStructuralDirectiveCheck], + {strictNullChecks: true} /* options */, + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + // No diagnostic messages are expected. + expect(diags.length).toBe(0); + }); + + it('should *not* produce a warning for non-structural directives in standalone components', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': `
`, + }, + declarations: [ + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + isStandalone: true, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [missingStructuralDirectiveCheck], + {strictNullChecks: true} /* options */, + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + // No diagnostic messages are expected. + expect(diags.length).toBe(0); + }); + + it('should *not* produce a warning when known control flow directives are used', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': `
+
  • + {{ item.name }} +
  • + +
    +
    +
    +
    `, + }, + declarations: [ + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + isStandalone: true, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [missingStructuralDirectiveCheck], + {strictNullChecks: true} /* options */, + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + // No diagnostic messages are expected. + expect(diags.length).toBe(0); + }); + + it('should *not* produce a warning for templates with no structural directives', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + { + fileName, + templates: { + 'TestCmp': `
    `, + }, + declarations: [ + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + isStandalone: true, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, + program.getTypeChecker(), + [missingStructuralDirectiveCheck], + {strictNullChecks: true} /* options */, + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + // No diagnostic messages are expected. + expect(diags.length).toBe(0); + }); + + it('should trigger diagnostic for nested component in function', () => { + // This test is more complex as we're testing the diagnostic against a component + // that can't be referenced because it's nested in a function. + + const {compiler, sourceFile} = createNgCompilerForFile( + `import {Component, Directive} from '@angular/core'; + + @Directive({ selector: '[foo]' }) + export class FooDir {} + + export function foo() { + @Component({ + imports: [FooDir], + template: '
    ', + }) + class MyCmp {} + }`, + ); + + const diags = compiler.getDiagnosticsForFile(sourceFile, OptimizeFor.SingleFile); + // Note sure why we get this diagnostic + // It is still raised if we pass `lib: ['dom']` to the compiler options + expect(diags.length).toBe(1); + expect(diags[0].messageText).toContain( + `Cannot find name 'document'. Do you need to change your target library? `, + ); + + // What matters is that we don't get the missing structural directive diagnostic. + }); + }); +}); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/symbol_util.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/symbol_util.ts index 6d318b9fe9fd..8420b752d4d0 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/symbol_util.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/symbol_util.ts @@ -44,7 +44,9 @@ function isSignalSymbol(symbol: ts.Symbol): boolean { return ( (ts.isInterfaceDeclaration(decl) || ts.isTypeAliasDeclaration(decl)) && SIGNAL_FNS.has(decl.name.text) && - (fileName.includes('@angular/core') || fileName.includes('angular2/rc/packages/core')) + (fileName.includes('@angular/core') || + fileName.includes('angular2/rc/packages/core') || + fileName.includes('bin/packages/core')) // for local usage in some tests ); }) ); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts index 1dd33905c001..c876571e22f0 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts @@ -168,12 +168,21 @@ export function findTypeCheckBlock( id: TypeCheckId, isDiagnosticRequest: boolean, ): ts.Node | null { + // This prioritised-level statements using a breadth-first search + // This is usually sufficient to find the TCB we're looking for for (const stmt of file.statements) { if (ts.isFunctionDeclaration(stmt) && getTypeCheckId(stmt, file, isDiagnosticRequest) === id) { return stmt; } } - return null; + + // In case the TCB we're looking for is nested (which is not common) + // eg: when a directive is declared inside a function, as it can happen in test files + return findNodeInFile( + file, + (node) => + ts.isFunctionDeclaration(node) && getTypeCheckId(node, file, isDiagnosticRequest) === id, + ); } /** @@ -266,3 +275,24 @@ export function checkIfGenericTypeBoundsCanBeEmitted( const emitter = new TypeParameterEmitter(node.typeParameters, reflector); return emitter.canEmit((ref) => env.canReferenceType(ref)); } + +export function findNodeInFile( + file: ts.SourceFile, + predicate: (node: ts.Node) => node is T, +): T | null; +export function findNodeInFile( + file: ts.SourceFile, + predicate: (node: ts.Node) => boolean, +): ts.Node | null; +export function findNodeInFile( + file: ts.SourceFile, + predicate: (node: ts.Node) => boolean, +): ts.Node | null { + const visit = (node: ts.Node): ts.Node | null => { + if (predicate(node)) { + return node; + } + return ts.forEachChild(node, visit) ?? null; + }; + return ts.forEachChild(file, visit) ?? null; +} diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts index e2dd791444ac..2e146b27beb7 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts @@ -62,6 +62,7 @@ import { } from './comments'; import {TypeCheckData} from './context'; import {isAccessExpression} from './ts_util'; +import {MaybeSourceFileWithOriginalFile, NgOriginalFile} from '../../program_driver'; /** * Generates and caches `Symbol`s for various template structures for a given component. @@ -255,7 +256,7 @@ export class SymbolBuilder { private getDirectiveMeta( host: TmplAstTemplate | TmplAstElement, - directiveDeclaration: ts.Declaration, + directiveDeclaration: ts.ClassDeclaration, ): TypeCheckableDirectiveMeta | null { let directives = this.typeCheckData.boundTarget.getDirectivesOfNode(host); @@ -279,7 +280,34 @@ export class SymbolBuilder { return null; } - return directives.find((m) => m.ref.node === directiveDeclaration) ?? null; + const directive = directives.find((m) => m.ref.node === directiveDeclaration); + if (directive) { + return directive; + } + + const originalFile = (directiveDeclaration.getSourceFile() as MaybeSourceFileWithOriginalFile)[ + NgOriginalFile + ]; + + if (originalFile !== undefined) { + // This is a preliminary check ahead of a more expensive search + const hasPotentialCandidate = directives.find( + (m) => m.ref.node.name.text === directiveDeclaration.name?.text, + ); + + if (hasPotentialCandidate) { + // In case the TCB has been inlined, + // We will look for a matching class + // If we find one, we look for it in the directives array + const classWithSameName = findMatchingDirective(originalFile, directiveDeclaration); + if (classWithSameName !== null) { + return directives.find((m) => m.ref.node === classWithSameName) ?? null; + } + } + } + + // Really nothing was found + return null; } private getDirectiveModule(declaration: ts.ClassDeclaration): ClassDeclaration | null { @@ -862,3 +890,47 @@ function unwrapSignalInputWriteTAccessor(expr: ts.LeftHandSideExpression): null typeExpr: expr, }; } + +/** + * Looks for a class declaration in the original source file that matches a given directive + * from the type check source file. + * + * @param originalSourceFile The original source where the runtime code resides + * @param directiveDeclarationInTypeCheckSourceFile The directive from the type check source file + */ +function findMatchingDirective( + originalSourceFile: ts.SourceFile, + directiveDeclarationInTypeCheckSourceFile: ts.ClassDeclaration, +): ts.ClassDeclaration | null { + const className = directiveDeclarationInTypeCheckSourceFile.name?.text ?? ''; + // We build an index of the class declarations with the same name + // To then compare the indexes to confirm we found the right class declaration + const ogClasses = collectClassesWithName(originalSourceFile, className); + const typecheckClasses = collectClassesWithName( + directiveDeclarationInTypeCheckSourceFile.getSourceFile(), + className, + ); + + return ogClasses[typecheckClasses.indexOf(directiveDeclarationInTypeCheckSourceFile)] ?? null; +} + +/** + * Builds a list of class declarations of a given name + * Is used as a index based reference to compare class declarations + * between the typecheck source file and the original source file + */ +function collectClassesWithName( + sourceFile: ts.SourceFile, + className: string, +): ts.ClassDeclaration[] { + const classes: ts.ClassDeclaration[] = []; + function visit(node: ts.Node) { + if (ts.isClassDeclaration(node) && node.name?.text === className) { + classes.push(node); + } + ts.forEachChild(node, visit); + } + sourceFile.forEachChild(visit); + + return classes; +} diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/BUILD.bazel b/packages/compiler-cli/src/ngtsc/typecheck/test/BUILD.bazel index c06cf6251dc9..25ba8d6b88ae 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/BUILD.bazel @@ -11,6 +11,7 @@ ts_library( deps = [ "//packages:types", "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/core", "//packages/compiler-cli/src/ngtsc/diagnostics", "//packages/compiler-cli/src/ngtsc/file_system", "//packages/compiler-cli/src/ngtsc/file_system/testing", diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts index 27b812924dcc..32ea8a1d8a41 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__get_symbol_of_template_node_spec.ts @@ -55,7 +55,10 @@ import { ngForTypeCheckTarget, setup as baseTestSetup, TypeCheckingTarget, + createNgCompilerForFile, } from '../testing'; +import {TsCreateProgramDriver} from '../../program_driver'; +import {findNodeInFile} from '../src/tcb_util'; runInEachFileSystem(() => { describe('TemplateTypeChecker.getSymbolOfNode', () => { @@ -2107,12 +2110,110 @@ runInEachFileSystem(() => { const nodes = templateTypeChecker.getTemplate(cmp)!; - const symbol = templateTypeChecker.getSymbolOfNode(nodes[0] as TmplAstElement, cmp)!; + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0], cmp)!; assertElementSymbol(symbol); expect(symbol.tcbLocation.tcbPath).toBe(sf.fileName); expect(symbol.tcbLocation.isShimFile).toBe(false); }); + it('finds the directive when relying on inline TCB', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = baseTestSetup([ + { + fileName, + templates: { + 'TestCmp': `
    `, + 'BarCmp': '', + }, + source: ` + /* Declare a non-exported component to force using an inline TCB */ + class BarCmp{} + + export class TestCmp {} + export class Foo {} + + `, + declarations: [ + { + type: 'directive', + name: 'Foo', + selector: `[foo]`, + }, + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + }, + { + name: 'BarCmp', + type: 'directive', + selector: `[bar-cmp]`, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const testCmp = getClass(sf, 'TestCmp'); + const nodes = templateTypeChecker.getTemplate(testCmp)!; + + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0], testCmp)!; + assertTemplateSymbol(symbol); + expect(symbol.directives.length).toBe(1); + expect(symbol.directives[0].selector).toBe('[foo]'); + }); + + it('finds the right directive when relying on inline TCB and having multiple classes with the same name in the scope', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = baseTestSetup([ + { + fileName, + templates: { + 'TestCmp': `
    `, + 'BarCmp': '', + }, + source: ` + class BarCmp{} + + export class Foo {} + export class TestCmp { + foo() { + // The test should not match this class + class Foo { + ThisIsNotTheClassYoureLookingFor = true; + } + return Foo; + } + } + `, + declarations: [ + { + type: 'directive', + name: 'Foo', + selector: `[foo]`, + }, + { + name: 'TestCmp', + type: 'directive', + selector: `[test-cmp]`, + }, + { + name: 'BarCmp', + type: 'directive', + selector: `[bar-cmp]`, + }, + ], + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const testCmp = getClass(sf, 'TestCmp'); + const nodes = templateTypeChecker.getTemplate(testCmp)!; + + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0], testCmp)!; + assertTemplateSymbol(symbol); + expect(symbol.directives.length).toBe(1); + expect(symbol.directives[0].selector).toBe('[foo]'); + }); + it('has correct tcb location for components with TCBs in a type-checking shim file', () => { const fileName = absoluteFrom('/main.ts'); const {program, templateTypeChecker} = setup([ @@ -2131,6 +2232,124 @@ runInEachFileSystem(() => { expect(symbol.tcbLocation.tcbPath).not.toBe(sf.fileName); expect(symbol.tcbLocation.isShimFile).toBe(true); }); + + it('find the directive when the class is nested in a function', () => { + // This test is more complex as we're testing the diagnostic against a component + // that can't be referenced because it's nested in a function. + + const {compiler, sourceFile} = createNgCompilerForFile(` + import {Component, Directive} from '@angular/core'; + + @Directive({ selector: '[foo]' }) + export class FooDir {} + + export function foo() { + @Component({ + imports: [FooDir], + template: '
    ', + }) + class MyCmp {} + } + `); + + const templateTypeChecker = compiler.getTemplateTypeChecker(); + + const myCmpClass = findNodeInFile( + sourceFile, + (node): node is ts.ClassDeclaration => + ts.isClassDeclaration(node) && node.name?.text === 'MyCmp', + )!; + + const nodes = templateTypeChecker.getTemplate(myCmpClass)!; + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0], myCmpClass)!; + + assertTemplateSymbol(symbol); + expect(symbol.kind).toBe(SymbolKind.Template); + expect(symbol.directives.length).toBe(1); + expect(symbol.directives[0].selector).toBe('[foo]'); + }); + + it('find the directive when the class is nested in a function and has other pention candidates', () => { + // This test is more complex as we're testing the diagnostic against a component + // that can't be referenced because it's nested in a function. + + const {compiler, sourceFile} = createNgCompilerForFile(` + import {Component, Directive} from '@angular/core'; + + if(true) { + @Directive({ selector: '[foo]' }) + export class FooDir {} + + export function foo() { + @Component({ + imports: [FooDir], + template: '
    ', + }) + class MyCmp {} + } + } + + if(true) { + @Directive({ selector: '[foo]' }) + export class FooDir { + /* we should not match this directive */ + } + } + `); + + const templateTypeChecker = compiler.getTemplateTypeChecker(); + + const myCmpClass = findNodeInFile( + sourceFile, + (node): node is ts.ClassDeclaration => + ts.isClassDeclaration(node) && node.name?.text === 'MyCmp', + )!; + + const nodes = templateTypeChecker.getTemplate(myCmpClass)!; + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0], myCmpClass)!; + assertTemplateSymbol(symbol); + expect(symbol.kind).toBe(SymbolKind.Template); + expect(symbol.directives.length).toBe(1); + expect(symbol.directives[0].selector).toBe('[foo]'); + }); + + it('find the directive when it is nested inside a class of the same name', () => { + // This test is more complex as we're testing the diagnostic against a component + // that can't be referenced because it's nested in a function. + + const {compiler, sourceFile} = createNgCompilerForFile(` + import {Component, Directive} from '@angular/core'; + + /* We name this class with the same name as the directive */ + class FooDir { + foo() { + @Directive({ selector: '[foo]' }) + export class FooDir {} + + @Component({ + imports: [FooDir], + template: '
    ', + }) + class MyCmp {} + } + } + `); + + const templateTypeChecker = compiler.getTemplateTypeChecker(); + + const myCmpClass = findNodeInFile( + sourceFile, + (node): node is ts.ClassDeclaration => + ts.isClassDeclaration(node) && node.name?.text === 'MyCmp', + )!; + + const nodes = templateTypeChecker.getTemplate(myCmpClass)!; + const symbol = templateTypeChecker.getSymbolOfNode(nodes[0], myCmpClass)!; + assertTemplateSymbol(symbol); + expect(symbol.kind).toBe(SymbolKind.Template); + expect(symbol.directives.length).toBe(1); + expect(symbol.directives[0].selector).toBe('[foo]'); + }); }); }); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/BUILD.bazel b/packages/compiler-cli/src/ngtsc/typecheck/testing/BUILD.bazel index f1eb155145b7..71ec9e658b3e 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/BUILD.bazel @@ -9,6 +9,7 @@ ts_library( visibility = ["//packages/compiler-cli/src/ngtsc:__subpackages__"], deps = [ "//packages/compiler", + "//packages/compiler-cli/src/ngtsc/core", "//packages/compiler-cli/src/ngtsc/file_system", "//packages/compiler-cli/src/ngtsc/file_system/testing", "//packages/compiler-cli/src/ngtsc/imports", diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index 4186a042b540..a095360107e7 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -33,8 +33,10 @@ import {globSync} from 'tinyglobby'; import { absoluteFrom, AbsoluteFsPath, + getFileSystem, getSourceFileOrError, LogicalFileSystem, + NgtscCompilerHost, } from '../../file_system'; import {TestFile} from '../../file_system/testing'; import { @@ -46,7 +48,7 @@ import { ReferenceEmitter, RelativePathStrategy, } from '../../imports'; -import {NOOP_INCREMENTAL_BUILD} from '../../incremental'; +import {NOOP_INCREMENTAL_BUILD, NoopIncrementalBuildStrategy} from '../../incremental'; import { ClassPropertyMapping, CompoundMetadataReader, @@ -97,6 +99,7 @@ import {TypeCheckShimGenerator} from '../src/shim'; import {TcbGenericContextBehavior} from '../src/type_check_block'; import {TypeCheckFile} from '../src/type_check_file'; import {sfExtensionData} from '../../shims'; +import {freshCompilationTicket, NgCompiler, NgCompilerHost} from '../../core'; export function typescriptLibDts(): TestFile { return { @@ -1042,3 +1045,31 @@ export class NoopOobRecorder implements OutOfBandDiagnosticRecorder { node: TmplAstComponent | TmplAstDirective, ): void {} } + +export function createNgCompilerForFile(fileContent: string) { + const fs = getFileSystem(); + fs.ensureDir(absoluteFrom('/node_modules/@angular/core')); + const FILE = absoluteFrom('/main.ts'); + + fs.writeFile(FILE, fileContent); + + const options: ts.CompilerOptions = { + strictTemplates: true, + lib: ['dom', 'dom.iterable', 'esnext'], + }; + const baseHost = new NgtscCompilerHost(getFileSystem(), options); + const host = NgCompilerHost.wrap(baseHost, [FILE], options, /* oldProgram */ null); + const program = ts.createProgram({host, options, rootNames: host.inputFiles}); + + const ticket = freshCompilationTicket( + program, + options, + new NoopIncrementalBuildStrategy(), + new TsCreateProgramDriver(program, host, options, []), + /* perfRecorder */ null, + /*enableTemplateTypeChecker*/ true, + /*usePoisonedData*/ false, + ); + const compiler = NgCompiler.fromTicket(ticket, host); + return {compiler, sourceFile: program.getSourceFile(FILE)!}; +}