diff --git a/adev/src/content/reference/extended-diagnostics/NG8114.md b/adev/src/content/reference/extended-diagnostics/NG8114.md new file mode 100644 index 0000000000000..49d832b7ad9a3 --- /dev/null +++ b/adev/src/content/reference/extended-diagnostics/NG8114.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. diff --git a/adev/src/content/reference/extended-diagnostics/overview.md b/adev/src/content/reference/extended-diagnostics/overview.md index 94714804e9d96..3e86e0cc872cc 100644 --- a/adev/src/content/reference/extended-diagnostics/overview.md +++ b/adev/src/content/reference/extended-diagnostics/overview.md @@ -20,7 +20,8 @@ Currently, Angular supports the following extended diagnostics: | `NG8108` | [`skipHydrationNotStatic`](extended-diagnostics/NG8108) | | `NG8109` | [`interpolatedSignalNotInvoked`](extended-diagnostics/NG8109) | | `NG8111` | [`uninvokedFunctionInEventBinding`](extended-diagnostics/NG8111) | -| `NG8113` | [`unusedStandaloneImports`](extended-diagnostics/NG8113) | +| `NG8113` | [`unusedStandaloneImports`](extended-diagnostics/NG8113) | +| `NG8114` | [`missingStructuralDirective`](extended-diagnostics/NG8114) | ## Configuration diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index b8412619d50d5..be713a7ebaba4 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -75,6 +75,7 @@ export enum ErrorCode { MISSING_PIPE = 8004, MISSING_REFERENCE_TARGET = 8003, MISSING_REQUIRED_INPUTS = 8008, + MISSING_STRUCTURAL_DIRECTIVE = 8114, 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 ee99d7d584756..78546c0e15d6b 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 a5013d246f957..e0797eb523525 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -518,6 +518,11 @@ export enum ErrorCode { */ UNUSED_STANDALONE_IMPORTS = 8113, + /** + * A structural directive is used in a template, but the directive is not imported. + */ + MISSING_STRUCTURAL_DIRECTIVE = 8114, + /** * 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 c79de4ce6af19..bec12d76866dd 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 f4527da5c181c..18cd509c7a824 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 0000000000000..a8e517f1d7c23 --- /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 0000000000000..2fcdeeeffe7e8 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/missing_structural_directive/index.ts @@ -0,0 +1,91 @@ +/** + * @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 {NgCompilerOptions} from '../../../../core/api'; +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', + 'ngSwitch', + '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 === null || symbol.directives.length > 0) { + return []; + } + + const sourceSpan = customStructuralDirective.keySpan || customStructuralDirective.sourceSpan; + const errorMessage = + `An unknown 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.`; + const diagnostic = ctx.makeTemplateDiagnostic(sourceSpan, errorMessage); + return [diagnostic]; + } +} + +export const factory: TemplateCheckFactory< + ErrorCode.MISSING_STRUCTURAL_DIRECTIVE, + ExtendedTemplateDiagnosticName.MISSING_STRUCTURAL_DIRECTIVE +> = { + code: ErrorCode.MISSING_STRUCTURAL_DIRECTIVE, + name: ExtendedTemplateDiagnosticName.MISSING_STRUCTURAL_DIRECTIVE, + create: (options: NgCompilerOptions) => { + return 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 5cb45e946b004..f6553f327f659 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 suffixNotSupportedFactory} from './checks/suffix_not_supported'; @@ -32,6 +33,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 0000000000000..0b630f9740927 --- /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/core:api", + "//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/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 0000000000000..437341009816a --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/missing_structural_directive/missing_structural_directive_spec.ts @@ -0,0 +1,218 @@ +/** + * @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 {getClass, setup} from '../../../../testing'; +import {factory as missingStructuralDirectiveCheck} from '../../../checks/missing_structural_directive'; +import {ExtendedTemplateCheckerImpl} from '../../../src/extended_template_checker'; + +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 *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': `
`, + }, + 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 warn 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); + }); + }); +});