fix(migrations): correctly detect then/else keywords in control flow migration

The control flow migration determines whether an `*ngIf` uses a `then`
and/or `else` clause by regex matching the raw microsyntax string for
the literal keywords `then`/`else`. The regexes only checked that the
keyword was preceded by a non-word character, but not that it was
followed by one.

As a result, a template reference name that merely starts with `then`
(e.g. `else thenBlock`) or `else` was misidentified as the `then`/`else`
keyword itself. This caused the migration to take the wrong code path
(e.g. then+else instead of else-only), which in turn made
`getTemplateName()` compute a `slice(start, end)` with `start > end`,
producing an empty template name. That empty placeholder was never
resolved and was silently emitted as an invalid
`<ng-template [ngTemplateOutlet]=""></ng-template>`, dropping the
original template content without any warning.

Add a negative lookahead `(?![\w\d])` to both regexes so `then`/`else`
are only matched as whole keywords, not as a prefix of a longer
template reference name.

Fixes #69914

(cherry picked from commit 5ad8231397)
This commit is contained in:
Suraj Yadav
2026-07-24 00:18:28 +05:30
committed by Alex Rickabaugh
parent 1caafa6d8a
commit 5d5b2ea72d
2 changed files with 128 additions and 2 deletions
@@ -75,8 +75,15 @@ export function migrateIf(template: string): {
}
function migrateNgIf(etm: ElementToMigrate, tmpl: string, offset: number): Result {
const matchThen = etm.attr.value.match(/[^\w\d];?\s*then/gm);
const matchElse = etm.attr.value.match(/[^\w\d];?\s*else/gm);
// The negative lookahead (?![\w$]) ensures `then`/`else` are matched only as
// whole keywords. Without it, an else/then template reference name that merely
// *starts* with `then`/`else` (e.g. `else thenBlock`) is misidentified as the
// `then` keyword, since `[^\w$];?\s*then` also matches the `then` prefix of
// `thenBlock`. `$` is included alongside `\w` (which already covers digits)
// since it is a valid identifier character in JS/template reference names
// (e.g. `#then$`), but is not part of `\w`.
const matchThen = etm.attr.value.match(/[^\w$];?\s*then(?![\w$])/gm);
const matchElse = etm.attr.value.match(/[^\w$];?\s*else(?![\w$])/gm);
if (etm.thenAttr !== undefined || etm.elseAttr !== undefined) {
// bound if then / if then else
@@ -465,6 +465,125 @@ describe('control flow migration (ng update)', () => {
);
});
it('should migrate an if else case where the else template reference name starts with `then`', async () => {
writeFile(
'/comp.ts',
`
import {Component} from '@angular/core';
import {NgIf} from '@angular/common';
@Component({
templateUrl: './comp.html'
})
class Comp {
data: any;
}
`,
);
writeFile(
'/comp.html',
[
`<ng-container *ngIf="data.teamMember; else thenBlock">`,
` <h2>Hello team member!</h2>`,
`</ng-container>`,
`<ng-template #thenBlock>`,
` <h2>No team member</h2>`,
`</ng-template>`,
].join('\n'),
);
await runMigration();
const content = tree.readContent('/comp.html');
expect(content).toBe(
[
`@if (data.teamMember) {`,
` <h2>Hello team member!</h2>`,
`} @else {`,
` <h2>No team member</h2>`,
`}\n`,
].join('\n'),
);
});
it('should migrate an if then case where the then template reference name starts with `else`', async () => {
writeFile(
'/comp.ts',
`
import {Component} from '@angular/core';
import {NgIf} from '@angular/common';
@Component({
templateUrl: './comp.html'
})
class Comp {
data: any;
}
`,
);
writeFile(
'/comp.html',
[
`<ng-container *ngIf="data.teamMember; then elseyBlock"></ng-container>`,
`<ng-template #elseyBlock>`,
` <h2>Then content</h2>`,
`</ng-template>`,
].join('\n'),
);
await runMigration();
const content = tree.readContent('/comp.html');
expect(content).toBe(
[`@if (data.teamMember) {`, ` <h2>Then content</h2>`, `}\n`].join('\n'),
);
});
it('should migrate an if then else case where template reference names start with `then`/`else`', async () => {
writeFile(
'/comp.ts',
`
import {Component} from '@angular/core';
import {NgIf} from '@angular/common';
@Component({
templateUrl: './comp.html'
})
class Comp {
data: any;
}
`,
);
writeFile(
'/comp.html',
[
`<ng-container *ngIf="data.teamMember; then thenyBlock; else elseyBlock"></ng-container>`,
`<ng-template #thenyBlock>`,
` <h2>Then content</h2>`,
`</ng-template>`,
`<ng-template #elseyBlock>`,
` <h2>Else content</h2>`,
`</ng-template>`,
].join('\n'),
);
await runMigration();
const content = tree.readContent('/comp.html');
expect(content).toBe(
[
`@if (data.teamMember) {`,
` <h2>Then content</h2>`,
`} @else {`,
` <h2>Else content</h2>`,
`}\n`,
].join('\n'),
);
});
it('should migrate an if case on a container', async () => {
writeFile(
'/comp.ts',