Visitar URL original
feat(core): support increment/decrement operators in expressions · angular/angular@6ea13ef · GitHub
Skip to content

Commit 6ea13ef

Browse files
crisbetoatscott
authored andcommitted
feat(core): support increment/decrement operators in expressions
Adds support for `++` and `--` operators in an effort to align our syntax closer to native JavaScript.
1 parent cb8ed6a commit 6ea13ef

14 files changed

Lines changed: 423 additions & 38 deletions

File tree

‎adev/src/content/guide/templates/expression-syntax.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Angular expressions additionally also support the following non-standard operato
9696
### Safe navigation migration
9797

9898
Prior to Angular 22, the optional chaining operator (`?.`) returned `null` when the left-hand side is `null` or `undefined`, whereas standard JavaScript's `?.` returns `undefined`.
99-
Since Angular 22, the optional chaining operator behavior in Angular expressions is aligned with the standard Javascript behavior.
99+
Since Angular 22, the optional chaining operator behavior in Angular expressions is aligned with the standard JavaScript behavior.
100100

101101
During the migration to v22, the `ng update` schematics adds a `$safeNavigationMigration` function to existing expressions to preserve the previous behavior.
102102

‎packages/compiler-cli/src/ngtsc/typecheck/template_semantics/src/template_semantics_checker.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ class ExpressionsSemanticsVisitor extends RecursiveAstVisitor {
8282
override visitBinary(ast: Binary, context: TmplAstNode): void {
8383
if (Binary.isAssignmentOperation(ast.operation) && ast.left instanceof PropertyRead) {
8484
this.checkForIllegalWriteInEventBinding(ast.left, context);
85-
} else {
86-
super.visitBinary(ast, context);
8785
}
86+
87+
// Always recurse since the right-hand side may contain other writes (e.g. `prop = y++`).
88+
super.visitBinary(ast, context);
8889
}
8990

9091
override visitUnary(ast: Unary, context: TmplAstNode): void {

‎packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ export class MyApp {
109109
<button (click)="number &&= 1"></button>
110110
<button (click)="number ||= 1"></button>
111111
<button (click)="number ??= 1"></button>
112+
<button (click)="number++"></button>
113+
<button (click)="number--"></button>
114+
<button (click)="++number"></button>
115+
<button (click)="--number"></button>
112116
`, isInline: true, dependencies: [{ kind: "pipe", type: IdentityPipe, name: "identity" }] });
113117
}
114118
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
@@ -135,6 +139,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
135139
<button (click)="number &&= 1"></button>
136140
<button (click)="number ||= 1"></button>
137141
<button (click)="number ??= 1"></button>
142+
<button (click)="number++"></button>
143+
<button (click)="number--"></button>
144+
<button (click)="++number"></button>
145+
<button (click)="--number"></button>
138146
`,
139147
imports: [IdentityPipe],
140148
}]
@@ -207,6 +215,10 @@ export class MyApp {
207215
<button (click)="number &&= 1"></button>
208216
<button (click)="number ||= 1"></button>
209217
<button (click)="number ??= 1"></button>
218+
<button (click)="number++"></button>
219+
<button (click)="number--"></button>
220+
<button (click)="++number"></button>
221+
<button (click)="--number"></button>
210222
`, isInline: true, dependencies: [{ kind: "pipe", type: IdentityPipe, name: "identity" }] });
211223
}
212224
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
@@ -233,6 +245,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
233245
<button (click)="number &&= 1"></button>
234246
<button (click)="number ||= 1"></button>
235247
<button (click)="number ??= 1"></button>
248+
<button (click)="number++"></button>
249+
<button (click)="number--"></button>
250+
<button (click)="++number"></button>
251+
<button (click)="--number"></button>
236252
`,
237253
imports: [IdentityPipe],
238254
}]

‎packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export class Bar {}
3131
<button (click)="number &&= 1"></button>
3232
<button (click)="number ||= 1"></button>
3333
<button (click)="number ??= 1"></button>
34+
<button (click)="number++"></button>
35+
<button (click)="number--"></button>
36+
<button (click)="++number"></button>
37+
<button (click)="--number"></button>
3438
`,
3539
imports: [IdentityPipe],
3640
})

‎packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ template: function MyApp_Template(rf, $ctx$) {
1919
…
2020
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_10_listener() { return $ctx$.number ??= 1; });
2121
…
22+
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_11_listener() { return $ctx$.number++; });
23+
…
24+
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_12_listener() { return $ctx$.number--; });
25+
…
26+
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_13_listener() { return ++$ctx$.number; });
27+
…
28+
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_14_listener() { return --$ctx$.number; });
29+
…
2230
}
2331
if (rf & 2) {
2432
$r3$.ɵɵtextInterpolateV([" ",

‎packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template_use_null.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ template: function MyApp_Template(rf, $ctx$) {
1919
…
2020
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_10_listener() { return $ctx$.number ??= 1; });
2121
…
22+
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_11_listener() { return $ctx$.number++; });
23+
…
24+
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_12_listener() { return $ctx$.number--; });
25+
…
26+
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_13_listener() { return ++$ctx$.number; });
27+
…
28+
$r3$.ɵɵdomListener("click", function MyApp_Template_button_click_14_listener() { return --$ctx$.number; });
29+
…
2230
}
2331
if (rf & 2) {
2432
$r3$.ɵɵtextInterpolateV([" ",

‎packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_use_null.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export class Bar {}
3131
<button (click)="number &&= 1"></button>
3232
<button (click)="number ||= 1"></button>
3333
<button (click)="number ??= 1"></button>
34+
<button (click)="number++"></button>
35+
<button (click)="number--"></button>
36+
<button (click)="++number"></button>
37+
<button (click)="--number"></button>
3438
`,
3539
imports: [IdentityPipe],
3640
})

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2496,6 +2496,65 @@ runInEachFileSystem(() => {
24962496
);
24972497
});
24982498

