Skip to content

Commit 640693d

Browse files
JeanMechekirjs
authored andcommitted
feat(compiler): Add support for multiple swich cases matching
consecutive `@case` blocks are now supported: ```ts @switch (case) { @case (0) @case (1) { case 0 or 1 } @case (2) { case 2 } @default { default } } ``` fixes angular#14659
1 parent e44839b commit 640693d

35 files changed

Lines changed: 788 additions & 166 deletions

File tree

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ packages/compiler-cli/test/compliance/test_cases/**/*.ts
2525

2626
# Ignore testing data files for language service
2727
vscode-ng-language-service/syntaxes/test/data/*.ts
28+
vscode-ng-language-service/syntaxes/test/data/*.html
2829

2930
# Ignore goldens MD files
3031
goldens/**/*.api.md

adev/src/content/guide/templates/control-flow.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,7 @@ While the `@if` block is great for most scenarios, the `@switch` block provides
106106
@case ('admin') {
107107
<app-admin-dashboard />
108108
}
109-
@case ('reviewer') {
110-
<app-reviewer-dashboard />
111-
}
109+
@case ('reviewer')
112110
@case ('editor') {
113111
<app-editor-dashboard />
114112
}
@@ -122,6 +120,8 @@ The value of the conditional expression is compared to the case expression using
122120

123121
**`@switch` does not have a fallthrough**, so you do not need an equivalent to a `break` or `return` statement in the block.
124122

123+
You can specify multiple conditions for a single block by have consecutive `@case` statements.
124+
125125
You can optionally include a `@default` block. The content of the `@default` block displays if none of the preceding case expressions match the switch value.
126126

127127
If no `@case` matches the expression and there is no `@default` block, nothing is shown.

