Skip to content

Commit fe8a683

Browse files
crisbetoAndrewKushnir
authored andcommitted
feat(compiler): support untagged template literals in expressions (#59230)
Updates the compiler to support untagged template literals inside of the expression syntax (e.g. ``hello ${world}``). PR Close #59230
1 parent 6960ec0 commit fe8a683

File tree

9 files changed

+197
-1
lines changed

9 files changed

+197
-1
lines changed

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/GOLDEN_PARTIAL.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,3 +723,60 @@ export declare class MyModule {
723723
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
724724
}
725725

726+
/****************************************************************************************************
727+
* PARTIAL FILE: template_literals.js
728+
****************************************************************************************************/
729+
import { Component, Pipe } from '@angular/core';
730+
import * as i0 from "@angular/core";
731+
export class UppercasePipe {
732+
transform(value) {
733+
return value.toUpperCase();
734+
}
735+
}
736+
UppercasePipe.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: UppercasePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
737+
UppercasePipe.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: UppercasePipe, isStandalone: true, name: "uppercase" });
738+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: UppercasePipe, decorators: [{
739+
type: Pipe,
740+
args: [{ name: 'uppercase' }]
741+
}] });
742+
export class MyApp {
743+
constructor() {
744+
this.name = 'Frodo';
745+
this.timeOfDay = 'morning';
746+
}
747+
}
748+
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
749+
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: true, selector: "my-app", ngImport: i0, template: `
750+
<div>No interpolations: {{ \`hello world \` }}</div>
751+
<span>With interpolations: {{ \`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
752+
<p>With pipe: {{\`hello \${name}\` | uppercase}}</p>
753+
`, isInline: true, dependencies: [{ kind: "pipe", type: UppercasePipe, name: "uppercase" }] });
754+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
755+
type: Component,
756+
args: [{
757+
selector: 'my-app',
758+
template: `
759+
<div>No interpolations: {{ \`hello world \` }}</div>
760+
<span>With interpolations: {{ \`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
761+
<p>With pipe: {{\`hello \${name}\` | uppercase}}</p>
762+
`,
763+
imports: [UppercasePipe],
764+
}]
765+
}] });
766+
767+
/****************************************************************************************************
768+
* PARTIAL FILE: template_literals.d.ts
769+
****************************************************************************************************/
770+
import * as i0 from "@angular/core";
771+
export declare class UppercasePipe {
772+
transform(value: string): string;
773+
static ɵfac: i0.ɵɵFactoryDeclaration<UppercasePipe, never>;
774+
static ɵpipe: i0.ɵɵPipeDeclaration<UppercasePipe, "uppercase", true>;
775+
}
776+
export declare class MyApp {
777+
name: string;
778+
timeOfDay: string;
779+
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
780+
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never, true, never>;
781+
}
782+

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/value_composition/TEST_CASES.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,20 @@
290290
]
291291
}
292292
]
293+
},
294+
{
295+
"description": "should support template literals",
296+
"inputFiles": [
297+
"template_literals.ts"
298+
],
299+
"expectations": [
300+
{
301+
"failureMessage": "Invalid template literal binding",
302+
"files": [
303+
"template_literals.js"
304+
]
305+
}
306+
]
293307
}
294308
]
295-
}
309+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
if (rf & 2) {
2+
$r3$.ɵɵadvance();
3+
$r3$.ɵɵtextInterpolate1("No interpolations: ", `hello world `, "");
4+
$r3$.ɵɵadvance(2);
5+
$r3$.ɵɵtextInterpolate1("With interpolations: ", `hello ${ctx.name}, it is currently ${ctx.timeOfDay}!`, "");
6+
$r3$.ɵɵadvance(2);
7+
$r3$.ɵɵtextInterpolate1("With pipe: ", $r3$.ɵɵpipeBind1(6, 3, `hello ${ctx.name}`), "");
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {Component, Pipe} from '@angular/core';
2+
3+
@Pipe({name: 'uppercase'})
4+
export class UppercasePipe {
5+
transform(value: string) {
6+
return value.toUpperCase();
7+
}
8+
}
9+
10+
@Component({
11+
selector: 'my-app',
12+
template: `
13+
<div>No interpolations: {{ \`hello world \` }}</div>
14+
<span>With interpolations: {{ \`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
15+
<p>With pipe: {{\`hello \${name}\` | uppercase}}</p>
16+
`,
17+
imports: [UppercasePipe],
18+
})
19+
export class MyApp {
20+
name = 'Frodo';
21+
timeOfDay = 'morning';
22+
}

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3352,6 +3352,58 @@ runInEachFileSystem(() => {
33523352
expect(diags.length).toBe(0);
33533353
});
33543354

3355+
describe('template literals', () => {
3356+
it('should treat template literals as strings', () => {
3357+
env.write(
3358+
'test.ts',
3359+
`
3360+
import {Component} from '@angular/core';
3361+
3362+
@Component({
3363+
template: 'Result: {{getValue(\`foo\`)}}',
3364+
standalone: true,
3365+
})
3366+
export class Main {
3367+
getValue(value: number) {
3368+
return value;
3369+
}
3370+
}
3371+
`,
3372+
);
3373+
3374+
const diags = env.driveDiagnostics();
3375+
expect(diags.length).toBe(1);
3376+
expect(diags[0].messageText).toBe(
3377+
`Argument of type 'string' is not assignable to parameter of type 'number'.`,
3378+
);
3379+
});
3380+
3381+
it('should check interpolations inside template literals', () => {
3382+
env.write(
3383+
'test.ts',
3384+
`
3385+
import {Component} from '@angular/core';
3386+
3387+
@Component({
3388+
template: '{{\`Hello \${getName(123)}\`}}',
3389+
standalone: true,
3390+
})
3391+
export class Main {
3392+
getName(value: string) {
3393+
return value;
3394+
}
3395+
}
3396+
`,
3397+
);
3398+
3399+
const diags = env.driveDiagnostics();
3400+
expect(diags.length).toBe(1);
3401+
expect(diags[0].messageText).toBe(
3402+
`Argument of type 'number' is not assignable to parameter of type 'string'.`,
3403+
);
3404+
});
3405+
});
3406+
33553407
describe('legacy schema checking with the DOM schema', () => {
33563408
beforeEach(() => {
33573409
env.tsconfig({fullTemplateTypeCheck: false});

packages/compiler/src/template/pipeline/ir/src/expression.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,6 +1305,10 @@ export function transformExpressionsInExpression(
13051305
}
13061306
} else if (expr instanceof o.WrappedNodeExpr) {
13071307
// TODO: Do we need to transform any TS nodes nested inside of this expression?
1308+
} else if (expr instanceof o.TemplateLiteralExpr) {
1309+
for (let i = 0; i < expr.expressions.length; i++) {
1310+
expr.expressions[i] = transformExpressionsInExpression(expr.expressions[i], transform, flags);
1311+
}
13081312
} else if (
13091313
expr instanceof o.ReadVarExpr ||
13101314
expr instanceof o.ExternalExpr ||

packages/compiler/src/template/pipeline/src/ingest.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,17 @@ function convertAst(
11641164
);
11651165
} else if (ast instanceof e.TypeofExpression) {
11661166
return o.typeofExpr(convertAst(ast.expression, job, baseSourceSpan));
1167+
} else if (ast instanceof e.TemplateLiteral) {
1168+
return new o.TemplateLiteralExpr(
1169+
ast.elements.map((el) => {
1170+
return new o.TemplateLiteralElementExpr(
1171+
el.text,
1172+
convertSourceSpan(el.span, baseSourceSpan),
1173+
);
1174+
}),
1175+
ast.expressions.map((expr) => convertAst(expr, job, baseSourceSpan)),
1176+
convertSourceSpan(ast.span, baseSourceSpan),
1177+
);
11671178
} else {
11681179
throw new Error(
11691180
`Unhandled expression type "${ast.constructor.name}" in file "${baseSourceSpan?.start.file.url}"`,

packages/core/test/acceptance/integration_spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2715,6 +2715,26 @@ describe('acceptance integration tests', () => {
27152715
expect(() => TestBed.createComponent(Comp).detectChanges()).not.toThrow();
27162716
});
27172717

2718+
it('should support template literals in expressions', () => {
2719+
@Component({
2720+
standalone: true,
2721+
template: 'Message: {{`Hello, ${name} - ${value}`}}',
2722+
})
2723+
class TestComponent {
2724+
name = 'Frodo';
2725+
value = 0;
2726+
}
2727+
2728+
const fixture = TestBed.createComponent(TestComponent);
2729+
fixture.detectChanges();
2730+
expect(fixture.nativeElement.textContent).toContain('Message: Hello, Frodo - 0');
2731+
2732+
fixture.componentInstance.value++;
2733+
fixture.componentInstance.name = 'Bilbo';
2734+
fixture.detectChanges();
2735+
expect(fixture.nativeElement.textContent).toContain('Message: Hello, Bilbo - 1');
2736+
});
2737+
27182738
describe('tView.firstUpdatePass', () => {
27192739
function isFirstUpdatePass() {
27202740
const lView = getLView();

packages/language-service/test/quick_info_spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,14 @@ describe('quick info', () => {
402402
expectedDisplayString: '(property) name: "name"',
403403
});
404404
});
405+
406+
it('should work for template literal interpolations', () => {
407+
expectQuickInfo({
408+
templateOverride: `<div *ngFor="let name of constNames">{{\`Hello \${na¦me}\`}}</div>`,
409+
expectedSpanText: 'name',
410+
expectedDisplayString: '(variable) name: { readonly name: "name"; }',
411+
});
412+
});
405413
});
406414

407415
describe('pipes', () => {

0 commit comments

Comments
 (0)