diff --git a/aio/content/extended-diagnostics/NG8109.md b/aio/content/extended-diagnostics/NG8109.md new file mode 100644 index 0000000000000..6fa3444af1485 --- /dev/null +++ b/aio/content/extended-diagnostics/NG8109.md @@ -0,0 +1,34 @@ +@name Signals must be invoked in template interpolations. + +@description + +Angular Signals are zero-argument functions (`() => T`). When executed, they return the current value of the signal. +This means they are meant to be invoked when used in template interpolations to render its value. + +## What should I do instead? + +When you use a signal within a template interpolation, you need to invoke it to render its value. + + + +import {Component, signal, Signal} from '@angular/core'; + +@Component({ + // … +}) +class MyComponent { + mySignal: Signal = signal(0) +} + + + + <div>{{ mySignal() }}/div> + + + + + + + + +@reviewed 2023-04-02 diff --git a/goldens/public-api/compiler-cli/error_code.md b/goldens/public-api/compiler-cli/error_code.md index d27864e5138d5..ca3dd2a1c3257 100644 --- a/goldens/public-api/compiler-cli/error_code.md +++ b/goldens/public-api/compiler-cli/error_code.md @@ -53,6 +53,7 @@ export enum ErrorCode { INJECTABLE_INHERITS_INVALID_CONSTRUCTOR = 2016, INLINE_TCB_REQUIRED = 8900, INLINE_TYPE_CTOR_REQUIRED = 8901, + INTERPOLATED_SIGNAL_NOT_INVOKED = 8109, INVALID_BANANA_IN_BOX = 8101, LOCAL_COMPILATION_IMPORTED_STYLES_STRING = 11002, LOCAL_COMPILATION_IMPORTED_TEMPLATE_STRING = 11001, diff --git a/goldens/public-api/compiler-cli/extended_template_diagnostic_name.md b/goldens/public-api/compiler-cli/extended_template_diagnostic_name.md index 6b6ca9e263d01..65db985eb5a26 100644 --- a/goldens/public-api/compiler-cli/extended_template_diagnostic_name.md +++ b/goldens/public-api/compiler-cli/extended_template_diagnostic_name.md @@ -6,6 +6,8 @@ // @public export enum ExtendedTemplateDiagnosticName { + // (undocumented) + INTERPOLATED_SIGNAL_NOT_INVOKED = "interpolatedSignalNotInvoked", // (undocumented) INVALID_BANANA_IN_BOX = "invalidBananaInBox", // (undocumented) 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 4df3d3d91e4d4..3f523a09eb087 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -377,6 +377,16 @@ export enum ErrorCode { */ SKIP_HYDRATION_NOT_STATIC = 8108, + /** + * Signal functions should be invoked when interpolated in templates. + * + * For example: + * ``` + * {{ mySignal() }} + * ``` + */ + INTERPOLATED_SIGNAL_NOT_INVOKED = 8109, + /** * 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 6b3a7834d12a9..b4446eeb2546d 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 @@ -24,4 +24,5 @@ export enum ExtendedTemplateDiagnosticName { MISSING_NGFOROF_LET = 'missingNgForOfLet', SUFFIX_NOT_SUPPORTED = 'suffixNotSupported', SKIP_HYDRATION_NOT_STATIC = 'skipHydrationNotStatic', + INTERPOLATED_SIGNAL_NOT_INVOKED = 'interpolatedSignalNotInvoked' } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel b/packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel index 8e6716fd26376..78848b17d509e 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/BUILD.bazel @@ -12,6 +12,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/diagnostics", "//packages/compiler-cli/src/ngtsc/typecheck/api", "//packages/compiler-cli/src/ngtsc/typecheck/extended/api", + "//packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked", "//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", diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/BUILD.bazel b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/BUILD.bazel new file mode 100644 index 0000000000000..ba677e82deac4 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/BUILD.bazel @@ -0,0 +1,17 @@ +load("//tools:defaults.bzl", "ts_library") + +ts_library( + name = "interpolated_signal_not_invoked", + srcs = ["index.ts"], + visibility = [ + "//packages/compiler-cli/src/ngtsc:__subpackages__", + "//packages/compiler-cli/test/ngtsc:__pkg__", + ], + deps = [ + "//packages/compiler", + "//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/interpolated_signal_not_invoked/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts new file mode 100644 index 0000000000000..3809cf6e03c9b --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/interpolated_signal_not_invoked/index.ts @@ -0,0 +1,67 @@ +/** + * @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.io/license + */ + +import {AST, Interpolation, PropertyRead, TmplAstNode} from '@angular/compiler'; +import ts from 'typescript'; + +import {ErrorCode, ExtendedTemplateDiagnosticName} from '../../../../diagnostics'; +import {NgTemplateDiagnostic, SymbolKind} from '../../../api'; +import {TemplateCheckFactory, TemplateCheckWithVisitor, TemplateContext} from '../../api'; + +/** + * Ensures Signals are invoked when used in template interpolations. + */ +class InterpolatedSignalCheck extends + TemplateCheckWithVisitor { + override code = ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED as const; + + override visitNode( + ctx: TemplateContext, + component: ts.ClassDeclaration, + node: TmplAstNode|AST): NgTemplateDiagnostic[] { + if (node instanceof Interpolation) { + return node.expressions.filter((item): item is PropertyRead => item instanceof PropertyRead) + .flatMap((item) => { + if (item instanceof PropertyRead) { + return buildDiagnosticForSignal(ctx, item, component); + } + return []; + }); + } + return []; + } +} + +function buildDiagnosticForSignal( + ctx: TemplateContext, node: PropertyRead, + component: ts.ClassDeclaration): + Array> { + const symbol = ctx.templateTypeChecker.getSymbolOfNode(node, component); + if (symbol?.kind === SymbolKind.Expression && + /* can this condition be improved ? */ + (symbol.tsType.symbol?.escapedName === 'WritableSignal' || + symbol.tsType.symbol?.escapedName === 'Signal') && + (symbol.tsType.symbol as any).parent.escapedName.includes('@angular/core')) { + const templateMapping = + ctx.templateTypeChecker.getTemplateMappingAtTcbLocation(symbol.tcbLocation)!; + + const errorString = `${node.name} is a function and should be invoked : ${node.name}()`; + const diagnostic = ctx.makeTemplateDiagnostic(templateMapping.span, errorString); + return [diagnostic]; + } + + return []; +} + +export const factory: TemplateCheckFactory< + ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED, + ExtendedTemplateDiagnosticName.INTERPOLATED_SIGNAL_NOT_INVOKED> = { + code: ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED, + name: ExtendedTemplateDiagnosticName.INTERPOLATED_SIGNAL_NOT_INVOKED, + create: () => new InterpolatedSignalCheck(), +}; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/BUILD.bazel b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/BUILD.bazel new file mode 100644 index 0000000000000..e74cdc2bd0340 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/BUILD.bazel @@ -0,0 +1,27 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = ["interpolated_signal_not_invoked_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/interpolated_signal_not_invoked", + "//packages/compiler-cli/src/ngtsc/typecheck/testing", + "@npm//typescript", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = ["//tools/testing:node_no_angular"], + deps = [ + ":test_lib", + ], +) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts new file mode 100644 index 0000000000000..fa2ea469ef284 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/interpolated_signal_not_invoked/interpolated_signal_not_invoked_spec.ts @@ -0,0 +1,355 @@ +/** + * @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.io/license + */ + +import ts from 'typescript'; + +import {ErrorCode, ExtendedTemplateDiagnosticName, ngErrorCode} from '../../../../../diagnostics'; +import {absoluteFrom, getSourceFileOrError} from '../../../../../file_system'; +import {runInEachFileSystem} from '../../../../../file_system/testing'; +import {getSourceCodeForDiagnostic} from '../../../../../testing'; +import {getClass, setup} from '../../../../testing'; +import {factory as interpolatedSignalFactory} from '../../../checks/interpolated_signal_not_invoked'; +import {ExtendedTemplateCheckerImpl} from '../../../src/extended_template_checker'; + +function coreDtsWithSignals() { + return { + fileName: absoluteFrom('/node_modules/@angular/core/index.d.ts'), + source: ` + export class Signal {}; + export declare function signal(initialValue: T): WritableSignal; + export declare function computed(computation: () => T): Signal; + + export interface WritableSignal extends Signal {} + `, + templates: {}, + }; +} + +runInEachFileSystem(() => { + describe('Interpolated Signal ', () => { + it('binds the error code to its extended template diagnostic name', () => { + expect(interpolatedSignalFactory.code).toBe(ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED); + expect(interpolatedSignalFactory.name) + .toBe(ExtendedTemplateDiagnosticName.INTERPOLATED_SIGNAL_NOT_INVOKED); + }); + + it('should not produce a warning when a signal getter is invoked', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
{{ mySignal() }}
`, + }, + source: ` + import {signal} from '@angular/core'; + + export class TestCmp { + mySignal = signal(0); + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} + /* options */ + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + expect(diags.length).toBe(0); + }); + }); + + it('should produce a warning when a signal isn\'t invoked', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
{{ mySignal1 }} {{ mySignal2 }}
`, + }, + source: ` + import {signal, Signal} from '@angular/core'; + + export class TestCmp { + mySignal1 = signal(0); + mySignal2:Signal; + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* options */ + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + expect(diags.length).toBe(2); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.INTERPOLATED_SIGNAL_NOT_INVOKED)); + expect(getSourceCodeForDiagnostic(diags[0])).toBe(`mySignal1`); + expect(getSourceCodeForDiagnostic(diags[1])).toBe(`mySignal2`); + }); + + it('should produce a warning when a computed signal isn\'t invoked', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
{{ mySignal2 }}
`, + }, + source: ` + import {signal, Signal, computed} from '@angular/core'; + + export class TestCmp { + mySignal1 = signal(0); + mySignal2 = computed(() => mySignal() * 2); + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* 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.INTERPOLATED_SIGNAL_NOT_INVOKED)); + expect(getSourceCodeForDiagnostic(diags[0])).toBe(`mySignal2`); + }); + + it('should not produce a warning when a computed signal is invoked', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
{{ mySignal2() }}
`, + }, + source: ` + import {signal, Signal, computed} from '@angular/core'; + + export class TestCmp { + mySignal1 = signal(0); + mySignal2 = computed(() => mySignal() * 2); + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* options */ + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + expect(diags.length).toBe(0); + }); + + it('should produce a warning when signal isn\'t invoked on interpolated binding', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
`, + }, + source: ` + import {signal, Signal, computed} from '@angular/core'; + + export class TestCmp { + mySignal = signal(0); + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* 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.INTERPOLATED_SIGNAL_NOT_INVOKED)); + expect(getSourceCodeForDiagnostic(diags[0])).toBe(`mySignal`); + }); + + it('should not produce a warning when signal is invoked on interpolated binding', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
`, + }, + source: ` + import {signal, Signal, computed} from '@angular/core'; + + export class TestCmp { + mySignal = signal(0); + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* options */ + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + expect(diags.length).toBe(0); + }); + + it('should produce a warning when signal is invoked in attribute binding interpolation ', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
`, + }, + source: ` + import {signal, Signal, computed} from '@angular/core'; + + export class TestCmp { + mySignal = signal(0); + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* 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.INTERPOLATED_SIGNAL_NOT_INVOKED)); + expect(getSourceCodeForDiagnostic(diags[0])).toBe(`mySignal`); + }); + + it('should not produce a warning when signal is invoked in attribute binding interpolation ', + () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
`, + }, + source: ` + import {signal, Signal, computed} from '@angular/core'; + + export class TestCmp { + mySignal = signal(0); + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} + /* options */ + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + expect(diags.length).toBe(0); + }); + + it('should produce a warning when nested signal isn\'t invoked on interpolated binding', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
`, + }, + source: ` + import {signal, Signal, computed} from '@angular/core'; + + export class TestCmp { + myObject = {myObject2: {myNestedSignal: signal(0)}}; + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* 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.INTERPOLATED_SIGNAL_NOT_INVOKED)); + expect(getSourceCodeForDiagnostic(diags[0])).toBe(`myNestedSignal`); + }); + + it('should not produce a warning with other Signal type', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
{{ mySignal }} {{ mySignal2 }}
`, + }, + source: ` + import {signal} from '@not-angular/core'; + + export class TestCmp { + mySignal = signal(0); + mySignal2 = signal(2); + }`, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* options */ + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + expect(diags.length).toBe(0); + }); + + it('should not produce a warning with other Signal type', () => { + const fileName = absoluteFrom('/main.ts'); + const {program, templateTypeChecker} = setup([ + coreDtsWithSignals(), + { + fileName, + templates: { + 'TestCmp': `
{{ foo(mySignal) }}
`, + }, + source: ` + import {signal} from '@angular/core'; + + export class TestCmp { + mySignal = signal(0); + + foo(signal: Signal) { + return 'foo' + } + } + `, + }, + ]); + const sf = getSourceFileOrError(program, fileName); + const component = getClass(sf, 'TestCmp'); + const extendedTemplateChecker = new ExtendedTemplateCheckerImpl( + templateTypeChecker, program.getTypeChecker(), [interpolatedSignalFactory], {} /* options */ + ); + const diags = extendedTemplateChecker.getDiagnosticsForComponent(component); + expect(diags.length).toBe(0); + }); +});