File

libs/nx/src/migrations/update-dynamic-form-field-signals/template-migration.ts

Description

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.

Index

Properties

Properties

readonly
Type Set<string>
writable
Type Set<string>
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);
}

results matching ""

    No results matching ""