packages/compiler-cli/src/ngtsc/typecheck/src/ops/content_projection.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
TmplAstIfBlockBranch,
1919
TmplAstNode,
2020
TmplAstSwitchBlock,
21-
TmplAstSwitchBlockCase,
21+
TmplAstSwitchBlockCaseGroup,
2222
TmplAstTemplate,
2323
TmplAstText,
2424
} from '@angular/compiler';
@@ -96,7 +96,10 @@ export class TcbControlFlowContentProjectionOp extends TcbOp {
9696

9797
private findPotentialControlFlowNodes() {
9898
const result: Array<
99-
TmplAstIfBlockBranch | TmplAstSwitchBlockCase | TmplAstForLoopBlock | TmplAstForLoopBlockEmpty
99+
| TmplAstIfBlockBranch
100+
| TmplAstSwitchBlockCaseGroup
101+
| TmplAstForLoopBlock
102+
| TmplAstForLoopBlockEmpty
100103
> = [];
101104

102105
for (const child of this.element.children) {
@@ -114,7 +117,7 @@ export class TcbControlFlowContentProjectionOp extends TcbOp {
114117
}
115118
}
116119
} else if (child instanceof TmplAstSwitchBlock) {
117-
for (const current of child.cases) {
120+
for (const current of child.groups) {
118121
if (this.shouldCheck(current)) {
119122
result.push(current);
120123
}

packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {TmplAstSwitchBlock, TmplAstSwitchBlockCase} from '@angular/compiler';
9+
import {TmplAstSwitchBlock, TmplAstSwitchBlockCaseGroup} from '@angular/compiler';
1010
import ts from 'typescript';
1111
import {TcbOp} from './base';
1212
import type {Scope} from './scope';
@@ -34,22 +34,26 @@ export class TcbSwitchOp extends TcbOp {
3434

3535
override execute(): null {
3636
const switchExpression = tcbExpression(this.block.expression, this.tcb, this.scope);
37-
const clauses = this.block.cases.map((current) => {
37+
const clauses = this.block.groups.flatMap<ts.CaseOrDefaultClause>((current) => {
3838
const checkBody = this.tcb.env.config.checkControlFlowBodies;
3939
const clauseScope = this.scope.createChildScope(
4040
this.scope,
4141
null,
4242
checkBody ? current.children : [],
4343
checkBody ? this.generateGuard(current, switchExpression) : null,
4444
);
45+
4546
const statements = [...clauseScope.render(), ts.factory.createBreakStatement()];
4647

47-
return current.expression === null
48-
? ts.factory.createDefaultClause(statements)
49-
: ts.factory.createCaseClause(
50-
tcbExpression(current.expression, this.tcb, clauseScope),
51-
statements,
52-
);
48+
return current.cases.map((switchCase, index) => {
49+
const statementsForCase = index === current.cases.length - 1 ? statements : [];
50+
return switchCase.expression === null
51+
? ts.factory.createDefaultClause(statementsForCase)
52+
: ts.factory.createCaseClause(
53+
tcbExpression(switchCase.expression, this.tcb, this.scope),
54+
statementsForCase,
55+
);
56+
});
5357
});
5458

5559
this.scope.addStatement(
@@ -60,20 +64,36 @@ export class TcbSwitchOp extends TcbOp {
6064
}
6165

6266
private generateGuard(
63-
node: TmplAstSwitchBlockCase,
67+
group: TmplAstSwitchBlockCaseGroup,
6468
switchValue: ts.Expression,
6569
): ts.Expression | null {
6670
// For non-default cases, the guard needs to compare against the case value, e.g.
6771
// `switchExpression === caseExpression`.
68-
if (node.expression !== null) {
69-
// The expression needs to be ignored for diagnostics since it has been checked already.
70-
const expression = tcbExpression(node.expression, this.tcb, this.scope);
71-
markIgnoreDiagnostics(expression);
72-
return ts.factory.createBinaryExpression(
73-
switchValue,
74-
ts.SyntaxKind.EqualsEqualsEqualsToken,
75-
expression,
76-
);
72+
const hasDefault = group.cases.some((c) => c.expression === null);
73+
74+
if (!hasDefault) {
75+
let guard: ts.Expression | null = null;
76+
77+
for (const switchCase of group.cases) {
78+
if (switchCase.expression !== null) {
79+
// The expression needs to be ignored for diagnostics since it has been checked already.
80+
const expression = tcbExpression(switchCase.expression, this.tcb, this.scope);
81+
markIgnoreDiagnostics(expression);
82+
const comparison = ts.factory.createBinaryExpression(
83+
switchValue,
84+
ts.SyntaxKind.EqualsEqualsEqualsToken,
85+
expression,
86+
);
87+
88+
if (guard === null) {
89+
guard = comparison;
90+
} else {
91+
guard = ts.factory.createBinaryExpression(guard, ts.SyntaxKind.BarBarToken, comparison);
92+
}
93+
}
94+
}
95+
96+
return guard;
7797
}
7898

7999
// To fully narrow the type in the default case, we need to generate an expression that negates
@@ -86,28 +106,35 @@ export class TcbSwitchOp extends TcbOp {
86106
// Will produce the guard `expr !== 1 && expr !== 2`.
87107
let guard: ts.Expression | null = null;
88108

89-
for (const current of this.block.cases) {
90-
if (current.expression === null) {
109+
for (const currentGroup of this.block.groups) {
110+
if (currentGroup === group) {
91111
continue;
92112
}
93113

94-
// The expression needs to be ignored for diagnostics since it has been checked already.
95-
const expression = tcbExpression(current.expression, this.tcb, this.scope);
96-
markIgnoreDiagnostics(expression);
97-
const comparison = ts.factory.createBinaryExpression(
98-
switchValue,
99-
ts.SyntaxKind.ExclamationEqualsEqualsToken,
100-
expression,
101-
);
114+
for (const switchCase of currentGroup.cases) {
115+
if (switchCase.expression === null) {
116+
// Skip the default case.
117+
continue;
118+
}
102119

103-
if (guard === null) {
104-
guard = comparison;
105-
} else {
106-
guard = ts.factory.createBinaryExpression(
107-
guard,
108-
ts.SyntaxKind.AmpersandAmpersandToken,
109-
comparison,
120+
// The expression needs to be ignored for diagnostics since it has been checked already.
121+
const expression = tcbExpression(switchCase.expression, this.tcb, this.scope);
122+
markIgnoreDiagnostics(expression);
123+
const comparison = ts.factory.createBinaryExpression(
124+
switchValue,
125+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
126+
expression,
110127
);
128+
129+
if (guard === null) {
130+
guard = comparison;
131+
} else {
132+
guard = ts.factory.createBinaryExpression(
133+
guard,
134+
ts.SyntaxKind.AmpersandAmpersandToken,
135+
comparison,
136+
);
137+
}
111138
}
112139
}
113140

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2002,6 +2002,50 @@ describe('type check blocks', () => {
20022002
);
20032003
});
20042004

2005+
it('should generate a switch block with a falltrough for consecutive cases', () => {
2006+
const TEMPLATE = `
2007+
@switch (expr) {
2008+
@case ('one')
2009+
@case ('two') {
2010+
{{oneOrTwo()}}
2011+
}
2012+
@case ('three') {
2013+
{{three()}}
2014+
}
2015+
}
2016+
`;
2017+
2018+
expect(tcb(TEMPLATE)).toContain(
2019+
'switch (((this).expr)) { ' +
2020+
'case "one": ' +
2021+
'case "two": "" + ((this).oneOrTwo()); break; ' +
2022+
'case "three": "" + ((this).three()); break; }',
2023+
);
2024+
});
2025+
2026+
it('should generate a switch block with a fallthrough to default case', () => {
2027+
const TEMPLATE = `
2028+
@switch (expr) {
2029+
@case (1)
2030+
@case (2) {
2031+
{{oneOrTwo()}}
2032+
}
2033+
@case (3)
2034+
@default {
2035+
{{default()}}
2036+
}
2037+
}
2038+
`;
2039+
2040+
expect(tcb(TEMPLATE)).toContain(
2041+
'switch (((this).expr)) { ' +
2042+
'case 1: ' +
2043+
'case 2: "" + ((this).oneOrTwo()); break; ' +
2044+
'case 3: ' +
2045+
'default: "" + ((this).default()); break; }',
2046+
);
2047+
});
2048+
20052049
it('should generate a switch block that only has a default case', () => {
20062050
const TEMPLATE = `
20072051
@switch (expr) {

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2327,6 +2327,68 @@ export declare class MyApp {
23272327
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, true, never>;
23282328
}
23292329

2330+
/****************************************************************************************************
2331+
* PARTIAL FILE: switch_multiple_cases.js
2332+
****************************************************************************************************/
2333+
import { Component } from '@angular/core';
2334+
import * as i0 from "@angular/core";
2335+
export class MyApp {
2336+
message = 'hello';
2337+
value() {
2338+
return 1;
2339+
}
2340+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
2341+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: false, selector: "ng-component", ngImport: i0, template: `
2342+
<div>
2343+
{{message}}
2344+
@switch (value()) {
2345+
@case (0) @case(1) {
2346+
case 01
2347+
}
2348+
@case (2) {
2349+
case 2
2350+
}
2351+
@default {
2352+
default
2353+
}
2354+
}
2355+
</div>
2356+
`, isInline: true });
2357+
}
2358+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
2359+
type: Component,
2360+
args: [{
2361+
template: `
2362+
<div>
2363+
{{message}}
2364+
@switch (value()) {
2365+
@case (0) @case(1) {
2366+
case 01
2367+
}
2368+
@case (2) {
2369+
case 2
2370+
}
2371+
@default {
2372+
default
2373+
}
2374+
}
2375+
</div>
2376+
`,
2377+
standalone: false
2378+
}]
2379+
}] });
2380+
2381+
/****************************************************************************************************
2382+
* PARTIAL FILE: switch_multiple_cases.d.ts
2383+
****************************************************************************************************/
2384+
import * as i0 from "@angular/core";
2385+
export declare class MyApp {
2386+
message: string;
2387+
value(): number;
2388+
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
2389+
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
2390+
}
2391+
23302392
/****************************************************************************************************
23312393
* PARTIAL FILE: nested_for_computed_template_variables.js
23322394
****************************************************************************************************/

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,21 @@
631631
}
632632
]
633633
},
634+
{
635+
"description": "should generate a switch block with multiple/consecutive case expressions",
636+
"inputFiles": ["switch_multiple_cases.ts"],
637+
"expectations": [
638+
{
639+
"files": [
640+
{
641+
"expected": "switch_multiple_cases_template.js",
642+
"generated": "switch_multiple_cases.js"
643+
}
644+
],
645+
"failureMessage": "Incorrect template"
646+
}
647+
]
648+
},
634649
{
635650
"description": "should generate computed for loop variables that depend on shadowed $index and $count",
636651
"inputFiles": ["nested_for_computed_template_variables.ts"],
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {Component} from '@angular/core';
2+
3+
@Component({
4+
template: `
5+
<div>
6+
{{message}}
7+
@switch (value()) {
8+
@case (0) @case(1) {
9+
case 01
10+
}
11+
@case (2) {
12+
case 2
13+
}
14+
@default {
15+
default
16+
}
17+
}
18+
</div>
19+
`,
20+
standalone: false
21+
})
22+
export class MyApp {
23+
message = 'hello';
24+
25+
value() {
26+
return 1;
27+
}
28+
}

0 commit comments

Comments
 (0)