File

libs/nx/src/migrations/utils/template-migration.ts

Description

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.

Index

Properties

Properties

members
Type Set<string>
Description

All signal-query member names on the component (all read-only).

plural
Type Set<string>
Description

The subset that are plural queries (readonly-array signals).

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);
}

results matching ""

    No results matching ""