libs/ngx-pfe/schematics/ng-update/v51-input-signal-reads/template-migration.ts

Index

Properties

Properties

line
line: number
Type : number

1-based within the template text; the caller maps it onto the containing file.

reason
reason: string
Type : string
snippet
snippet: string
Type : string
import {
  AST,
  ASTWithSource,
  Binary,
  Call,
  ImplicitReceiver,
  ParseError,
  parseTemplate,
  PropertyRead,
  RecursiveAstVisitor,
  ThisReceiver,
  TmplAstBoundAttribute,
  TmplAstBoundDeferredTrigger,
  TmplAstBoundEvent,
  TmplAstBoundText,
  TmplAstDeferredBlock,
  TmplAstDeferredBlockTriggers,
  TmplAstElement,
  TmplAstForLoopBlock,
  TmplAstIfBlock,
  TmplAstLetDeclaration,
  TmplAstNode,
  TmplAstRecursiveVisitor,
  TmplAstSwitchBlock,
  TmplAstTemplate,
} from '@angular/compiler';
import MagicString from 'magic-string';
import { ALL_MEMBER_NAMES, FUNCTION_VALUED_MEMBERS, MEMBERS_BY_SELECTOR } from './input-signal-targets';
import { toSnippet } from './manual-review';

export interface TemplateMemberSet {
  members: Set<string>;
}

export interface TemplateManualItem {
  /** 1-based within the template text; the caller maps it onto the containing file. */
  line: number;
  snippet: string;
  reason: string;
}

export interface TemplateMigrationResult {
  text: string | null;
  manualItems: TemplateManualItem[];
}

const TEMPLATE_MANUAL_REASONS = {
  readonlyWrite: 'assignment to a signal input — signal inputs are read-only (no setter); resolve manually',
  twoWayBinding: 'two-way binding to a signal input — signal inputs are read-only; bind the input one-way and emit the change back instead',
} as const;

/** Angular's parse messages can carry a long trailing URL. */
const MAX_PARSE_MESSAGE_LENGTH = 160;

/** Insert `()` after a member name. Writes are never rewritten, so there is no write patch. */
interface TemplatePatch {
  readEnd: number;
}

// Comparison operators also end in `=` but are not assignments.
const COMPARISON_OPERATORS = new Set(['==', '===', '!=', '!==', '<=', '>=']);

/**
 * Records the reads of a single binding expression that need unwrapping: reads on the component
 * instance (implicit receiver or `this`) whose name is a host member, and reads through a
 * template reference variable bound to a target element (`<pfe-error-message #err>{{ err.message }}`).
 */
class ExpressionVisitor extends RecursiveAstVisitor {
  constructor(
    private readonly members: TemplateMemberSet,
    private readonly refVarMembers: ReadonlyMap<string, ReadonlySet<string>>,
    private readonly getScopedLocals: () => string[],
    private readonly patches: TemplatePatch[],
    private readonly manualOffsets: ManualOffset[]
  ) {
    super();
  }

