libs/nx/src/migrations/utils/template-migration.ts
Result of migrateTemplate: the new text (or null if unchanged) + skips.
Properties |
| manualItems | |
| Type |
TemplateManualItem[]
|
| text | |
| Type |
string | null
|
import {
AST,
ASTWithSource,
Binary,
Call,
ImplicitReceiver,
NonNullAssert,
ParseError,
parseTemplate,
PropertyRead,
RecursiveAstVisitor,
SafePropertyRead,
ThisReceiver,
TmplAstBoundAttribute,
TmplAstBoundDeferredTrigger,
TmplAstBoundEvent,
TmplAstBoundText,
TmplAstDeferredBlock,
TmplAstDeferredBlockTriggers,
TmplAstForLoopBlock,
TmplAstIfBlock,
TmplAstLetDeclaration,
TmplAstRecursiveVisitor,
TmplAstSwitchBlock,
TmplAstTemplate
} from '@angular/compiler';
import MagicString from 'magic-string';
import { queryListOnlyReason } from './query-list-members';
/**
* Describes the signal-query members of the component backing a template.
*
* Every signal query (`viewChild`/`viewChildren`/`contentChild`/`contentChildren`)
* is a *read-only* getter signal, so — unlike the writable/computed split of the
* Dynamic-Form-field migration — there is no writable set here: reads unwrap to
* `x()` and any write is reported for manual review.
*
* `plural` is the subset backed by `viewChildren`/`contentChildren`, which return a
* `Signal<readonly T[]>`. A plural read followed by a `QueryList`-only member
* (`.first`, `.changes`, …) has no clean unwrap and is reported instead of rewritten.
*/
export interface TemplateMemberSet {
/** All signal-query member names on the component (all read-only). */
members: Set<string>;
/** The subset that are plural queries (readonly-array signals). */
plural: Set<string>;
}
/**
* A binding the template migration left untouched because it has no safe mechanical
* rewrite. `line` is 1-based within the template text (the caller maps it onto the
* containing file).
*/
export interface TemplateManualItem {
line: number;
snippet: string;
reason: string;
}
/** Result of {@link migrateTemplate}: the new text (or null if unchanged) + skips. */
export interface TemplateMigrationResult {
text: string | null;
manualItems: TemplateManualItem[];
}
/** Singular/plural noun for the human-readable manual-review reasons below. */
export interface MemberKindLabel {
singular: string;
plural: string;
}
const DEFAULT_MEMBER_KIND_LABEL: MemberKindLabel = {
singular: 'signal query',
plural: 'signal queries'
};
/** Human-readable reasons for template skips, worded for the given member kind. */
function buildTemplateManualReasons(memberKindLabel: MemberKindLabel) {
return {
readonlyWrite:
`assignment to a ${memberKindLabel.singular} — ${memberKindLabel.plural} are read-only ` +
'(no setter); resolve manually',
twoWayBinding:
`two-way binding to a ${memberKindLabel.singular} — ${memberKindLabel.plural} are ` +
'read-only; bind differently and update the source manually'
} as const;
}
/** Upper bound for a reported parse message; Angular's can carry a long trailing URL. */
const MAX_PARSE_MESSAGE_LENGTH = 160;
/**
* A single read rewrite to apply to the template source: unwrap a signal read by
* inserting `()` after the member name. Writes are never rewritten (queries are
* read-only), so there is no write patch kind.
*/
interface TemplatePatch {
readEnd: number;
}
// Comparison operators also end in `=` but are not assignments.
const COMPARISON_OPERATORS = new Set(['==', '===', '!=', '!==', '<=', '>=']);
/**
* Walks a single binding expression's AST and records the reads that need
* unwrapping. Only accesses whose receiver is the component instance (the implicit
* receiver or `this`) and whose name is a signal-query member — and is not currently
* shadowed by a template-local — are collected.
*/
class ExpressionVisitor extends RecursiveAstVisitor {
constructor(
private readonly members: TemplateMemberSet,
private readonly getScopedLocals: () => string[],
private readonly patches: TemplatePatch[],
private readonly manualOffsets: ManualOffset[],
private readonly manualReasons: ReturnType<typeof buildTemplateManualReasons>
) {
super();
}
override visitBinary(ast: Binary, context: unknown): void {
// An assignment to a member (`member = rhs`, `member += rhs`, …). The member is
// read-only, so there is no mechanical rewrite: leave the LHS untouched and report
// it. The LHS must never be unwrapped as a read (`member() = rhs`).
if (
isAssignment(ast.operation) &&
ast.left instanceof PropertyRead &&
this.isTargetRead(ast.left)
) {
this.manualOffsets.push({
start: ast.sourceSpan.start,
end: ast.sourceSpan.end,
reason: this.manualReasons.readonlyWrite
});
// Still migrate reads inside the right-hand side.
ast.right.visit(this, context);
return;
}
super.visitBinary(ast, context);
}
override visitPropertyRead(ast: PropertyRead, context: unknown): void {
// `member.<x>` where `member` is a *plural* query and `<x>` is a `QueryList`-only
// member (`.first`, `.changes`, …). Unwrapping to `member().first` would still be
// a type error, so leave it untouched and report the precise fix. Do NOT descend
// into the receiver (that would unwrap `member`). A non-null assertion
// (`member!.first`) is looked through so it is reported too.
const receiver = unwrapNonNullAssert(ast.receiver);
if (receiver instanceof PropertyRead && this.isTargetRead(receiver)) {
const reason = this.members.plural.has(receiver.name) && queryListOnlyReason(ast.name);
if (reason) {
this.manualOffsets.push({
start: ast.receiver.sourceSpan.start,
end: ast.sourceSpan.end,
reason
});
return;
}
}
// Visit the receiver first so nested reads (`a.b.member`) are handled, then
// decide about this node.
ast.receiver.visit(this, context);
if (this.isTargetRead(ast)) {
this.patches.push({ readEnd: ast.nameSpan.end });
}
}
override visitSafePropertyRead(ast: SafePropertyRead, context: unknown): void {
// Safe-navigation counterpart of the plural/`QueryList`-only guard in
// visitPropertyRead: `member?.first` parses as a SafePropertyRead whose receiver
// is the plural query read. Unwrapping to `member()?.first` would still be a type
// error (`.first` does not exist on a readonly array), so leave it untouched and
// report the precise fix. This also covers safe method calls (`member?.get(i)` /
// `member?.toArray()`) and a non-null-asserted receiver (`member!?.first`). Do NOT
// descend into the receiver (that would unwrap `member`).
const receiver = unwrapNonNullAssert(ast.receiver);
if (receiver instanceof PropertyRead && this.isTargetRead(receiver)) {
const reason = this.members.plural.has(receiver.name) && queryListOnlyReason(ast.name);
if (reason) {
this.manualOffsets.push({
start: ast.receiver.sourceSpan.start,
end: ast.sourceSpan.end,
reason
});
return;
}
}
// Array-compatible or singular member (`member?.length`, `member?.value`): unwrap
// the receiver read exactly like the non-safe form.
ast.receiver.visit(this, context);
}
override visitCall(ast: Call, context: unknown): void {
// Idempotency: an already-migrated read is a Call whose receiver is the target
// PropertyRead (`member()`). Don't treat that inner read as a fresh read (which
// would produce `member()()`); just recurse into the arguments and any deeper
// receiver so real reads there are still handled.
if (ast.receiver instanceof PropertyRead && this.isTargetRead(ast.receiver)) {
ast.receiver.receiver.visit(this, context);
ast.args.forEach((arg) => arg.visit(this, context));
return;
}
super.visitCall(ast, context);
}
private isTargetRead(ast: PropertyRead): boolean {
// The component instance is the implicit receiver (`member`) or `this.member`.
const onComponentInstance =
ast.receiver instanceof ImplicitReceiver || ast.receiver instanceof ThisReceiver;
if (!onComponentInstance) return false;
if (!this.members.members.has(ast.name)) return false;
// Shadowed by a template-local of the same name → not the component member.
return !this.getScopedLocals().includes(ast.name);
}
}
/**
* Walks the template (`TmplAst*`) node tree, descending into every place an
* expression can live — including structural directives and control-flow blocks,
* which the default recursive visitor does not enter — and hands each expression
* to the {@link ExpressionVisitor}.
*
* It also tracks template-local variable names in scope so accesses to a shadowed
* name are ignored.
*/
class TemplateReferenceVisitor extends TmplAstRecursiveVisitor {
private readonly scopedLocals: string[] = [];
constructor(
private readonly members: TemplateMemberSet,
private readonly patches: TemplatePatch[],
private readonly manualOffsets: ManualOffset[],
private readonly manualReasons: ReturnType<typeof buildTemplateManualReasons>
) {
super();
}
override visitBoundText(text: TmplAstBoundText): void {
this.walkExpression(text.value);
}
override visitBoundAttribute(attribute: TmplAstBoundAttribute): void {
this.handleBoundAttribute(attribute);
}
override visitBoundEvent(event: TmplAstBoundEvent): void {
// A two-way binding (`[(x)]="member"`) synthesizes a change event whose handler
// is the bare member; the paired input is reported in visitBoundAttribute, so
// skip the synthesized event.
if (isTwoWayEvent(event)) return;
this.walkExpression(event.handler);
}
override visitTemplate(template: TmplAstTemplate): void {
// Structural directives (`*ngFor`, `*ngIf`) desugar onto a Template node.
// The bound expressions live in `templateAttrs`; local bindings that shadow
// component members live in `variables` / `references`.
const introduced = [
...template.variables.map((variable) => variable.name),
...template.references.map((reference) => reference.name)
];
this.withLocals(introduced, () => {
for (const attribute of template.templateAttrs) {
if (attribute instanceof TmplAstBoundAttribute) this.walkExpression(attribute.value);
}
for (const input of template.inputs) {
this.handleBoundAttribute(input);
}
// Via visitBoundEvent so a two-way binding's synthesized change event is skipped
// here (`<ng-template [(x)]="member">`, `<input *ngIf [(ngModel)]="member">`) as
// it is on a plain element.
for (const output of template.outputs) this.visitBoundEvent(output);
template.children.forEach((child) => child.visit(this));
});
}
/**
* Handles a bound attribute, dispatching on whether it is a two-way binding.
*
* A one-way binding just migrates its expression. A two-way binding needs care:
* - `[(x)]="member"` — the bound target IS the query member. A signal query is
* read-only, so this cannot be rewritten; report it and do not unwrap. A non-null
* assertion (`[(x)]="member!"`) is looked through: it does not make the target
* writable, so `member()!` would be just as broken a write.
* - `[(x)]="member.prop"` — the target is a *sub-property* of the member. The
* member itself is still read (`member.prop` reads `member` then writes `.prop`),
* so the `member` read must be unwrapped to `member()` exactly like a one-way
* binding; only the bare-member case is unrewritable.
*/
private handleBoundAttribute(attribute: TmplAstBoundAttribute): void {
if (!isTwoWayBinding(attribute)) {
this.walkExpression(attribute.value);
return;
}
const ast = attribute.value instanceof ASTWithSource ? attribute.value.ast : attribute.value;
// Bare member target (`[(x)]="member"` / `[(x)]="member!"`) — read-only, report and
// leave untouched.
const target = unwrapNonNullAssert(ast);
if (target instanceof PropertyRead && this.isTargetRead(target)) {
this.manualOffsets.push({
start: attribute.sourceSpan.start.offset,
end: attribute.sourceSpan.end.offset,
reason: this.manualReasons.twoWayBinding
});
return;
}
// Sub-property target (`[(x)]="member.prop"`) — the member is read, so unwrap it.
this.walkExpression(attribute.value);
}
override visitForLoopBlock(block: TmplAstForLoopBlock): void {
// `@for (item of expr; track ...)` — `expr` is evaluated in the outer scope,
// but `item` and the implicit context variables shadow members in the body.
this.walkExpression(block.expression);
const introduced = [
block.item.name,
...block.contextVariables.map((variable) => variable.name)
];
this.withLocals(introduced, () => {
this.walkExpression(block.trackBy);
block.children.forEach((child) => child.visit(this));
});
// The `@empty` block is a sibling view (the loop variables are not in scope), but
// it can still declare its own `@let`, so give it an isolated scope.
if (block.empty) this.withScope(() => block.empty?.visit(this));
}
override visitIfBlock(block: TmplAstIfBlock): void {
for (const branch of block.branches) {
if (branch.expression) this.walkExpression(branch.expression);
// `@if (expr; as alias)` binds `alias` inside the branch body.
const introduced = branch.expressionAlias ? [branch.expressionAlias.name] : [];
this.withLocals(introduced, () => {
branch.children.forEach((child) => child.visit(this));
});
}
}
override visitSwitchBlock(block: TmplAstSwitchBlock): void {
this.walkExpression(block.expression);
// `@case` expressions live under `groups[].cases`; their bodies under
// `groups[].children`.
for (const group of block.groups) {
for (const switchCase of group.cases) {
if (switchCase.expression) this.walkExpression(switchCase.expression);
}
// Each `@case` / `@default` body is its own view: a `@let` declared inside it
// is scoped to that body and must not leak to sibling cases or later nodes.
this.withScope(() => group.children.forEach((child) => child.visit(this)));
}
}
override visitDeferredBlock(block: TmplAstDeferredBlock): void {
// `@defer (when expr; prefetch when expr; hydrate when expr)` — the base recursive
// visitor treats triggers as no-ops, so a `when`/`prefetch when`/`hydrate when`
// expression referencing a query member is never unwrapped. Since the member became
// a getter *function*, an un-unwrapped trigger is always truthy (the block loads
// eagerly), so we must walk each bound trigger's expression here.
this.walkDeferredTriggers(block.triggers);
this.walkDeferredTriggers(block.prefetchTriggers);
this.walkDeferredTriggers(block.hydrateTriggers);
// The main block, and each of `@placeholder`/`@loading`/`@error`, is its own view:
// a `@let` declared in one must not leak to the others or to later top-level nodes.
// The base visitor walks them in a shared scope, so isolate each with `withScope`.
this.withScope(() => block.children.forEach((child) => child.visit(this)));
if (block.placeholder) this.withScope(() => block.placeholder?.visit(this));
if (block.loading) this.withScope(() => block.loading?.visit(this));
if (block.error) this.withScope(() => block.error?.visit(this));
}
/** Walks the `when` expression of every bound trigger in a trigger group. */
private walkDeferredTriggers(triggers: TmplAstDeferredBlockTriggers): void {
// Only `when` (a BoundDeferredTrigger) carries a bindable expression; `on`
// triggers (idle/timer/hover/…) never reference component members.
const when: TmplAstBoundDeferredTrigger | undefined = triggers.when;
if (when) this.walkExpression(when.value);
}
override visitLetDeclaration(declaration: TmplAstLetDeclaration): void {
// The `@let name = value;` initializer is evaluated before `name` is bound,
// so migrate the value first, then treat `name` as a local for later nodes.
this.walkExpression(declaration.value);
if (this.members.members.has(declaration.name)) this.scopedLocals.push(declaration.name);
}
/**
* Runs `run()` in a nested variable scope. Any template-local pushed onto
* `scopedLocals` during the call — including a `@let` declared *inside* the block —
* is removed when it returns, because a block-scoped local is not visible after its
* containing block closes. Truncating to the saved depth (rather than popping a
* fixed count) is what makes `@let` scoping correct: `visitLetDeclaration` pushes
* onto the shared stack, so without this the shadow would leak to later siblings.
*/
private withScope(run: () => void): void {
const savedDepth = this.scopedLocals.length;
try {
run();
} finally {
this.scopedLocals.length = savedDepth;
}
}
private withLocals(names: string[], run: () => void): void {
this.withScope(() => {
for (const name of names) {
if (this.members.members.has(name)) this.scopedLocals.push(name);
}
run();
});
}
private walkExpression(value: AST | null | undefined): void {
if (!value) return;
const ast = value instanceof ASTWithSource ? value.ast : value;
if (!ast) return;
ast.visit(
new ExpressionVisitor(
this.members,
() => this.scopedLocals,
this.patches,
this.manualOffsets,
this.manualReasons
)
);
}
/** Mirror of ExpressionVisitor.isTargetRead, used for two-way binding detection. */
private isTargetRead(ast: PropertyRead): boolean {
const onComponentInstance =
ast.receiver instanceof ImplicitReceiver || ast.receiver instanceof ThisReceiver;
if (!onComponentInstance) return false;
if (!this.members.members.has(ast.name)) return false;
return !this.scopedLocals.includes(ast.name);
}
}
/** An un-migratable span collected during the walk (offsets into the template text). */
interface ManualOffset {
start: number;
end: number;
reason: string;
}
/**
* Rewrites signal-query member reads inside an Angular template.
*
* Returns the migrated template text (`null` when nothing changed, so callers can
* avoid rewriting untouched files) together with any bindings that could not be
* migrated automatically — including the whole template when Angular cannot parse it.
* The transform is idempotent: an already migrated `x()` is left as-is.
*
* Only accesses on the component instance are touched — reads on template-local
* variables (`*ngFor="let x of ..."`, `@for`, `@if ... as x`, `@let x = ...`,
* template references) shadow the component member and are skipped, the template
* analogue of the subclass member-shadowing rule on the TypeScript side.
*/
export function migrateTemplate(
templateText: string,
members: TemplateMemberSet,
memberKindLabel: MemberKindLabel = DEFAULT_MEMBER_KIND_LABEL
): TemplateMigrationResult {
const empty: TemplateMigrationResult = { text: null, manualItems: [] };
if (members.members.size === 0) return empty;
// Cheap pre-filter: skip templates that don't even mention a target member.
if (![...members.members].some((member) => templateText.includes(member))) return empty;
const parsed = parseTemplate(templateText, 'template.html', {
// Keep the source text byte-for-byte so AST spans line up with the raw text
// we are patching (no whitespace collapsing, no line-ending normalization).
preserveWhitespaces: true,
preserveLineEndings: true
});
// If Angular can't parse the template, don't guess — leave it untouched. Silence
// would be dangerous here: the template *does* mention a target member (the
// pre-filter above), and an un-unwrapped read is a getter function, so it silently
// evaluates as always-truthy rather than failing loudly. Report it instead, with
// Angular's own diagnostic so it reads as a template problem, not a migration bug.
if (parsed.errors && parsed.errors.length > 0) {
return { text: null, manualItems: [toParseErrorItem(parsed.errors[0])] };
}
const patches: TemplatePatch[] = [];
const manualOffsets: ManualOffset[] = [];
const manualReasons = buildTemplateManualReasons(memberKindLabel);
const visitor = new TemplateReferenceVisitor(members, patches, manualOffsets, manualReasons);
parsed.nodes.forEach((node) => node.visit(visitor));
const manualItems = resolveManualItems(templateText, manualOffsets);
const text = patches.length === 0 ? null : applyPatches(templateText, patches);
return { text, manualItems };
}
/**
* True when `templateText` contains the name of at least one member in `members`.
* Flags an inline `template:` authored as a *substitution* template literal
* (`` `<h1>${x}</h1> {{ id }}` ``) — {@link getStringLikeInitializer} callers can't
* pass it to {@link migrateTemplate} (it isn't a plain string), so a mention must be
* surfaced as a manual-review item instead of silently skipped.
*/
export function templateTextMentionsMember(templateText: string, members: Set<string>): boolean {
return [...members].some((member) => templateText.includes(member));
}
/**
* Turns an Angular parse error into a manual-review item. Only the first error of a
* template is reported: the later ones are usually cascade noise from the same root
* cause (one unescaped `{` yields both an "unexpected EOF" and an "invalid ICU").
*/
function toParseErrorItem(error: ParseError): TemplateManualItem {
const message = error.msg.replace(/\s+/g, ' ').trim();
const truncated =
message.length > MAX_PARSE_MESSAGE_LENGTH
? `${message.slice(0, MAX_PARSE_MESSAGE_LENGTH - 1)}…`
: message;
// Angular can point at the exact offset; surround it with a little source context so
// the line number is actionable even after the file is edited.
const context = error.span.start.getContext(30, 1);
const snippet = context
? `${context.before}${context.after}`.replace(/\s+/g, ' ').trim()
: error.span.start.file.content.split('\n')[error.span.start.line]?.trim() ?? '';
return {
// `ParseLocation.line` is 0-based; manual-review items are 1-based.
line: error.span.start.line + 1,
snippet,
reason: `template could not be parsed, so it was left untouched — Angular reported: ${truncated}`
};
}
/** Converts raw offset ranges into 1-based line numbers + single-line snippets. */
function resolveManualItems(templateText: string, offsets: ManualOffset[]): TemplateManualItem[] {
// De-duplicate by start offset (a node can be reached more than once).
const seen = new Set<number>();
const items: TemplateManualItem[] = [];
for (const offset of offsets) {
if (seen.has(offset.start)) continue;
seen.add(offset.start);
const line = templateText.slice(0, offset.start).split('\n').length;
const snippet = templateText.slice(offset.start, offset.end).replace(/\s+/g, ' ').trim();
items.push({ line, snippet, reason: offset.reason });
}
return items.sort((a, b) => a.line - b.line);
}
/**
* Applies the collected read patches to the template text. Point insertions are
* order-independent thanks to magic-string's `appendLeft` semantics, so no manual
* right-to-left sort is needed; we only de-duplicate anchors reached more than once.
*/
function applyPatches(templateText: string, patches: TemplatePatch[]): string {
const magic = new MagicString(templateText);
const applied = new Set<number>();
for (const patch of patches) {
if (applied.has(patch.readEnd)) continue;
applied.add(patch.readEnd);
magic.appendLeft(patch.readEnd, '()');
}
return magic.toString();
}
/** Unwraps a non-null assertion (`member!`) to the underlying expression. */
function unwrapNonNullAssert(ast: AST): AST {
return ast instanceof NonNullAssert ? ast.expression : ast;
}
/** True for a two-way binding attribute (`[(member)]="..."`). */
function isTwoWayBinding(attribute: TmplAstBoundAttribute): boolean {
// BindingType.TwoWay === 5; compare numerically to avoid importing the enum.
return (attribute.type as unknown as number) === 5;
}
/** True for the change event synthesized by a two-way binding. */
function isTwoWayEvent(event: TmplAstBoundEvent): boolean {
// ParsedEventType.TwoWay === 2; compare numerically to avoid importing the enum.
return (event.type as unknown as number) === 2;
}
/**
* True for an assignment operator (`=`, `+=`, `??=`, …). Angular parses these as
* `Binary` nodes, so we distinguish them from comparisons that also end in `=`.
*/
function isAssignment(operation: string): boolean {
return operation.endsWith('=') && !COMPARISON_OPERATORS.has(operation);
}