Skip to content

Commit 752ddbc

Browse files
zelliottdylhunn
authored andcommitted
feat(compiler-cli): Support template binding to protected component members (#45823)
This commit adds support for creating template bindings to protected members within the component class. PR Close #45823
1 parent f0b5e83 commit 752ddbc

File tree

6 files changed

+168
-144
lines changed

6 files changed

+168
-144
lines changed

packages/compiler-cli/src/ngtsc/typecheck/README.md

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ TCBs are not ever emitted, nor are they referenced from any other code (they're
2424
Given a component `SomeCmp`, its TCB takes the form of a function:
2525

2626
```typescript
27-
function tcb(ctx: SomeCmp): void {
27+
function tcb(this: SomeCmp): void {
2828
// TCB code
2929
}
3030
```
@@ -38,15 +38,15 @@ The component context is the theoretical component instance associated with the
3838

3939
For example, if `SomeCmp`'s template has an interpolation expression `{{foo.bar}}`, this suggests that `SomeCmp` has a property `foo`, and that `foo` itself is an object with a property `bar` (or in a type sense, that the type of `SomeCmp.foo` has a property `bar`).
4040

41-
Such a binding is expressed in the TCB function, using the `ctx` parameter as the component instance:
41+
Such a binding is expressed in the TCB function, using the `this` parameter as the component instance:
4242

4343
```typescript
44-
function tcb(ctx: SomeCmp): void {
45-
'' + ctx.foo.bar;
44+
function tcb(this: SomeCmp): void {
45+
'' + this.foo.bar;
4646
}
4747
```
4848

49-
If `SomeCmp` does not have a `foo` property, then TypeScript will produce a type error/diagnostic for the expression `ctx.foo`. If `ctx.foo` does exist, but is not of a type that has a `bar` property, then TypeScript will catch that too. By mapping the template expression `{{foo.bar}}` to TypeScript code, the compiler has captured its _intent_ in the TCB in a way that TypeScript can validate.
49+
If `SomeCmp` does not have a `foo` property, then TypeScript will produce a type error/diagnostic for the expression `this.foo`. If `this.foo` does exist, but is not of a type that has a `bar` property, then TypeScript will catch that too. By mapping the template expression `{{foo.bar}}` to TypeScript code, the compiler has captured its _intent_ in the TCB in a way that TypeScript can validate.
5050

5151
#### Types of template declarations
5252

@@ -62,7 +62,7 @@ declares a single `<input>` element with a local ref `#name`, meaning that withi
6262
Within the TCB, the `<input>` element is treated as a declaration, and the compiler leverages the powerful type inference of `document.createElement`:
6363

6464
```typescript
65-
function tcb(ctx: SomeCmp): void {
65+
function tcb(this: SomeCmp): void {
6666
var _t1 = document.createElement('input');
6767
'' + _t1.value;
6868
}
@@ -83,13 +83,13 @@ Just like with HTML elements, directives present on elements within the template
8383
The TCB for this template looks like:
8484

8585
```typescript
86-
function tcb(ctx: SomeCmp): void {
86+
function tcb(this: SomeCmp): void {
8787
var _t1: OtherCmp = null!;
88-
_t1.foo = ctx.bar;
88+
_t1.foo = this.bar;
8989
}
9090
```
9191

92-
Since `<other-cmp>` is a component, the TCB declares `_t1` to be of that component's type. This allows for the binding `[foo]="bar"` to be expressed in TypeScript as `_t1.foo = ctx.bar` - an assignment to `OtherCmp`'s `@Input` for `foo` of the `bar` property from the template's context. TypeScript can then type check this operation and produce diagnostics if the type of `ctx.bar` is not assignable to the `_t1.foo` property which backs the `@Input`.
92+
Since `<other-cmp>` is a component, the TCB declares `_t1` to be of that component's type. This allows for the binding `[foo]="bar"` to be expressed in TypeScript as `_t1.foo = this.bar` - an assignment to `OtherCmp`'s `@Input` for `foo` of the `bar` property from the template's context. TypeScript can then type check this operation and produce diagnostics if the type of `this.bar` is not assignable to the `_t1.foo` property which backs the `@Input`.
9393

9494
#### Generic directives & type constructors
9595

@@ -147,9 +147,9 @@ This type constructor can then be used to infer the instance type of a usage of
147147
Would use the above type constructor in its TCB:
148148

149149
```typescript
150-
function tcb(ctx: SomeCmp): void {
151-
var _t1 = ctor1({ngForOf: ctx.users});
152-
// Assuming ctx.users is User[], then _t1 is inferred as NgFor<User>.
150+
function tcb(this: SomeCmp): void {
151+
var _t1 = ctor1({ngForOf: this.users});
152+
// Assuming this.users is User[], then _t1 is inferred as NgFor<User>.
153153
}
154154
```
155155

@@ -180,9 +180,9 @@ In the TCB, the template context of this nested template is itself a declaration
180180
```typescript
181181
declare function ctor1(inputs: {ngForOf?: Iterable<T>}): NgFor<T>;
182182

183-
function tcb(ctx: SomeCmp): void {
183+
function tcb(this: SomeCmp): void {
184184
// _t1 is the NgFor directive instance, inferred as NgFor<User>.
185-
var _t1 = ctor1({ngForOf: ctx.users});
185+
var _t1 = ctor1({ngForOf: this.users});
186186

187187
// _t2 is the context type for the embedded views created by the NgFor structural directive.
188188
var _t2: any;
@@ -221,9 +221,9 @@ The `typecheck` system is aware of the presence of this method on any structural
221221
```typescript
222222
declare function ctor1(inputs: {ngForOf?: Iterable<T>}): NgFor<T>;
223223

224-
function tcb(ctx: SomeCmp): void {
224+
function tcb(this: SomeCmp): void {
225225
// _t1 is the NgFor directive instance, inferred as NgFor<User>.
226-
var _t1 = ctor1({ngForOf: ctx.users});
226+
var _t1 = ctor1({ngForOf: this.users});
227227

228228
// _t2 is the context type for the embedded views created by the NgFor structural directive.
229229
var _t2: any;
@@ -258,15 +258,15 @@ Because the `NgFor` directive _declared_ to the template type checking engine wh
258258
Obviously, if `user` is potentially `null`, then this `NgIf` is intended to only show the `<div>` when `user` actually has a value. However, from a type-checking perspective, the expression `user.name` is not legal if `user` is potentially `null`. So if this template was rendered into a TCB as:
259259

260260
```typescript
261-
function tcb(ctx: SomeCmp): void {
261+
function tcb(this: SomeCmp): void {
262262
// Type of the NgIf directive instance.
263263
var _t1: NgIf;
264264

265265
// Binding *ngIf="user != null".
266-
_t1.ngIf = ctx.user !== null;
266+
_t1.ngIf = this.user !== null;
267267

268268
// Nested template interpolation `{{user.name}}`
269-
'' + ctx.user.name;
269+
'' + this.user.name;
270270
}
271271
```
272272

@@ -286,23 +286,23 @@ export class NgIf {
286286
The presence and type of this static property tells the template type-checking engine to reflect the bound expression for its `ngIf` input as a guard for any embedded views created by the structural directive. This produces a TCB:
287287

288288
```typescript
289-
function tcb(ctx: SomeCmp): void {
289+
function tcb(this: SomeCmp): void {
290290
// Type of the NgIf directive instance.
291291
var _t1: NgIf;
292292

293293
// Binding *ngIf="user != null".
294-
_t1.ngIf = ctx.user !== null;
294+
_t1.ngIf = this.user !== null;
295295

296296
// Guard generated due to the `ngTemplateGuard_ngIf` declaration by the NgIf directive.
297297
if (user !== null) {
298298
// Nested template interpolation `{{user.name}}`.
299-
// `ctx.user` here is appropriately narrowed to be non-nullable.
300-
'' + ctx.user.name;
299+
// `this.user` here is appropriately narrowed to be non-nullable.
300+
'' + this.user.name;
301301
}
302302
}
303303
```
304304

305-
The guard expression causes TypeScript to narrow the type of `ctx.user` within the `if` block and identify that `ctx.user` cannot be `null` within the embedded view context, just as `NgIf` itself does during rendering.
305+
The guard expression causes TypeScript to narrow the type of `this.user` within the `if` block and identify that `this.user` cannot be `null` within the embedded view context, just as `NgIf` itself does during rendering.
306306

307307
### Generation process
308308

@@ -393,7 +393,7 @@ Here, type-checking the `[in]` binding requires knowing the type of `ref`, which
393393
```typescript
394394
declare function ctor1<T>(inputs: {in?: T}): GenericCmp<T>;
395395

396-
function tcb(ctx: SomeCmp): void {
396+
function tcb(this: SomeCmp): void {
397397
// Not legal: cannot refer to t1 (ref) before its declaration.
398398
var t1 = ctor1({in: t1.value});
399399

@@ -408,7 +408,7 @@ To get around this, `TcbOp`s may optionally provide a fallback value via a `circ
408408
```typescript
409409
declare function ctor1<T>(inputs: {in?: T}): GenericCmp<T>;
410410

411-
function tcb(ctx: SomeCmp): void {
411+
function tcb(this: SomeCmp): void {
412412
// Generated to break the cycle for `ref` - infers a placeholder
413413
// type for the component without using any of its input bindings.
414414
var t1 = ctor1(null!);
@@ -445,16 +445,16 @@ Consider a template expression of the form:
445445
The generated TCB code for this expression would look like:
446446

447447
```typescript
448-
'' + ctx.foo.bar;
448+
'' + this.foo.bar;
449449
```
450450

451451
What actually gets generated for this expression looks more like:
452452

453453
```typescript
454-
'' + (ctx.foo /* 3,5 */).bar /* 3,9 */;
454+
'' + (this.foo /* 3,5 */).bar /* 3,9 */;
455455
```
456456

457-
The trailing comment for each node in the TCB indicates the template offsets for the corresponding template nodes. If for example TypeScript returns a diagnostic for the `ctx.foo` part of the expression (such as if `foo` is not a valid property on the component context), the attached comment can be used to map this diagnostic back to the original template's `foo` node.
457+
The trailing comment for each node in the TCB indicates the template offsets for the corresponding template nodes. If for example TypeScript returns a diagnostic for the `this.foo` part of the expression (such as if `foo` is not a valid property on the component context), the attached comment can be used to map this diagnostic back to the original template's `foo` node.
458458

459459
#### `TemplateId`
460460

@@ -548,7 +548,7 @@ Additions of such inline type checking code have significant ramifications on th
548548
A similar problem exists for generic components and the declaration of TCBs. A TCB function must also copy the generic bounds of its context component:
549549

550550
```typescript
551-
function tcb<T extends string>(ctx: SomeCmp<T>): void {
551+
function tcb<T extends string>(this: SomeCmp<T>): void {
552552
/* tcb code */
553553
}
554554
```

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

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export function generateTypeCheckBlock(
117117
}
118118
}
119119

120-
const paramList = [tcbCtxParam(ref.node, ctxRawType.typeName, typeArguments)];
120+
const paramList = [tcbThisParam(ref.node, ctxRawType.typeName, typeArguments)];
121121

122122
const scopeStatements = scope.render();
123123
const innerBody = ts.factory.createBlock([
@@ -1099,7 +1099,7 @@ class TcbUnclaimedOutputsOp extends TcbOp {
10991099
/**
11001100
* A `TcbOp` which generates a completion point for the component context.
11011101
*
1102-
* This completion point looks like `ctx. ;` in the TCB output, and does not produce diagnostics.
1102+
* This completion point looks like `this. ;` in the TCB output, and does not produce diagnostics.
11031103
* TypeScript autocompletion APIs can be used at this completion point (after the '.') to produce
11041104
* autocompletion results of properties and methods from the template's component context.
11051105
*/
@@ -1111,7 +1111,7 @@ class TcbComponentContextCompletionOp extends TcbOp {
11111111
override readonly optional = false;
11121112

11131113
override execute(): null {
1114-
const ctx = ts.factory.createIdentifier('ctx');
1114+
const ctx = ts.factory.createThis();
11151115
const ctxDot = ts.factory.createPropertyAccessExpression(ctx, '');
11161116
markIgnoreDiagnostics(ctxDot);
11171117
addExpressionIdentifier(ctxDot, ExpressionIdentifier.COMPONENT_COMPLETION);
@@ -1636,17 +1636,18 @@ interface TcbBoundInput {
16361636
}
16371637

16381638
/**
1639-
* Create the `ctx` parameter to the top-level TCB function, with the given generic type arguments.
1639+
* Create the `this` parameter to the top-level TCB function, with the given generic type
1640+
* arguments.
16401641
*/
1641-
function tcbCtxParam(
1642+
function tcbThisParam(
16421643
node: ClassDeclaration<ts.ClassDeclaration>, name: ts.EntityName,
16431644
typeArguments: ts.TypeNode[]|undefined): ts.ParameterDeclaration {
16441645
const type = ts.factory.createTypeReferenceNode(name, typeArguments);
16451646
return ts.factory.createParameterDeclaration(
16461647
/* decorators */ undefined,
16471648
/* modifiers */ undefined,
16481649
/* dotDotDotToken */ undefined,
1649-
/* name */ 'ctx',
1650+
/* name */ 'this',
16501651
/* questionToken */ undefined,
16511652
/* type */ type,
16521653
/* initializer */ undefined);
@@ -1708,7 +1709,7 @@ class TcbExpressionTranslator {
17081709
// Therefore if `resolve` is called on an `ImplicitReceiver`, it's because no outer
17091710
// PropertyRead/Call resolved to a variable or reference, and therefore this is a
17101711
// property read or method call on the component context itself.
1711-
return ts.factory.createIdentifier('ctx');
1712+
return ts.factory.createThis();
17121713
} else if (ast instanceof BindingPipe) {
17131714
const expr = this.translate(ast.exp);
17141715
const pipeRef = this.tcb.getPipeByName(ast.name);
@@ -1985,13 +1986,13 @@ function tcbCreateEventHandler(
19851986
/* type */ eventParamType);
19861987
addExpressionIdentifier(eventParam, ExpressionIdentifier.EVENT_PARAMETER);
19871988

1988-
return ts.factory.createFunctionExpression(
1989-
/* modifier */ undefined,
1990-
/* asteriskToken */ undefined,
1991-
/* name */ undefined,
1989+
// Return an arrow function instead of a function expression to preserve the `this` context.
1990+
return ts.factory.createArrowFunction(
1991+
/* modifiers */ undefined,
19921992
/* typeParameters */ undefined,
19931993
/* parameters */[eventParam],
19941994
/* type */ ts.factory.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
1995+
/* equalsGreaterThanToken */ undefined,
19951996
/* body */ ts.factory.createBlock([body]));
19961997
}
19971998

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,29 @@ class TestComponent {
585585
expect(messages).toEqual(
586586
[`TestComponent.html(3, 30): Type 'HTMLElement' is not assignable to type 'string'.`]);
587587
});
588+
589+
it('allows access to protected members', () => {
590+
const messages = diagnose(`<button (click)="doFoo()">{{ message }}</button>`, `
591+
class TestComponent {
592+
protected message = 'Hello world';
593+
protected doFoo(): void {}
594+
}`);
595+
596+
expect(messages).toEqual([]);
597+
});
598+
599+
it('disallows access to private members', () => {
600+
const messages = diagnose(`<button (click)="doFoo()">{{ message }}</button>`, `
601+
class TestComponent {
602+
private message = 'Hello world';
603+
private doFoo(): void {}
604+
}`);
605+
606+
expect(messages).toEqual([
607+
`TestComponent.html(1, 18): Property 'doFoo' is private and only accessible within class 'TestComponent'.`,
608+
`TestComponent.html(1, 30): Property 'message' is private and only accessible within class 'TestComponent'.`
609+
]);
610+
});
588611
});
589612

590613
describe('method call spans', () => {

0 commit comments

Comments
 (0)