Skip to content

Commit ceadd28

Browse files
crisbetothePunderWoman
authored andcommitted
fix(compiler): allow $any in two-way bindings (#59362)
Some time ago we narrowed down the expressions we support in two-way bindings, because in most cases any apart from property reads doesn't make sense. This ended up preventing users from using `$any` in the binding since it's considered a function call. These changes update the validation logic to allow `$any`. Fixes #51165. PR Close #59362
1 parent dc8280d commit ceadd28

File tree

8 files changed

+163
-3
lines changed

8 files changed

+163
-3
lines changed

packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,24 @@ describe('type check blocks', () => {
927927
);
928928
});
929929

930+
it('should handle $any cast in a two-way binding', () => {
931+
const TEMPLATE = `<div twoWay [(input)]="$any(value)"></div>`;
932+
const DIRECTIVES: TestDeclaration[] = [
933+
{
934+
type: 'directive',
935+
name: 'TwoWay',
936+
selector: '[twoWay]',
937+
inputs: {input: 'input'},
938+
outputs: {inputChange: 'inputChange'},
939+
},
940+
];
941+
const block = tcb(TEMPLATE, DIRECTIVES);
942+
expect(block).toContain('var _t1 = null! as i0.TwoWay;');
943+
expect(block).toContain('_t1.input = i1.ɵunwrapWritableSignal(((((this).value) as any)));');
944+
expect(block).toContain('var _t2 = i1.ɵunwrapWritableSignal((((this).value) as any));');
945+
expect(block).toContain('_t2 = $event;');
946+
});
947+
930948
it('should detect writes to template variables', () => {
931949
const TEMPLATE = `<ng-template let-v><div (event)="v = 3"></div></ng-template>`;
932950
const block = tcb(TEMPLATE);

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/GOLDEN_PARTIAL.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,3 +1016,50 @@ export declare class TestCmp {
10161016
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmp, "ng-component", never, {}, {}, never, never, true, never>;
10171017
}
10181018

1019+
/****************************************************************************************************
1020+
* PARTIAL FILE: two_way_to_any.js
1021+
****************************************************************************************************/
1022+
import { Component, Directive, model } from '@angular/core';
1023+
import * as i0 from "@angular/core";
1024+
export class NgModelDirective {
1025+
constructor() {
1026+
this.ngModel = model('');
1027+
}
1028+
}
1029+
NgModelDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: NgModelDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1030+
NgModelDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "0.0.0-PLACEHOLDER", type: NgModelDirective, isStandalone: true, selector: "[ngModel]", inputs: { ngModel: { classPropertyName: "ngModel", publicName: "ngModel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { ngModel: "ngModelChange" }, ngImport: i0 });
1031+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: NgModelDirective, decorators: [{
1032+
type: Directive,
1033+
args: [{ selector: '[ngModel]' }]
1034+
}] });
1035+
export class TestCmp {
1036+
constructor() {
1037+
this.value = 123;
1038+
}
1039+
}
1040+
TestCmp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, deps: [], target: i0.ɵɵFactoryTarget.Component });
1041+
TestCmp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmp, isStandalone: true, selector: "test-cmp", ngImport: i0, template: '<input [(ngModel)]="$any(value)">', isInline: true, dependencies: [{ kind: "directive", type: NgModelDirective, selector: "[ngModel]", inputs: ["ngModel"], outputs: ["ngModelChange"] }] });
1042+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, decorators: [{
1043+
type: Component,
1044+
args: [{
1045+
selector: 'test-cmp',
1046+
template: '<input [(ngModel)]="$any(value)">',
1047+
imports: [NgModelDirective],
1048+
}]
1049+
}] });
1050+
1051+
/****************************************************************************************************
1052+
* PARTIAL FILE: two_way_to_any.d.ts
1053+
****************************************************************************************************/
1054+
import * as i0 from "@angular/core";
1055+
export declare class NgModelDirective {
1056+
ngModel: import("@angular/core").ModelSignal<string>;
1057+
static ɵfac: i0.ɵɵFactoryDeclaration<NgModelDirective, never>;
1058+
static ɵdir: i0.ɵɵDirectiveDeclaration<NgModelDirective, "[ngModel]", never, { "ngModel": { "alias": "ngModel"; "required": false; "isSignal": true; }; }, { "ngModel": "ngModelChange"; }, never, never, true, never>;
1059+
}
1060+
export declare class TestCmp {
1061+
value: number;
1062+
static ɵfac: i0.ɵɵFactoryDeclaration<TestCmp, never>;
1063+
static ɵcmp: i0.ɵɵComponentDeclaration<TestCmp, "test-cmp", never, {}, {}, never, never, true, never>;
1064+
}
1065+

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_listener/TEST_CASES.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,23 @@
370370
"failureMessage": "Incorrect template"
371371
}
372372
]
373+
},
374+
{
375+
"description": "should generate a two-way binding to a $any expression",
376+
"inputFiles": [
377+
"two_way_to_any.ts"
378+
],
379+
"expectations": [
380+
{
381+
"files": [
382+
{
383+
"expected": "two_way_to_any_template.js",
384+
"generated": "two_way_to_any.js"
385+
}
386+
],
387+
"failureMessage": "Incorrect template"
388+
}
389+
]
373390
}
374391
]
375392
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {Component, Directive, model} from '@angular/core';
2+
3+
@Directive({selector: '[ngModel]'})
4+
export class NgModelDirective {
5+
ngModel = model('');
6+
}
7+
8+
@Component({
9+
selector: 'test-cmp',
10+
template: '<input [(ngModel)]="$any(value)">',
11+
imports: [NgModelDirective],
12+
})
13+
export class TestCmp {
14+
value = 123;
15+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
function TestCmp_Template(rf, ctx) {
2+
if (rf & 1) {
3+
$r3$.ɵɵelementStart(0, "input", 0);
4+
$r3$.ɵɵtwoWayListener("ngModelChange", function TestCmp_Template_input_ngModelChange_0_listener($event) {
5+
$r3$.ɵɵtwoWayBindingSet(ctx.value, $event) || (ctx.value = $event);
6+
return $event;
7+
});
8+
$r3$.ɵɵelementEnd();
9+
}
10+
if (rf & 2) {
11+
$r3$.ɵɵtwoWayProperty("ngModel", ctx.value);
12+
}
13+
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,34 @@ runInEachFileSystem(() => {
657657
);
658658
});
659659

660+
it('should be able to cast to any in a two-way binding', () => {
661+
env.tsconfig({strictTemplates: true, _checkTwoWayBoundEvents: true});
662+
env.write(
663+
'test.ts',
664+
`
665+
import {Component, Directive, Input, Output, EventEmitter} from '@angular/core';
666+
667+
@Directive({selector: '[dir]', standalone: true})
668+
export class Dir {
669+
@Input() val!: number;
670+
@Output() valChange = new EventEmitter<number>();
671+
}
672+
673+
@Component({
674+
template: '<input dir [(val)]="$any(invalidType)">',
675+
standalone: true,
676+
imports: [Dir],
677+
})
678+
export class FooCmp {
679+
invalidType = 'hello';
680+
}
681+
`,
682+
);
683+
684+
const diags = env.driveDiagnostics();
685+
expect(diags.length).toBe(0);
686+
});
687+
660688
it('should type check a two-way binding to input/output pair where the input has a wider type than the output', () => {
661689
env.tsconfig({strictTemplates: true, _checkTwoWayBoundEvents: true});
662690
env.write(

packages/compiler/src/template_parser/binding_parser.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import {
1111
AbsoluteSourceSpan,
1212
AST,
1313
ASTWithSource,
14-
Binary,
1514
BindingPipe,
1615
BindingType,
1716
BoundElementProperty,
18-
Conditional,
17+
Call,
1918
EmptyExpr,
19+
ImplicitReceiver,
2020
KeyedRead,
2121
NonNullAssert,
2222
ParsedEvent,
@@ -25,10 +25,10 @@ import {
2525
ParsedPropertyType,
2626
ParsedVariable,
2727
ParserError,
28-
PrefixNot,
2928
PropertyRead,
3029
RecursiveAstVisitor,
3130
TemplateBinding,
31+
ThisReceiver,
3232
VariableBinding,
3333
} from '../expression_parser/ast';
3434
import {Parser} from '../expression_parser/parser';
@@ -811,6 +811,17 @@ export class BindingParser {
811811
return this._isAllowedAssignmentEvent(ast.expression);
812812
}
813813

814+
if (
815+
ast instanceof Call &&
816+
ast.args.length === 1 &&
817+
ast.receiver instanceof PropertyRead &&
818+
ast.receiver.name === '$any' &&
819+
ast.receiver.receiver instanceof ImplicitReceiver &&
820+
!(ast.receiver.receiver instanceof ThisReceiver)
821+
) {
822+
return this._isAllowedAssignmentEvent(ast.args[0]);
823+
}
824+
814825
if (ast instanceof PropertyRead || ast instanceof KeyedRead) {
815826
return true;
816827
}

packages/compiler/test/render3/r3_template_transform_spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,14 @@ describe('R3 template transform', () => {
491491
]);
492492
});
493493

494+
it('should parse $any in a two-way binding', () => {
495+
expectFromHtml('<div [(prop)]="$any(v)"></div>').toEqual([
496+
['Element', 'div'],
497+
['BoundAttribute', BindingType.TwoWay, 'prop', '$any(v)'],
498+
['BoundEvent', ParsedEventType.TwoWay, 'propChange', null, '$any(v)'],
499+
]);
500+
});
501+
494502
it('should parse bound events and properties via bindon-', () => {
495503
expectFromHtml('<div bindon-prop="v"></div>').toEqual([
496504
['Element', 'div'],
@@ -553,6 +561,9 @@ describe('R3 template transform', () => {
553561
'!a',
554562
'!!a',
555563
'a ? b : c',
564+
'$any(a || b)',
565+
'this.$any(a)',
566+
'$any(a, b)',
556567
];
557568

558569
for (const expression of unsupportedExpressions) {

0 commit comments

Comments
 (0)