libs/nx/src/migrations/update-dynamic-form-field-signals/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,
parseTemplate,
PropertyRead,
RecursiveAstVisitor,
ThisReceiver,
TmplAstBoundAttribute,
TmplAstBoundEvent,
TmplAstBoundText,
TmplAstForLoopBlock,
TmplAstIfBlock,
TmplAstLetDeclaration,
TmplAstRecursiveVisitor,
TmplAstSwitchBlock,
TmplAstTemplate
} from '@angular/compiler';
import MagicString from 'magic-string';
/**
* Describes which members of the component backing a template became signals.
* Mirrors the TypeScript-side distinction:
*
* - `writable` members became `signal(...)` — reads unwrap to `x()`, writes
* convert to `x.set(v)`.
* - `readonly` members became `computed(...)` — reads unwrap to `x()`, but writes
* cannot be migrated (a computed has no setter) and are left untouched.
*/
export interface TemplateMemberSet {
writable: Set<string>;
readonly: 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[];
}
// Human-readable reasons for template skips.
const TEMPLATE_MANUAL_REASONS = {
computedWrite:
'assignment to a computed (read-only) signal — a computed has no setter; resolve manually',
compoundAssignment:
'compound assignment to a signal — read, combine, then call `.set(...)` manually',
twoWayBinding:
'two-way binding to a signal — use separate [value]/(valueChange) bindings manually'
} as const;
/**
* A single rewrite to apply to the template source.
*
* - `read` — unwrap a signal read: insert `()` after the member name.
* - `write` — convert `member = rhs` into `member.set(rhs)` by replacing the
* ` = ` operator gap with `.set(` and appending `)` after the right-hand side.
* Reads inside the RHS are emitted as their own `read` patches, so a write and
* its RHS reads compose (`minDate = maxDate` → `minDate.set(maxDate())`).
*/
type TemplatePatch =
| { kind: 'read'; readEnd: number }
| { kind: 'write'; gapStart: number; gapEnd: number; closeAt: number };
// Methods that only exist on a signal. A member access immediately followed by
// one of these was already migrated and must be left as-is (idempotency).
const SIGNAL_METHODS = new Set(['set', 'update', 'asReadonly']);
// 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/writes that need
* rewriting. Only accesses whose receiver is the component instance (the implicit
* receiver or `this`) and whose name is a migrated member — and is not currently
* shadowed by a template-local — are collected.
*/
class ExpressionVisitor extends RecursiveAstVisitor {
constructor(
private readonly members: TemplateMemberSet,
private readonly allMembers: Set<string>,
private readonly getScopedLocals: () => string[],
private readonly patches: TemplatePatch[],
private readonly manualOffsets: ManualOffset[]
) {
super();
}
override visitBinary(ast: Binary, context: unknown): void {
// An assignment to a target member (`member = rhs`, `member += rhs`, …). The
// LHS must never be unwrapped as a read — that would produce `member() = rhs`.
if (
isAssignment(ast.operation) &&
ast.left instanceof PropertyRead &&
this.isTargetRead(ast.left)
) {
// Only a plain `=` to a *writable* signal can be mechanically rewritten to
// `.set(...)`. Compound assignments (`+=`, `??=`, …) and writes to computed
// members have no safe rewrite, so we leave the LHS untouched and report it.
if (ast.operation === '=' && this.members.writable.has(ast.left.name)) {
this.patches.push({
kind: 'write',
gapStart: ast.left.sourceSpan.end,
gapEnd: ast.right.sourceSpan.start,
closeAt: ast.right.sourceSpan.end
});
} else {
this.manualOffsets.push({
start: ast.sourceSpan.start,
end: ast.sourceSpan.end,
reason:
ast.operation === '='
? TEMPLATE_MANUAL_REASONS.computedWrite
: TEMPLATE_MANUAL_REASONS.compoundAssignment
});
}
// Migrate reads inside the right-hand side regardless of the LHS decision.
ast.right.visit(this, context);
return;
}
super.visitBinary(ast, context);
}
override visitPropertyRead(ast: PropertyRead, context: unknown): void {
// Idempotency: `member.set(...)` / `.update(...)` / `.asReadonly()` — the
// receiver is an already-migrated signal, so don't unwrap it as a fresh read
// (which would produce `member().set(...)`). Still descend past it so any
// deeper reads are handled.
if (
SIGNAL_METHODS.has(ast.name) &&
ast.receiver instanceof PropertyRead &&
this.isTargetRead(ast.receiver)
) {
ast.receiver.receiver.visit(this, context);
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({ kind: 'read', readEnd: ast.nameSpan.end });
}
}
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.allMembers.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 allMembers: Set<string>,
private readonly patches: TemplatePatch[],
private readonly manualOffsets: ManualOffset[]
) {
super();
}
override visitBoundText(text: TmplAstBoundText): void {
this.walkExpression(text.value);
}
override visitBoundAttribute(attribute: TmplAstBoundAttribute): void {
// Two-way bindings (`[(x)]="member"`) have no mechanical rewrite to a signal
// call, so we skip them and report them for manual review.
if (isTwoWayBinding(attribute)) {
this.reportTwoWay(attribute);
return;
}
this.walkExpression(attribute.value);
}
override visitBoundEvent(event: TmplAstBoundEvent): void {
// A two-way binding (`[(x)]="member"`) synthesizes a change event whose
// handler is the bare member; it has no mechanical signal rewrite, so skip it
// (the paired input is reported in visitBoundAttribute).
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) {
if (isTwoWayBinding(input)) this.reportTwoWay(input);
else this.walkExpression(input.value);
}
for (const output of template.outputs) this.walkExpression(output.handler);
template.children.forEach((child) => child.visit(this));
});
}
/** Reports a two-way binding whose bound expression targets a migrated member. */
private reportTwoWay(attribute: TmplAstBoundAttribute): void {
const ast = attribute.value instanceof ASTWithSource ? attribute.value.ast : attribute.value;
if (ast instanceof PropertyRead && this.isTargetRead(ast)) {
this.manualOffsets.push({
start: attribute.sourceSpan.start.offset,
end: attribute.sourceSpan.end.offset,
reason: TEMPLATE_MANUAL_REASONS.twoWayBinding
});
}
}
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));
});
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);
}
group.children.forEach((child) => child.visit(this));
}
}
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.allMembers.has(declaration.name)) this.scopedLocals.push(declaration.name);
}
private withLocals(names: string[], run: () => void): void {
const active = names.filter((name) => this.allMembers.has(name));
active.forEach((name) => this.scopedLocals.push(name));
try {
run();
} finally {
active.forEach(() => this.scopedLocals.pop());
}
}
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.allMembers,
() => this.scopedLocals,
this.patches,
this.manualOffsets
)
);
}
/** 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.allMembers.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 member reads/writes 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. The transform is idempotent: an already migrated
* `x()` / `x.set(v)` 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
): TemplateMigrationResult {
const empty: TemplateMigrationResult = { text: null, manualItems: [] };
const allMembers = new Set([...members.writable, ...members.readonly]);
if (allMembers.size === 0) return empty;
// Cheap pre-filter: skip templates that don't even mention a target member.
if (![...allMembers].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.
if (parsed.errors && parsed.errors.length > 0) return empty;
const patches: TemplatePatch[] = [];
const manualOffsets: ManualOffset[] = [];
const visitor = new TemplateReferenceVisitor(members, allMembers, patches, manualOffsets);
parsed.nodes.forEach((node) => node.visit(visitor));
const manualItems = resolveManualItems(templateText, manualOffsets);
const text = patches.length === 0 ? null : applyPatches(templateText, patches);
return { text, manualItems };
}
/** 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 patches to the template text. Point insertions and the
* write operator-gap overwrite are order-independent thanks to magic-string's
* `appendLeft` / `appendRight` 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 appliedReads = new Set<number>();
const appliedWrites = new Set<number>();
for (const patch of patches) {
if (patch.kind === 'read') {
if (appliedReads.has(patch.readEnd)) continue;
appliedReads.add(patch.readEnd);
// appendLeft so it sits before a write's closing `)` appended via appendRight.
magic.appendLeft(patch.readEnd, '()');
} else {
if (appliedWrites.has(patch.gapStart)) continue;
appliedWrites.add(patch.gapStart);
// ` = ` -> `.set(` , then `)` after the RHS. appendRight keeps the closing
// paren after any RHS read-unwrap appended at the same offset.
magic.overwrite(patch.gapStart, patch.gapEnd, '.set(');
magic.appendRight(patch.closeAt, ')');
}
}
return magic.toString();
}
/** 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);
}