Skip to content

Commit 8908fcd

Browse files
devversionalxhub
authored andcommitted
refactor(compiler-cli): initial type-checking for signal inputs (angular#53521)
This commit introduces the initial type-checking for signal inputs. To enable type-checking od signal inputs, there are a couple of tricks needed. It's not trivial as it would look like at first glance. Initial attempts could have been to generate additional statements in type-checking blocks for signal inputs to simply call a method like `InputSignal#applyNewValue`. This would seem natural, as it would match what will happen at runtime, but this would break the language-service auto completion in a highly subtle way. Consider the case where multiple directives match the same input. Consider the directives have some overlap in accepted input values, but they also have distinct diverging values, like: ```ts class DirA { value = input<'apple'|'shared'>(); } class DirB { value = input<'orange'|'shared'>(); } ``` In such cases, auto completion for the binding expression should suggest the following values: `apple`, `shared`, `orange` and `undefined`. The language service achieves this by getting completions in the type-check block where the user expression would live. This BREAKS if we'd have multiple places where the expression from the user is used. Two different places, or more, surface additional problems with diagnostic collection. Previously diagnostics would surface the union type of allowed values, but with multiple places, we'd have to work with potentially 1+ diagnostics. This is non-ideal. Another important consideration is test coverage. It might sound problematic to consider the existing test infrastructure as relevant, but in practice, we have thousands of diagnostic type check block tests that would greatly benefit if the general emit structure would still match conceptually. This is another bonus argument on why changing the way inputs are applied is probably an option we should consider as a last resort. Ultimately, there is a good solution where we unwrap directive signal inputs, based on metadata, and access a brand type field on the `InputSignal`. This ensures auto-completion continues to work as is, and also the structure of type check blocks doesn't change conceptually. In future commits we also need to handle type-inference for generic signal inputs. Note: Another alternative considered, in terms of using metadata or not. We could have type helpers to unwrap signal inputs using type helpers like: `T extends InputSignal<any, WriteT> ? WriteT : T`. This would allow us to drop the input signal metadata dependency, but in reality, this has a few issues: - users might have `@Input`'s passing around `InputSignal`'s. This is unlikely, but shows that the solution would not be fully correct. - we need the metadata regardless, as we plan on accessing it at runtime as well, to distinguish between signal inputs and normal inputs when applying new values. This was not clear when this option was considered initially. PR Close angular#53521
1 parent 3e0e0b4 commit 8908fcd

File tree

7 files changed

+55
-6
lines changed

7 files changed

+55
-6
lines changed

packages/compiler-cli/src/ngtsc/metadata/src/util.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,16 @@ export function extractDirectiveTypeCheckMeta(
120120
const hasNgTemplateContextGuard = staticMembers.some(
121121
member => member.kind === ClassMemberKind.Method && member.name === 'ngTemplateContextGuard');
122122

123-
const coercedInputFields =
124-
new Set(staticMembers.map(extractCoercedInput)
125-
.filter((inputName): inputName is ClassPropertyName => inputName !== null));
123+
const coercedInputFields = new Set<ClassPropertyName>(
124+
staticMembers.map(extractCoercedInput).filter((inputName): inputName is ClassPropertyName => {
125+
// If the input refers to a signal input, we will not respect coercion members.
126+
// A transform function should be used instead.
127+
// TODO(signals): consider throwing a diagnostic?
128+
if (inputName === null || inputs.getByClassPropertyName(inputName)?.isSignal) {
129+
return false;
130+
}
131+
return true;
132+
}));
126133

127134
const restrictedInputFields = new Set<ClassPropertyName>();
128135
const stringLiteralInputFields = new Set<ClassPropertyName>();

packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,15 @@ export class Environment implements ReferenceEmitEnvironment {
172172
this.reflector, this.refEmitter, this.importManager);
173173
}
174174

175+
/**
176+
* Generate a `ts.Expression` that refers to the external symbol. This
177+
* may result in new imports being generated.
178+
*/
179+
referenceExternalSymbol(moduleName: string, name: string): ts.Expression {
180+
const external = new ExternalExpr({moduleName, name});
181+
return translateExpression(external, this.importManager);
182+
}
183+
175184
/**
176185
* Generates a `ts.TypeNode` representing a type that is being referenced from a different place
177186
* in the program. Any type references inside the transplanted type will be rewritten so that

packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {AST, BindingPipe, BindingType, BoundTarget, Call, createCssSelectorFromNode, CssSelector, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafeCall, SafePropertyRead, SchemaMetadata, SelectorMatcher, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstDeferredBlock, TmplAstDeferredBlockTriggers, TmplAstElement, TmplAstForLoopBlock, TmplAstHoverDeferredTrigger, TmplAstIcu, TmplAstIfBlock, TmplAstIfBlockBranch, TmplAstInteractionDeferredTrigger, TmplAstNode, TmplAstReference, TmplAstSwitchBlock, TmplAstSwitchBlockCase, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable, TmplAstViewportDeferredTrigger, TransplantedType} from '@angular/compiler';
9+
import {AST, BindingPipe, BindingType, BoundTarget, Call, createCssSelectorFromNode, CssSelector, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, R3Identifiers, SafeCall, SafePropertyRead, SchemaMetadata, SelectorMatcher, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstDeferredBlock, TmplAstDeferredBlockTriggers, TmplAstElement, TmplAstForLoopBlock, TmplAstHoverDeferredTrigger, TmplAstIcu, TmplAstIfBlock, TmplAstIfBlockBranch, TmplAstInteractionDeferredTrigger, TmplAstNode, TmplAstReference, TmplAstSwitchBlock, TmplAstSwitchBlockCase, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable, TmplAstViewportDeferredTrigger, TransplantedType} from '@angular/compiler';
1010
import ts from 'typescript';
1111

1212
import {Reference} from '../../imports';
@@ -707,13 +707,19 @@ class TcbDirectiveInputsOp extends TcbOp {
707707

708708
let assignment: ts.Expression = wrapForDiagnostics(expr);
709709

710-
for (const {fieldName, required, transformType} of attr.inputs) {
710+
for (const {fieldName, required, transformType, isSignal} of attr.inputs) {
711711
let target: ts.LeftHandSideExpression;
712712

713713
if (required) {
714714
seenRequiredInputs.add(fieldName);
715715
}
716716

717+
// Note: There is no special logic for transforms/coercion with signal inputs.
718+
// For signal inputs, a `transformType` will never be set as we do not capture
719+
// the transform in the compiler metadata. Signal inputs incorporate their
720+
// transform write type into their member type, and we extract it below when
721+
// unwrapping the `InputSignal<ReadT, WriteT>`.
722+
717723
if (this.dir.coercedInputFields.has(fieldName)) {
718724
let type: ts.TypeNode;
719725

@@ -781,6 +787,24 @@ class TcbDirectiveInputsOp extends TcbOp {
781787
dirId, ts.factory.createIdentifier(fieldName));
782788
}
783789

790+
// For signal inputs, we unwrap the target `InputSignal`. Note that
791+
// we intentionally do the following things:
792+
// 1. keep the direct access to `dir.[field]` so that modifiers are honored.
793+
// 2. follow the existing pattern where multiple targets assign a single expression.
794+
// This is a significant requirement for language service auto-completion.
795+
if (isSignal) {
796+
const inputSignalBrandWriteSymbol = this.tcb.env.referenceExternalSymbol(
797+
R3Identifiers.InputSignalBrandWriteType.moduleName,
798+
R3Identifiers.InputSignalBrandWriteType.name);
799+
if (!ts.isIdentifier(inputSignalBrandWriteSymbol) &&
800+
!ts.isPropertyAccessExpression(inputSignalBrandWriteSymbol)) {
801+
throw new Error(`Expected identifier or property access for reference to ${
802+
R3Identifiers.InputSignalBrandWriteType.name}`);
803+
}
804+
805+
target = ts.factory.createElementAccessExpression(target, inputSignalBrandWriteSymbol);
806+
}
807+
784808
if (attr.attribute.keySpan !== undefined) {
785809
addParseSpanInfo(target, attr.attribute.keySpan);
786810
}

packages/compiler/src/render3/r3_identifiers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,7 @@ export class Identifiers {
389389
o.ExternalReference = {name: 'ɵɵtrustConstantResourceUrl', moduleName: CORE};
390390
static validateIframeAttribute:
391391
o.ExternalReference = {name: 'ɵɵvalidateIframeAttribute', moduleName: CORE};
392+
393+
// type-checking
394+
static InputSignalBrandWriteType = {name: 'ɵINPUT_SIGNAL_BRAND_WRITE_TYPE', moduleName: CORE};
392395
}

packages/core/src/authoring.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@
1010
// https://docs.google.com/document/d/1RXb1wYwsbJotO1KBgSDsAtKpduGmIHod9ADxuXcAvV4/edit?tab=t.0.
1111

1212
export {InputFunction as ɵInputFunction, inputFunction as ɵinputFunctionForApiGuard, inputRequiredFunction as ɵinputFunctionRequiredForApiGuard} from './authoring/input';
13-
export {InputOptions as ɵInputOptions, InputOptionsWithoutTransform as ɵInputOptionsWithoutTransform, InputOptionsWithTransform as ɵInputOptionsWithTransform, InputSignal} from './authoring/input_signal';
13+
export {InputOptions as ɵInputOptions, InputOptionsWithoutTransform as ɵInputOptionsWithoutTransform, InputOptionsWithTransform as ɵInputOptionsWithTransform, InputSignal, ɵINPUT_SIGNAL_BRAND_WRITE_TYPE} from './authoring/input_signal';

packages/core/test/bundling/defer/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,6 +1472,9 @@
14721472
{
14731473
"name": "init_input"
14741474
},
1475+
{
1476+
"name": "init_input_signal"
1477+
},
14751478
{
14761479
"name": "init_input_transforms_feature"
14771480
},

packages/core/test/render3/jit_environment_spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ const INTERFACE_EXCEPTIONS = new Set<string>([
3030
const AOT_ONLY = new Set<string>([
3131
'ɵsetClassMetadata',
3232
'ɵsetClassMetadataAsync',
33+
34+
// used in type-checking.
35+
'ɵINPUT_SIGNAL_BRAND_WRITE_TYPE',
3336
]);
3437

3538
/**

0 commit comments

Comments
 (0)