Skip to content

Commit a61fe41

Browse files
alxhubmhevery
authored andcommitted
fix(ivy): emulate a View Engine type-checking bug with safe navigation (#35462)
In its default compatibility mode, the Ivy template type-checker attempts to emulate the View Engine default mode as accurately as is possible. This commit addresses a gap in this compatibility that stems from a View Engine type-checking bug. Consider two template expressions: ```html {{ obj?.field }} {{ fn()?.field }} ``` and suppose that the type of `obj` and `fn()` are the same - both return either `null` or an object with a `field` property. Under View Engine, these type-check differently. The `obj` case will catch if the object type (when not null) does not have a `field` property, while the `fn()` case will not. This is due to how View Engine represents safe navigations: ```typescript // for the 'obj' case (obj == null ? null as any : obj.field) // for the 'fn()' case let tmp: any; ((tmp = fn()) == null ? null as any : tmp.field) ``` Because View Engine uses the same code generation backend as it does to produce the runtime code for this expression, it uses a ternary for safe navigation, with a temporary variable to avoid invoking 'fn()' twice. The type of this temporary variable is 'any', however, which causes the `tmp.field` check to be meaningless. Previously, the Ivy template type-checker in compatibility mode assumed that `fn()?.field` would always check for the presence of 'field' on the non-null result of `fn()`. This commit emulates the View Engine bug in Ivy's compatibility mode, so an 'any' type will be inferred under the same conditions. As part of this fix, a new format for safe navigation operations in template type-checking code is introduced. This is based on the realization that ternary based narrowing is unnecessary. For the `fn()` case in strict mode, Ivy now generates: ```typescript (null as any ? fn()!.field : undefined) ``` This effectively uses the ternary operator as a type "or" operation. The resulting type will be a union of the type of `fn()!.field` with `undefined`. For the `fn()` case in compatibility mode, Ivy now emulates the bug with: ```typescript (fn() as any).field ``` The cast expression includes the call to `fn()` and allows it to be checked while still returning a type of `any` from the expression. For the `obj` case in compatibility mode, Ivy now generates: ```typescript (obj!.field as any) ``` This cast expression still returns `any` for its type, but will check for the existence of `field` on the type of `obj!`. PR Close #35462
1 parent 049f118 commit a61fe41

File tree

3 files changed

+105
-29
lines changed

3 files changed

+105
-29
lines changed

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

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -219,39 +219,100 @@ class AstTranslator implements AstVisitor {
219219
visitQuote(ast: Quote): never { throw new Error('Method not implemented.'); }
220220

221221
visitSafeMethodCall(ast: SafeMethodCall): ts.Expression {
222-
// See the comment in SafePropertyRead above for an explanation of the need for the non-null
223-
// assertion here.
222+
// See the comments in SafePropertyRead above for an explanation of the cases here.
223+
let node: ts.Expression;
224224
const receiver = wrapForDiagnostics(this.translate(ast.receiver));
225-
const guard = ts.getMutableClone(receiver);
226-
ignoreDiagnostics(guard);
227-
const method = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name);
228225
const args = ast.args.map(expr => this.translate(expr));
229-
const expr = ts.createCall(method, undefined, args);
230-
const whenNull = this.config.strictSafeNavigationTypes ? UNDEFINED : NULL_AS_ANY;
231-
const node = safeTernary(guard, expr, whenNull);
226+
if (this.config.strictSafeNavigationTypes) {
227+
// "a?.method(...)" becomes (null as any ? a!.method(...) : undefined)
228+
const method = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name);
229+
const call = ts.createCall(method, undefined, args);
230+
node = ts.createParen(ts.createConditional(NULL_AS_ANY, call, UNDEFINED));
231+
} else if (VeSafeLhsInferenceBugDetector.veWillInferAnyFor(ast)) {
232+
// "a?.method(...)" becomes (a as any).method(...)
233+
const method = ts.createPropertyAccess(tsCastToAny(receiver), ast.name);
234+
node = ts.createCall(method, undefined, args);
235+
} else {
236+
// "a?.method(...)" becomes (a!.method(...) as any)
237+
const method = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name);
238+
node = tsCastToAny(ts.createCall(method, undefined, args));
239+
}
232240
addParseSpanInfo(node, ast.sourceSpan);
233241
return node;
234242
}
235243

