mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
feat(compiler): Add support for the instanceof binary operator
Because why not ? fixes #59975
This commit is contained in:
parent
9273f1c3c2
commit
72534e2a34
21 changed files with 147 additions and 22 deletions
|
|
@ -68,6 +68,7 @@ Angular supports the following operators from standard JavaScript.
|
|||
| typeof | `typeof 42` |
|
||||
| void | `void 1` |
|
||||
| in | `'model' in car` |
|
||||
| instanceof | `car instanceof Automobile` |
|
||||
| Assignment | `a = b` |
|
||||
| Addition Assignment | `a += b` |
|
||||
| Subtraction Assignment | `a -= b` |
|
||||
|
|
@ -100,7 +101,6 @@ NOTE: Optional chaining behaves differently from the standard JavaScript version
|
|||
| Object destructuring | `const { name } = person` |
|
||||
| Array destructuring | `const [firstItem] = items` |
|
||||
| Comma operator | `x = (x++, x)` |
|
||||
| instanceof | `car instanceof Automobile` |
|
||||
| new | `new Car()` |
|
||||
|
||||
## Lexical context for expressions
|
||||
|
|
|
|||
|
|
@ -333,7 +333,6 @@ export type BinaryOperator =
|
|||
| '||'
|
||||
| '+'
|
||||
| '??'
|
||||
| 'in'
|
||||
| '='
|
||||
| '+='
|
||||
| '-='
|
||||
|
|
@ -343,7 +342,9 @@ export type BinaryOperator =
|
|||
| '**='
|
||||
| '&&='
|
||||
| '||='
|
||||
| '??=';
|
||||
| '??='
|
||||
| 'in'
|
||||
| 'instanceof';
|
||||
|
||||
/**
|
||||
* The original location of the start or end of a node created by the `AstFactory`.
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const BINARY_OPERATORS = /* @__PURE__ */ new Map<o.BinaryOperator, BinaryOperato
|
|||
[o.BinaryOperator.NullishCoalesce, '??'],
|
||||
[o.BinaryOperator.Exponentiation, '**'],
|
||||
[o.BinaryOperator.In, 'in'],
|
||||
[o.BinaryOperator.InstanceOf, 'instanceof'],
|
||||
[o.BinaryOperator.Assign, '='],
|
||||
[o.BinaryOperator.AdditionAssignment, '+='],
|
||||
[o.BinaryOperator.SubtractionAssignment, '-='],
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ export class TypeScriptAstFactory implements AstFactory<ts.Statement, ts.Express
|
|||
'||': ts.SyntaxKind.BarBarToken,
|
||||
'+': ts.SyntaxKind.PlusToken,
|
||||
'??': ts.SyntaxKind.QuestionQuestionToken,
|
||||
'in': ts.SyntaxKind.InKeyword,
|
||||
'=': ts.SyntaxKind.EqualsToken,
|
||||
'+=': ts.SyntaxKind.PlusEqualsToken,
|
||||
'-=': ts.SyntaxKind.MinusEqualsToken,
|
||||
|
|
@ -79,6 +78,8 @@ export class TypeScriptAstFactory implements AstFactory<ts.Statement, ts.Express
|
|||
'&&=': ts.SyntaxKind.AmpersandAmpersandEqualsToken,
|
||||
'||=': ts.SyntaxKind.BarBarEqualsToken,
|
||||
'??=': ts.SyntaxKind.QuestionQuestionEqualsToken,
|
||||
'in': ts.SyntaxKind.InKeyword,
|
||||
'instanceof': ts.SyntaxKind.InstanceOfKeyword,
|
||||
}))();
|
||||
|
||||
private readonly VAR_TYPES: Record<VariableDeclarationType, ts.NodeFlags> =
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ class AstTranslator implements AstVisitor {
|
|||
['&', ts.SyntaxKind.AmpersandToken],
|
||||
['|', ts.SyntaxKind.BarToken],
|
||||
['??', ts.SyntaxKind.QuestionQuestionToken],
|
||||
['in', ts.SyntaxKind.InKeyword],
|
||||
['=', ts.SyntaxKind.EqualsToken],
|
||||
['+=', ts.SyntaxKind.PlusEqualsToken],
|
||||
['-=', ts.SyntaxKind.MinusEqualsToken],
|
||||
|
|
@ -117,6 +116,8 @@ class AstTranslator implements AstVisitor {
|
|||
['&&=', ts.SyntaxKind.AmpersandAmpersandEqualsToken],
|
||||
['||=', ts.SyntaxKind.BarBarEqualsToken],
|
||||
['??=', ts.SyntaxKind.QuestionQuestionEqualsToken],
|
||||
['in', ts.SyntaxKind.InKeyword],
|
||||
['instanceof', ts.SyntaxKind.InstanceOfKeyword],
|
||||
]);
|
||||
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -114,6 +114,15 @@ describe('type check blocks', () => {
|
|||
expect(tcb(`{{!('bar' in {bar: 'bar'}) }}`)).toContain(`!((("bar") in ({ "bar": "bar" })))`);
|
||||
});
|
||||
|
||||
it('should handle "instanceof" expressions', () => {
|
||||
expect(tcb(`{{obj instanceof MyClass}}`)).toContain(
|
||||
`((((this).obj)) instanceof (((this).MyClass)))`,
|
||||
);
|
||||
expect(tcb(`{{!(obj instanceof MyClass)}}`)).toContain(
|
||||
`!(((((this).obj)) instanceof (((this).MyClass)))))`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle attribute values for directive inputs', () => {
|
||||
const TEMPLATE = `<div dir inputA="value"></div>`;
|
||||
const DIRECTIVES: TestDeclaration[] = [
|
||||
|
|
|
|||
|
|
@ -80,9 +80,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
|
|||
type: Pipe,
|
||||
args: [{ name: 'identity' }]
|
||||
}] });
|
||||
export class Bar {
|
||||
}
|
||||
export class MyApp {
|
||||
foo = { bar: 'baz' };
|
||||
number = 1;
|
||||
bar = new Bar();
|
||||
Bar = Bar;
|
||||
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
||||
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: true, selector: "ng-component", ngImport: i0, template: `
|
||||
{{ 1 + 2 }}
|
||||
|
|
@ -95,6 +99,7 @@ export class MyApp {
|
|||
{{ void 'test' }}
|
||||
{{ (-1) ** 3 }}
|
||||
{{ 'bar' in foo }}
|
||||
{{ bar instanceof Bar }}
|
||||
<button (click)="number += 1"></button>
|
||||
<button (click)="number -= 1"></button>
|
||||
<button (click)="number *= 1"></button>
|
||||
|
|
@ -120,6 +125,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
|
|||
{{ void 'test' }}
|
||||
{{ (-1) ** 3 }}
|
||||
{{ 'bar' in foo }}
|
||||
{{ bar instanceof Bar }}
|
||||
<button (click)="number += 1"></button>
|
||||
<button (click)="number -= 1"></button>
|
||||
<button (click)="number *= 1"></button>
|
||||
|
|
@ -143,11 +149,15 @@ export declare class IdentityPipe {
|
|||
static ɵfac: i0.ɵɵFactoryDeclaration<IdentityPipe, never>;
|
||||
static ɵpipe: i0.ɵɵPipeDeclaration<IdentityPipe, "identity", true>;
|
||||
}
|
||||
export declare class Bar {
|
||||
}
|
||||
export declare class MyApp {
|
||||
foo: {
|
||||
bar?: string;
|
||||
};
|
||||
number: number;
|
||||
bar: Bar;
|
||||
Bar: typeof Bar;
|
||||
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
|
||||
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, true, never>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {Component, Pipe} from '@angular/core';
|
||||
import { Component, Pipe } from '@angular/core';
|
||||
|
||||
@Pipe({name: 'identity'})
|
||||
export class IdentityPipe {
|
||||
|
|
@ -7,6 +7,8 @@ export class IdentityPipe {
|
|||
}
|
||||
}
|
||||
|
||||
export class Bar {}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
{{ 1 + 2 }}
|
||||
|
|
@ -19,6 +21,7 @@ export class IdentityPipe {
|
|||
{{ void 'test' }}
|
||||
{{ (-1) ** 3 }}
|
||||
{{ 'bar' in foo }}
|
||||
{{ bar instanceof Bar }}
|
||||
<button (click)="number += 1"></button>
|
||||
<button (click)="number -= 1"></button>
|
||||
<button (click)="number *= 1"></button>
|
||||
|
|
@ -34,4 +37,6 @@ export class IdentityPipe {
|
|||
export class MyApp {
|
||||
foo: {bar?: string} = {bar: 'baz'};
|
||||
number = 1;
|
||||
bar = new Bar();
|
||||
Bar = Bar;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,14 @@ template: function MyApp_Template(rf, $ctx$) {
|
|||
1 + 2, " ",
|
||||
1 % 2 + 3 / 4 * 5 ** 6, " ",
|
||||
+1, " ",
|
||||
typeof $r3$.ɵɵpureFunction0(12, _c0) === "object", " ",
|
||||
!(typeof $r3$.ɵɵpureFunction0(13, _c0) === "object"), " ",
|
||||
typeof $r3$.ɵɵpureFunction0(13, _c0) === "object", " ",
|
||||
!(typeof $r3$.ɵɵpureFunction0(14, _c0) === "object"), " ",
|
||||
typeof ($ctx$.foo == null ? null : $ctx$.foo.bar) === "string", " ",
|
||||
$r3$.ɵɵpipeBind1(1, 10, typeof ($ctx$.foo == null ? null : $ctx$.foo.bar)), " ",
|
||||
$r3$.ɵɵpipeBind1(1, 11, typeof ($ctx$.foo == null ? null : $ctx$.foo.bar)), " ",
|
||||
void "test", " ",
|
||||
(-1) ** 3, " ",
|
||||
"bar" in $ctx$.foo, " "
|
||||
"bar" in $ctx$.foo, " ",
|
||||
$ctx$.bar instanceof $ctx$.Bar, " "
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -816,6 +816,26 @@ runInEachFileSystem(() => {
|
|||
expect(diags[0].messageText).toContain(`Type 'string' is not assignable to type 'object'`);
|
||||
});
|
||||
|
||||
it('should error on invalid instanceof binary expressions', () => {
|
||||
env.write(
|
||||
'test.ts',
|
||||
`
|
||||
import {Component} from '@angular/core';
|
||||
@Component({
|
||||
template: \` {{'foo' instanceof String}} \`,
|
||||
})
|
||||
class TestCmp {
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(2);
|
||||
expect(diags[0].messageText).toContain(
|
||||
`The left-hand side of an 'instanceof' expression must be of type 'any', an object type or a type parameter.`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('strictInputTypes', () => {
|
||||
beforeEach(() => {
|
||||
env.write(
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@ type BinaryOperation =
|
|||
| '<='
|
||||
| '>='
|
||||
| 'in'
|
||||
| 'instanceof'
|
||||
// Additive
|
||||
| '+'
|
||||
| '-'
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const KEYWORDS = [
|
|||
'typeof',
|
||||
'void',
|
||||
'in',
|
||||
'instanceof',
|
||||
];
|
||||
|
||||
export class Lexer {
|
||||
|
|
@ -126,6 +127,10 @@ export class Token {
|
|||
return this.type === TokenType.Keyword && this.strValue === 'in';
|
||||
}
|
||||
|
||||
isKeywordInstanceOf(): boolean {
|
||||
return this.type === TokenType.Keyword && this.strValue === 'instanceof';
|
||||
}
|
||||
|
||||
isError(): boolean {
|
||||
return this.type === TokenType.Error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -964,10 +964,14 @@ class _ParseAST {
|
|||
}
|
||||
|
||||
private parseRelational(): AST {
|
||||
// '<', '>', '<=', '>=', 'in'
|
||||
// '<', '>', '<=', '>=', 'in', 'instanceof'
|
||||
const start = this.inputIndex;
|
||||
let result = this.parseAdditive();
|
||||
while (this.next.type == TokenType.Operator || this.next.isKeywordIn) {
|
||||
while (
|
||||
this.next.type == TokenType.Operator ||
|
||||
this.next.isKeywordIn() || // Should be invoked. This is bug here that will be fixed by #65249 when the breaking change window opens.
|
||||
this.next.isKeywordInstanceOf()
|
||||
) {
|
||||
const operator = this.next.strValue;
|
||||
switch (operator) {
|
||||
case '<':
|
||||
|
|
@ -975,6 +979,7 @@ class _ParseAST {
|
|||
case '<=':
|
||||
case '>=':
|
||||
case 'in':
|
||||
case 'instanceof':
|
||||
this.advance();
|
||||
const right = this.parseAdditive();
|
||||
result = new Binary(this.span(start), this.sourceSpan(start), operator, result, right);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const BINARY_OPERATORS = new Map([
|
|||
[o.BinaryOperator.Or, '||'],
|
||||
[o.BinaryOperator.Plus, '+'],
|
||||
[o.BinaryOperator.In, 'in'],
|
||||
[o.BinaryOperator.InstanceOf, 'instanceof'],
|
||||
[o.BinaryOperator.AdditionAssignment, '+='],
|
||||
[o.BinaryOperator.SubtractionAssignment, '-='],
|
||||
[o.BinaryOperator.MultiplicationAssignment, '*='],
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ export enum BinaryOperator {
|
|||
NullishCoalesce,
|
||||
Exponentiation,
|
||||
In,
|
||||
InstanceOf,
|
||||
AdditionAssignment,
|
||||
SubtractionAssignment,
|
||||
MultiplicationAssignment,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const BINARY_OPERATORS = new Map([
|
|||
['||', o.BinaryOperator.Or],
|
||||
['+', o.BinaryOperator.Plus],
|
||||
['in', o.BinaryOperator.In],
|
||||
['instanceof', o.BinaryOperator.InstanceOf],
|
||||
['+=', o.BinaryOperator.AdditionAssignment],
|
||||
['-=', o.BinaryOperator.SubtractionAssignment],
|
||||
['*=', o.BinaryOperator.MultiplicationAssignment],
|
||||
|
|
|
|||
|
|
@ -222,6 +222,12 @@ describe('lexer', () => {
|
|||
expect(tokens[0].isKeywordIn()).toBe(true);
|
||||
});
|
||||
|
||||
it('should tokenize instanceof keyword', () => {
|
||||
const tokens: Token[] = lex('instanceof');
|
||||
expectKeywordToken(tokens[0], 0, 10, 'instanceof');
|
||||
expect(tokens[0].isKeywordInstanceOf()).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore whitespace', () => {
|
||||
const tokens: Token[] = lex('a \t \n \r b');
|
||||
expectIdentifierToken(tokens[0], 0, 1, 'a');
|
||||
|
|
|
|||
|
|
@ -6,30 +6,30 @@
|
|||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {expect} from '@angular/private/testing/matchers';
|
||||
import {
|
||||
AbsoluteSourceSpan,
|
||||
ArrowFunction,
|
||||
ASTWithSource,
|
||||
BindingPipe,
|
||||
BindingPipeType,
|
||||
Call,
|
||||
EmptyExpr,
|
||||
Interpolation,
|
||||
LiteralMap,
|
||||
LiteralMapPropertyKey,
|
||||
ParseSpan,
|
||||
PropertyRead,
|
||||
TemplateBinding,
|
||||
VariableBinding,
|
||||
BindingPipeType,
|
||||
ParseSpan,
|
||||
LiteralMapPropertyKey,
|
||||
ArrowFunction,
|
||||
} from '../../src/expression_parser/ast';
|
||||
import {ParseError} from '../../src/parse_util';
|
||||
import {Lexer} from '../../src/expression_parser/lexer';
|
||||
import {Parser, SplitInterpolation} from '../../src/expression_parser/parser';
|
||||
import {expect} from '@angular/private/testing/matchers';
|
||||
import {ParseError} from '../../src/parse_util';
|
||||
|
||||
import {getFakeSpan} from './utils/span';
|
||||
import {unparse, unparseWithSpan} from './utils/unparser';
|
||||
import {validate} from './utils/validator';
|
||||
import {getFakeSpan} from './utils/span';
|
||||
|
||||
describe('parser', () => {
|
||||
describe('parseAction', () => {
|
||||
|
|
@ -88,6 +88,8 @@ describe('parser', () => {
|
|||
checkAction('2 > 3');
|
||||
checkAction('2 <= 2');
|
||||
checkAction('2 >= 2');
|
||||
checkAction(`"key" in obj`);
|
||||
checkAction(`foo instanceof Foo`);
|
||||
});
|
||||
|
||||
it('should parse equality expressions', () => {
|
||||
|
|
@ -130,6 +132,11 @@ describe('parser', () => {
|
|||
checkAction('a //comment', 'a');
|
||||
});
|
||||
|
||||
it('should parse instanceof expressions', () => {
|
||||
checkAction(`obj instanceof MyClass`, `obj instanceof MyClass`);
|
||||
checkAction(`(obj instanceof MyClass) || false`, `(obj instanceof MyClass) || false`);
|
||||
});
|
||||
|
||||
it('should retain // in string literals', () => {
|
||||
checkAction(`"http://www.google.com"`, `"http://www.google.com"`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -132,5 +132,9 @@ describe('serializer', () => {
|
|||
it('serializes in expressions', () => {
|
||||
expect(serialize(parse(' foo in bar '))).toBe('foo in bar');
|
||||
});
|
||||
|
||||
it('serializes instanceof expressions', () => {
|
||||
expect(serialize(parse(' foo instanceof Bar '))).toBe('foo instanceof Bar');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -321,6 +321,32 @@ describe('control flow - if', () => {
|
|||
expect(fixture.nativeElement.textContent.trim()).toBe('no 42');
|
||||
});
|
||||
|
||||
it('should support a condition with the instanceof keyword', () => {
|
||||
class Foo {}
|
||||
|
||||
// prettier-ignore
|
||||
@Component({
|
||||
template: `
|
||||
@if (value instanceof Foo) {
|
||||
is Foo
|
||||
} @else {
|
||||
is not Foo
|
||||
}
|
||||
`,
|
||||
})
|
||||
class TestComponent {
|
||||
Foo = Foo;
|
||||
value: string | Foo = new Foo();
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(TestComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent.trim()).toBe('is Foo');
|
||||
fixture.componentInstance.value = 'not a Foo';
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent.trim()).toBe('is not Foo');
|
||||
});
|
||||
|
||||
it('should expose expression value through alias on @else if', () => {
|
||||
@Component({
|
||||
template: `
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {animate, AnimationEvent, state, style, transition, trigger} from '@angul
|
|||
import {AnimationDriver} from '@angular/animations/browser';
|
||||
import {MockAnimationDriver, MockAnimationPlayer} from '@angular/animations/browser/testing';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
|
||||
import {
|
||||
Component,
|
||||
ContentChild,
|
||||
|
|
@ -38,8 +40,6 @@ import {isLView} from '../../src/render3/interfaces/type_checks';
|
|||
import {ID, LView, PARENT, TVIEW} from '../../src/render3/interfaces/view';
|
||||
import {getLView} from '../../src/render3/state';
|
||||
import {fakeAsync, flushMicrotasks, TestBed} from '../../testing';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
|
||||
|
||||
describe('acceptance integration tests', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -3098,6 +3098,26 @@ describe('acceptance integration tests', () => {
|
|||
expect(fixture.nativeElement.textContent).toContain('KO');
|
||||
});
|
||||
|
||||
it('should support "instanceof" expressions', () => {
|
||||
class MyClass {}
|
||||
|
||||
@Component({
|
||||
template: `{{ obj instanceof MyClass ? 'OK' : 'KO' }}`,
|
||||
})
|
||||
class TestComponent {
|
||||
MyClass = MyClass;
|
||||
obj: any = new MyClass();
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(TestComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('OK');
|
||||
|
||||
fixture.componentInstance.obj = {};
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('KO');
|
||||
});
|
||||
|
||||
describe('tView.firstUpdatePass', () => {
|
||||
function isFirstUpdatePass() {
|
||||
const lView = getLView();
|
||||
|
|
|
|||
Loading…
Reference in a new issue