  override visitBinary(ast: Binary, context: unknown): void {
    // The LHS of an assignment must never be unwrapped as a read (`member() = rhs`).
    if (isAssignment(ast.operation) && ast.left instanceof PropertyRead && (this.isTargetRead(ast.left) || this.isRefVarRead(ast.left))) {
      this.manualOffsets.push({
        start: ast.sourceSpan.start,
        end: ast.sourceSpan.end,
        reason: TEMPLATE_MANUAL_REASONS.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 {
    // A ref-var read (`err.message`) is itself a PropertyRead whose receiver is the ref-var read
    // (`err`). Handle it before descending, so that receiver is not visited as a candidate read.
    if (this.isRefVarRead(ast)) {
      this.patches.push({ readEnd: ast.nameSpan.end });
      return;
    }

    // Receiver first, so nested reads (`a.b.member`) are handled.
    ast.receiver.visit(this, context);
    if (this.isTargetRead(ast)) {
      this.patches.push({ readEnd: ast.nameSpan.end });
    }
  }

  override visitCall(ast: Call, context: unknown): void {
    // An already-migrated read is a Call whose receiver is the target PropertyRead (`member()`);
    // don't unwrap that inner read again. A function-valued input is deliberately excluded: the
    // old code already called it to invoke the callback (`nxNextAction()`), and after the
    // migration reading the signal and invoking the value are two calls (`nxNextAction()()`).
    // An existing `()` therefore does NOT mean "already migrated" for those members, which is
    // why this migration is not idempotent.
    if (ast.receiver instanceof PropertyRead && !FUNCTION_VALUED_MEMBERS.has(ast.receiver.name)) {
      if (this.isTargetRead(ast.receiver)) {
        ast.receiver.receiver.visit(this, context);
        ast.args.forEach((arg) => arg.visit(this, context));
        return;
      }
      if (this.isRefVarRead(ast.receiver)) {
        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);
  }

  private isRefVarRead(ast: PropertyRead): boolean {
    if (!(ast.receiver instanceof PropertyRead)) return false;
    const receiver = ast.receiver;
    // The ref var itself is read off the component instance (implicit receiver).
    if (!(receiver.receiver instanceof ImplicitReceiver || receiver.receiver instanceof ThisReceiver)) {
      return false;
    }
    if (this.getScopedLocals().includes(receiver.name)) return false;
    return this.refVarMembers.get(receiver.name)?.has(ast.name) ?? false;
  }
}

/**
 * Walks the `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 tracks the template-locals in scope so shadowed names are ignored.
 */
class TemplateReferenceVisitor extends TmplAstRecursiveVisitor {
  private readonly scopedLocals: string[] = [];

  constructor(
    private readonly members: TemplateMemberSet,
    private readonly refVarMembers: ReadonlyMap<string, ReadonlySet<string>>,
    private readonly patches: TemplatePatch[],
    private readonly manualOffsets: ManualOffset[]
  ) {
    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 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);
      }
      // Route inputs/outputs through `visit` rather than walking their expressions directly, so
      // the two-way-binding guards apply here too: walking `outputs` directly would migrate the
      // bare member in the synthesized change event of `<input *ngIf="c" [(ngModel)]="message">`.
      for (const input of template.inputs) input.visit(this);
      for (const output of template.outputs) output.visit(this);
      template.children.forEach((child) => child.visit(this));
    });
  }

  /**
   * `[message]="expr"` migrates the value side only — a signal input is bound exactly like a
   * decorator input, so an attribute *name* is never rewritten. A two-way binding splits:
   * `[(x)]="member"` targets the read-only input itself and is only reported, while
   * `[(x)]="member.prop"` targets a sub-property, so `member` is read and gets unwrapped.
   */
  private handleBoundAttribute(attribute: TmplAstBoundAttribute): void {
    if (!isTwoWayBinding(attribute)) {
      this.walkExpression(attribute.value);
      return;
    }

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

    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));
    });
    // `@empty` is a sibling view (no loop variables in scope) but can declare its own `@let`.
    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`.
    // Each body is its own view, so a `@let` in it must not leak to a sibling case.
    for (const group of block.groups) {
      for (const switchCase of group.cases) {
        if (switchCase.expression) this.walkExpression(switchCase.expression);
      }
      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` expression referencing an input member is never
    // unwrapped. Since the member became a getter *function*, an un-unwrapped trigger is always
    // truthy (the block loads eagerly).
    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, but the
    // base visitor walks them in a shared scope — so isolate each one.
    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));
  }

  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.
    this.walkExpression(declaration.value);
    if (this.isShadowable(declaration.name)) this.scopedLocals.push(declaration.name);
  }

  /**
   * 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 a
   * block-scoped `@let` 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.isShadowable(name)) this.scopedLocals.push(name);
      }
      run();
    });
  }

  private isShadowable(name: string): boolean {
    return this.members.members.has(name) || this.refVarMembers.has(name);
  }

  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.refVarMembers, () => 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.members.members.has(ast.name)) return false;
    return !this.scopedLocals.includes(ast.name);
  }
}

interface ManualOffset {
  start: number;
  end: number;
  reason: string;
}

/**
 * Rewrites signal-input member reads inside an Angular template (`null` when nothing changed).
 *
 * `hostMembers` are the inputs the backing component inherits from a target and drive the
 * implicit-receiver / `this.x` reads; it is empty for a plain consumer template, whose ref-var
 * reads resolve from the template itself.
 *
 * Reads on template-locals (`*ngFor="let x of ..."`, `@for`, `@if ... as x`, `@let x = ...`)
 * 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, hostMembers: TemplateMemberSet): TemplateMigrationResult {
  const empty: TemplateMigrationResult = { text: null, manualItems: [] };

  // A ref-var read can appear in any template, so the pre-filter checks every migrated name
  // rather than just the host's.
  if (![...ALL_MEMBER_NAMES].some((member) => templateText.includes(member))) return empty;

  const parsed = parseTemplate(templateText, 'template.html', {
    // Keep the source byte-for-byte, so AST spans line up with the raw text being patched.
    preserveWhitespaces: true,
    preserveLineEndings: true,
  });

  // The template does mention an input member (pre-filter above) and an un-unwrapped read is a
  // getter function, so it would silently evaluate as always-truthy — report instead.
  if (parsed.errors && parsed.errors.length > 0) {
    return { text: null, manualItems: [toParseErrorItem(parsed.errors[0])] };
  }

  const refVarMembers = collectTargetRefVars(parsed.nodes);
  if (hostMembers.members.size === 0 && refVarMembers.size === 0) return empty;

  const patches: TemplatePatch[] = [];
  const manualOffsets: ManualOffset[] = [];
  const visitor = new TemplateReferenceVisitor(hostMembers, refVarMembers, 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 };
}

/**
 * Maps each ref-var name bound to a target element onto that element's members —
 * `<pfe-error-message #err>` binds `err` to `{hideError, message, hideClose}`.
 *
 * The whole node tree is scanned up front (rather than per-scope) because a ref var is visible
 * to its siblings and their descendants, so a read can legitimately precede the declaration in
 * document order. A name declared on both a target and a non-target element is dropped: which
 * element a read resolves to depends on view scoping we do not model.
 */