236244
visitSafePropertyRead(ast: SafePropertyRead): ts.Expression {
237-
// A safe property expression a?.b takes the form `(a != null ? a!.b : whenNull)`, where
238-
// whenNull is either of type 'any' or or 'undefined' depending on strictness. The non-null
239-
// assertion is necessary because in practice 'a' may be a method call expression, which won't
240-
// have a narrowed type when repeated in the ternary true branch.
245+
let node: ts.Expression;
241246
const receiver = wrapForDiagnostics(this.translate(ast.receiver));
242-
const guard = ts.getMutableClone(receiver);
243-
ignoreDiagnostics(guard);
244-
const expr = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name);
245-
const whenNull = this.config.strictSafeNavigationTypes ? UNDEFINED : NULL_AS_ANY;
246-
const node = safeTernary(guard, expr, whenNull);
247+
// The form of safe property reads depends on whether strictness is in use.
248+
if (this.config.strictSafeNavigationTypes) {
249+
// Basically, the return here is either the type of the complete expression with a null-safe
250+
// property read, or `undefined`. So a ternary is used to create an "or" type:
251+
// "a?.b" becomes (null as any ? a!.b : undefined)
252+
// The type of this expression is (typeof a!.b) | undefined, which is exactly as desired.
253+
const expr = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name);
254+
node = ts.createParen(ts.createConditional(NULL_AS_ANY, expr, UNDEFINED));
255+
} else if (VeSafeLhsInferenceBugDetector.veWillInferAnyFor(ast)) {
256+
// Emulate a View Engine bug where 'any' is inferred for the left-hand side of the safe
257+
// navigation operation. With this bug, the type of the left-hand side is regarded as any.
258+
// Therefore, the left-hand side only needs repeating in the output (to validate it), and then
259+
// 'any' is used for the rest of the expression. This is done using a comma operator:
260+
// "a?.b" becomes (a as any).b, which will of course have type 'any'.
261+
node = ts.createPropertyAccess(tsCastToAny(receiver), ast.name);
262+
} else {
263+
// The View Engine bug isn't active, so check the entire type of the expression, but the final
264+
// result is still inferred as `any`.
265+
// "a?.b" becomes (a!.b as any)
266+
const expr = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name);
267+
node = tsCastToAny(expr);
268+
}
247269
addParseSpanInfo(node, ast.sourceSpan);
248270
return node;
249271
}
250272
}
251273