2499+
it('should detect an illegal write to a template variable through update operators', () => {
2500+
env.write(
2501+
'test.ts',
2502+
`
2503+
import {Component} from '@angular/core';
2504+
import {CommonModule} from '@angular/common';
2505+
2506+
@Component({
2507+
template: \`
2508+
<div *ngIf="x as y">
2509+
<button (click)="y++">Increment</button>
2510+
<button (click)="--y">Decrement</button>
2511+
</div>
2512+
\`,
2513+
imports: [CommonModule]
2514+
})
2515+
export class TestCmp {
2516+
x!: number;
2517+
}
2518+
`,
2519+
);
2520+
const diags = env.driveDiagnostics();
2521+
expect(diags.length).toBe(2);
2522+
expect(diags.map((d) => getSourceCodeForDiagnostic(d))).toEqual(['y++', '--y']);
2523+
expect(diags.map((d) => d.messageText)).toEqual([
2524+
`Cannot use variable 'y' as the left-hand side of an assignment expression. Template variables are read-only.`,
2525+
`Cannot use variable 'y' as the left-hand side of an assignment expression. Template variables are read-only.`,
2526+
]);
2527+
});
2528+
2529+
it('should detect an illegal write to a template variable through an update operator on the right-hand side of an assignment', () => {
2530+
env.write(
2531+
'test.ts',
2532+
`
2533+
import {Component} from '@angular/core';
2534+
import {CommonModule} from '@angular/common';
2535+
2536+
@Component({
2537+
template: \`
2538+
<div *ngIf="x as y">
2539+
<button (click)="prop = y++">Increment</button>
2540+
</div>
2541+
\`,
2542+
imports: [CommonModule]
2543+
})
2544+
export class TestCmp {
2545+
x!: number;
2546+
prop = 0;
2547+
}
2548+
`,
2549+
);
2550+
const diags = env.driveDiagnostics();
2551+
expect(diags.length).toBe(1);
2552+
expect(getSourceCodeForDiagnostic(diags[0])).toBe('prop = y++');
2553+
expect(diags[0].messageText).toBe(
2554+
`Cannot use variable 'y' as the left-hand side of an assignment expression. Template variables are read-only.`,
2555+
);
2556+
});
2557+
24992558
describe('foreign component template semantics', () => {
25002559
const foreignSetupCode = `
25012560
// We must redeclare foreignImports and ForeignComponent to test them since they are marked @internal.
@@ -8940,6 +8999,31 @@ suppress
89408999
expect(diags[1].messageText).toBe(`Cannot assign to @let declaration 'value'.`);
89419000
});
89429001

9002+
it('should not allow update operators to be used on a readonly signal', () => {
9003+
env.write(
9004+
'test.ts',
9005+
`
9006+
import {Component, signal} from '@angular/core';
9007+
9008+
@Component({
9009+
template: \`
9010+
<button (click)="count++">Click me</button>
9011+
<button (click)="--count">Click me</button>
9012+
\`,
9013+
})
9014+
export class Main {
9015+
readonly count = signal(0);
9016+
}
9017+
`,
9018+
);
9019+
9020+
const diags = env.driveDiagnostics();
9021+
expect(diags.map((d) => d.messageText)).toEqual([
9022+
`Cannot assign to 'count' because it is a read-only property.`,
9023+
`Cannot assign to 'count' because it is a read-only property.`,
9024+
]);
9025+
});
9026+
89439027
it('should not allow a let declaration value to be changed through a `this` access', () => {
89449028
env.write(
89459029
'test.ts',

‎packages/compiler/src/expression_parser/parser.ts‎

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -752,25 +752,17 @@ class _ParseAST {
752752

753753
if (target instanceof PropertyRead || target instanceof KeyedRead) {
754754
return true;
755-
} else {
755+
}
756+
757+
if (!(target instanceof EmptyExpr)) {
756758
if (target instanceof SafePropertyRead || target instanceof SafeKeyedRead) {
757759
this.error(`The '?.' operator cannot be used in the assignment`);
758-
} else if (target instanceof Unary) {
759-
this.error(`The '${target.operator}' operator cannot be used in the assignment`);
760-
} else if (target instanceof Binary) {
761-
this.error(`The '${target.operation}' operator cannot be used in the assignment`);
762-
} else if (target instanceof PrefixNot) {
763-
this.error("The '!' operator cannot be used in the assignment");
764-
} else if (target instanceof Call || target instanceof SafeCall) {
765-
this.error("The '()' operator cannot be used in the assignment");
766-
} else if (target instanceof LiteralPrimitive) {
767-
this.error(`The '${target.value}' literal cannot be used in the assignment`);
768760
} else {
769761
this.error('The expression cannot be used in the assignment');
770762
}
771-
772-
return false;
773763
}
764+
765+
return false;
774766
}
775767

776768
private expectOperator(operator: string) {

‎packages/compiler/src/expression_parser/serializer.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ class SerializeExpressionVisitor implements expr.AstVisitor {
1818
const inner = ast.expr.visit(this, context);
1919

2020
if (ast.isPrefix) {
21-
const lastChar = ast.operator[ast.operator.length - 1];
22-
const space = (lastChar === '+' || lastChar === '-') && inner.startsWith(lastChar) ? ' ' : '';
21+
const space = inner.startsWith(ast.operator[0]) ? ' ' : '';
2322
return `${ast.operator}${space}${inner}`;
2423
}
2524

0 commit comments

Comments
 (0)