function collectTargetRefVars(nodes: TmplAstNode[]): ReadonlyMap<string, ReadonlySet<string>> {
  const byName = new Map<string, ReadonlySet<string>>();
  const ambiguous = new Set<string>();

  const register = (tagName: string, references: { name: string; value: string }[]): void => {
    const members = MEMBERS_BY_SELECTOR.get(tagName);
    for (const reference of references) {
      // `#x="ngModel"` points at a directive's `exportAs`, not at the component instance.
      if (reference.value || !members) {
        ambiguous.add(reference.name);
        continue;
      }
      const existing = byName.get(reference.name);
      if (existing && existing !== members) ambiguous.add(reference.name);
      byName.set(reference.name, members);
    }
  };

  class RefVarVisitor extends TmplAstRecursiveVisitor {
    override visitElement(element: TmplAstElement): void {
      register(element.name, element.references);
      super.visitElement(element);
    }

    override visitTemplate(template: TmplAstTemplate): void {
      // A ref var on an `<ng-template>` binds the TemplateRef, not a component, so it is never a
      // target read — but it still has to be registered, so a same-named ref var on a real target
      // element becomes ambiguous rather than silently migrated. (A structural directive such as
      // `<pfe-error-message #err *ngIf>` keeps its ref var on the desugared inner element, which
      // `visitElement` already covers.)
      register(template.tagName ?? '', template.references);
      super.visitTemplate(template);
    }
  }

  const visitor = new RefVarVisitor();
  nodes.forEach((node) => node.visit(visitor));

  for (const name of ambiguous) byName.delete(name);
  return byName;
}

/**
 * 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 = toSnippet(error.msg);
  const truncated = message.length > MAX_PARSE_MESSAGE_LENGTH ? `${message.slice(0, MAX_PARSE_MESSAGE_LENGTH - 1)}…` : message;

  // Surround the error offset with source context, so the report stays actionable even after
  // the file is edited.
  const context = error.span.start.getContext(30, 1);
  const snippet = context
    ? toSnippet(`${context.before}${context.after}`)
    : (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}`,
  };
}

function resolveManualItems(templateText: string, offsets: ManualOffset[]): TemplateManualItem[] {
  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 = toSnippet(templateText.slice(offset.start, offset.end));
    items.push({ line, snippet, reason: offset.reason });
  }
  return items.sort((a, b) => a.line - b.line);
}

/** Point insertions are order-independent with `appendLeft`, so no right-to-left sort is needed. */
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();
}

function isTwoWayBinding(attribute: TmplAstBoundAttribute): boolean {
  // BindingType.TwoWay === 5; compare numerically to avoid importing the enum.
  return (attribute.type as unknown as number) === 5;
}

function isTwoWayEvent(event: TmplAstBoundEvent): boolean {
  // ParsedEventType.TwoWay === 2; compare numerically to avoid importing the enum.
  return (event.type as unknown as number) === 2;
}

/** Angular parses `=`, `+=`, `??=`, … as Binary nodes, alongside comparisons ending in `=`. */
function isAssignment(operation: string): boolean {
  return operation.endsWith('=') && !COMPARISON_OPERATORS.has(operation);
}

results matching ""

    No results matching ""