252-
function safeTernary(
253-
lhs: ts.Expression, whenNotNull: ts.Expression, whenNull: ts.Expression): ts.Expression {
254-
const notNullComp = ts.createBinary(lhs, ts.SyntaxKind.ExclamationEqualsToken, ts.createNull());
255-
const ternary = ts.createConditional(notNullComp, whenNotNull, whenNull);
256-
return ts.createParen(ternary);
274+
/**
275+
* Checks whether View Engine will infer a type of 'any' for the left-hand side of a safe navigation
276+
* operation.
277+
*
278+
* In View Engine's template type-checker, certain receivers of safe navigation operations will
279+
* cause a temporary variable to be allocated as part of the checking expression, to save the value
280+
* of the receiver and use it more than once in the expression. This temporary variable has type
281+
* 'any'. In practice, this means certain receivers cause View Engine to not check the full
282+
* expression, and other receivers will receive more complete checking.
283+
*
284+
* For compatibility, this logic is adapted from View Engine's expression_converter.ts so that the
285+
* Ivy checker can emulate this bug when needed.
286+
*/
287+
class VeSafeLhsInferenceBugDetector implements AstVisitor {
288+
private static SINGLETON = new VeSafeLhsInferenceBugDetector();
289+
290+
static veWillInferAnyFor(ast: SafeMethodCall|SafePropertyRead) {
291+
return ast.receiver.visit(VeSafeLhsInferenceBugDetector.SINGLETON);
292+
}
293+
294+
visitBinary(ast: Binary): boolean { return ast.left.visit(this) || ast.right.visit(this); }
295+
visitChain(ast: Chain): boolean { return false; }
296+
visitConditional(ast: Conditional): boolean {
297+
return ast.condition.visit(this) || ast.trueExp.visit(this) || ast.falseExp.visit(this);
298+
}
299+
visitFunctionCall(ast: FunctionCall): boolean { return true; }
300+
visitImplicitReceiver(ast: ImplicitReceiver): boolean { return false; }
301+
visitInterpolation(ast: Interpolation): boolean {
302+
return ast.expressions.some(exp => exp.visit(this));
303+
}
304+
visitKeyedRead(ast: KeyedRead): boolean { return false; }
305+
visitKeyedWrite(ast: KeyedWrite): boolean { return false; }
306+
visitLiteralArray(ast: LiteralArray): boolean { return true; }
307+
visitLiteralMap(ast: LiteralMap): boolean { return true; }
308+
visitLiteralPrimitive(ast: LiteralPrimitive): boolean { return false; }
309+
visitMethodCall(ast: MethodCall): boolean { return true; }
310+
visitPipe(ast: BindingPipe): boolean { return true; }
311+
visitPrefixNot(ast: PrefixNot): boolean { return ast.expression.visit(this); }
312+
visitNonNullAssert(ast: PrefixNot): boolean { return ast.expression.visit(this); }
313+
visitPropertyRead(ast: PropertyRead): boolean { return false; }
314+
visitPropertyWrite(ast: PropertyWrite): boolean { return false; }
315+
visitQuote(ast: Quote): boolean { return false; }
316+
visitSafeMethodCall(ast: SafeMethodCall): boolean { return true; }
317+
visitSafePropertyRead(ast: SafePropertyRead): boolean { return false; }
257318
}

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,14 @@ describe('type check blocks diagnostics', () => {
9797
it('should annotate safe property access', () => {
9898
const TEMPLATE = `{{ a?.b }}`;
9999
expect(tcbWithSpans(TEMPLATE))
100-
.toContain(
101-
'(((ctx).a /*3,4*/) /*ignore*/ != null ? ((ctx).a /*3,4*/)!.b : undefined) /*3,8*/;');
100+
.toContain('((null as any) ? ((ctx).a /*3,4*/)!.b : undefined) /*3,8*/');
102101
});
103102

104103
it('should annotate safe method calls', () => {
105104
const TEMPLATE = `{{ a?.method(b) }}`;
106105
expect(tcbWithSpans(TEMPLATE))
107106
.toContain(
108-
'(((ctx).a /*3,4*/) /*ignore*/ != null ? ((ctx).a /*3,4*/)!.method((ctx).b /*13,14*/) : undefined) /*3,16*/;');
107+
'((null as any) ? ((ctx).a /*3,4*/)!.method((ctx).b /*13,14*/) : undefined) /*3,16*/');
109108
});
110109

111110
it('should annotate $any casts', () => {

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -561,15 +561,31 @@ describe('type check blocks', () => {
561561

562562
it('should use undefined for safe navigation operations when enabled', () => {
563563
const block = tcb(TEMPLATE, DIRECTIVES);
564-
expect(block).toContain('(((ctx).a) != null ? ((ctx).a)!.method() : undefined)');
565-
expect(block).toContain('(((ctx).a) != null ? ((ctx).a)!.b : undefined)');
564+
expect(block).toContain('((null as any) ? ((ctx).a)!.method() : undefined)');
565+
expect(block).toContain('((null as any) ? ((ctx).a)!.b : undefined)');
566566
});
567567
it('should use an \'any\' type for safe navigation operations when disabled', () => {
568568
const DISABLED_CONFIG:
569569
TypeCheckingConfig = {...BASE_CONFIG, strictSafeNavigationTypes: false};
570570
const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG);
571-
expect(block).toContain('(((ctx).a) != null ? ((ctx).a)!.method() : null as any)');
572-
expect(block).toContain('(((ctx).a) != null ? ((ctx).a)!.b : null as any)');
571+
expect(block).toContain('(((ctx).a)!.method() as any)');
572+
expect(block).toContain('(((ctx).a)!.b as any)');
573+
});
574+
});
575+
576+
describe('config.strictSafeNavigationTypes (View Engine bug emulation)', () => {
577+
const TEMPLATE = `{{a.method()?.b}} {{a()?.method()}}`;
578+
it('should check the presence of a property/method on the receiver when enabled', () => {
579+
const block = tcb(TEMPLATE, DIRECTIVES);
580+
expect(block).toContain('((null as any) ? (((ctx).a).method())!.b : undefined)');
581+
expect(block).toContain('((null as any) ? ((ctx).a())!.method() : undefined)');
582+
});
583+
it('should not check the presence of a property/method on the receiver when disabled', () => {
584+
const DISABLED_CONFIG:
585+
TypeCheckingConfig = {...BASE_CONFIG, strictSafeNavigationTypes: false};
586+
const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG);
587+
expect(block).toContain('((((ctx).a).method()) as any).b');
588+
expect(block).toContain('(((ctx).a()) as any).method()');
573589
});
574590
});
575591

0 commit comments

Comments
 (0)