lacolaco's marginalia

Angular v22.2アップデートのまとめ

Angular v22.2.0がリリースされた。マンスリーのマイナーアップデートなので機能追加もいろいろと行われている。内容を確認しておこう。重要なものに絞っているので全部知りたい場合は公式のCHANGELOGに当たってほしい。

angular/angular

フレームワークの主な変更点は以下。

テンプレートからプライベートメンバへのアクセス許可

コンポーネントのテンプレートHTMLからクラスのプライベートメンバを参照できるようになった。この件の背景や影響についてはすでに別記事で書いたのでそちらを読んでほしい。

strictUnclaimedEventNamesの追加

テンプレート内でのイベントバインディング(eventName)が、対象のDOM要素が持つイベントか、ディレクティブのアウトプットと一致することを強制するコンパイルフラグが追加された。まだこれはオプトインで、strictTemplatesにも含まれていないため個別に設定が必要。

<!-- error -->
<button (unknownEvent)="...">

@Component.deferredImportsの追加

@Component.deferredImportsで特定の名前付き@deferブロックに紐づけたコンポーネント・ディレクティブ・パイプについて、指定したブロック内でのみ使われていることをテンプレートの型チェックで検証するようになった。@deferの外や、別の名前のブロック内で使うとコンパイルエラーになる。

@Component({
  deferredImports: {
    blockA: [CmpA],
    blockB: [CmpB],
  },
  template: `
    @defer (name blockA) {
      <!-- error: CmpB(selector: 'cmp-b')はblockB用の依存 -->
      <cmp-b />
    }
  `,
})
export class App {}

ErrorBoundary機能の追加

テンプレート内で描画エラーを捕捉する@boundaryブロックが追加された。ブロック内の描画に失敗すると@errorブロックに切り替わるため、画面の一部のエラーをその範囲内で扱える。@errorでは$errorでエラーを参照でき、$reset()でエラー状態を解除して再描画を試せる。

@boundary {
  <complex-chart [data]="data" />
} @error {
  <p>チャートの描画に失敗した: {{ $error.message }}</p>
  <button (click)="$reset()">再試行</button>
}

ErrorHandlerにもonViewErrorフックが追加され、描画エラーの詳細を受け取れるようになった。あわせてAngular Language Serviceも@boundaryと@errorに対応し、入力補完やホバー、定義への移動、ブロックの折りたたみなどで新しい構文を扱えるようになっている。

ディレクティブ用のテストユーティリティ追加

TestBed.createDirectiveが追加され、テスト用のホストコンポーネントを自分で定義せずにディレクティブをテストできるようになった。戻り値のDirectiveFixtureからdirectiveInstanceでディレクティブ本体、nativeElementでホスト要素を参照でき、detectChanges()で変更検知を実行できる。

bindingsにはinputBindingやoutputBindingを指定できる。属性セレクタだけのディレクティブではホスト要素のタグ名を推測できないため、tagNameも指定する。

import { Directive, input, inputBinding, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';

@Directive({
  selector: '[active]',
  host: { '[class.active]': 'active()' },
})
class ActiveDirective {
  active = input(false);
}

it('入力に応じてホスト要素のクラスを切り替える', () => {
  const active = signal(true);
  const fixture = TestBed.createDirective(ActiveDirective, {
    tagName: 'div',
    bindings: [inputBinding('active', active)],
  });
  fixture.detectChanges();
  expect(fixture.nativeElement.classList.contains('active')).toBe(true);

  active.set(false);
  fixture.detectChanges();
  expect(fixture.nativeElement.classList.contains('active')).toBe(false);
});

ビュー・コンテンツクエリでのInjector取得

ビュークエリやコンテンツクエリのreadオプションにInjectorを指定できるようになった。取得できるのはクエリで見つかった要素のノードインジェクタで、その要素の位置から見えるプロバイダを解決できる。

import { Component, Directive, InjectionToken, Injector, viewChild } from '@angular/core';

const TOKEN = new InjectionToken<string>('TOKEN');

@Directive({
  selector: '[localProvider]',
  providers: [{ provide: TOKEN, useValue: '要素内の値' }],
})
class LocalProviderDirective {}

@Component({
  imports: [LocalProviderDirective],
  template: '<div #target localProvider></div>',
})
class AppComponent {
  targetInjector = viewChild.required('target', { read: Injector });

  ngAfterViewInit() {
    console.log(this.targetInjector().get(TOKEN)); // '要素内の値'
  }
}

Signal Formsで非表示固定のフィールドを指定

Signal Formsのhiddenルールで、条件を省略できるようになった。これまでは常に非表示にしたい場合も{ when: () => true }を指定する必要があったが、hidden(path)だけで書けるようになる。対象のフィールドは常にhidden()がtrueになり、バリデーションの対象からも外れる。

import { Component, signal } from '@angular/core';
import { form, FormField, hidden } from '@angular/forms/signals';

@Component({
  imports: [FormField],
  template: `
    @if (!profileForm.publicUrl().hidden()) {
      <input [formField]="profileForm.publicUrl" />
    }
  `,
})
class ProfileComponent {
  profileModel = signal({ publicUrl: '' });
  profileForm = form(this.profileModel, (path) => {
    hidden(path.publicUrl);
  });
}

hiddenはフォームデータ上のフィールドの状態を指定するルールで、DOMを自動的に非表示にするものではない。表示の切り替えは上の例のようにテンプレート側でhidden()を参照する。

containsTreeAPIの公開

ルーター内部で使われていたcontainsTreeが@angular/routerの公開APIになった。2つのUrlTreeを渡して、一方がもう一方に含まれるかを判定できる。現在のURLとの比較に限らず、任意のURL同士を比較する用途で使える。

import { containsTree, DefaultUrlSerializer } from '@angular/router';

const serializer = new DefaultUrlSerializer();
const container = serializer.parse('/products/42?category=books&page=2');
const target = serializer.parse('/products?category=books');

containsTree(container, target);                     // true
containsTree(container, target, { paths: 'exact' });  // false
containsTree(container, target, { queryParams: 'exact' }); // false

RedirectCommandのthrowによるリダイレクト

ガードやリゾルバからRedirectCommandをthrowしてリダイレクトできるようになった。RedirectCommandはErrorを継承するようになり、ルーターは投げられたコマンドを通常のナビゲーションエラーではなくリダイレクトとして扱う。

これまではリダイレクトの指示を戻り値として返す必要があったが、ネストしたヘルパー関数からもthrowで処理を中断できる。呼び出し元までRedirectCommandを返して伝播させる必要がなくなり、ヘルパーの戻り値の型にリダイレクト用の型を混ぜずに済む。

import { inject } from '@angular/core';
import { RedirectCommand, ResolveFn, Router } from '@angular/router';

function requireId(id: string | null): string {
  if (id === null) {
    throw new RedirectCommand(inject(Router).parseUrl('/not-found'));
  }
  return id;
}

export const idResolver: ResolveFn<string> = (route) => {
  return requireId(route.paramMap.get('id'));
};

Router Resources APIの公開

ルート単位のデータ取得をResource APIで扱うRouter Resourcesが公開APIになった。withRouterResources()で有効にし、ルートのresourcesにデータ取得処理を定義できる。ルート間での並列読み込みや、画面遷移をブロックしないデータ取得、再ナビゲーションなしでのデータ再取得に対応している。

使い方や従来のリゾルバとの違いについては、後日別の記事で詳しく書く予定。

ルート別インジェクタ自動破棄機能の安定化

使われなくなったルートのインジェクタを自動的に破棄する機能が安定版になり、withAutoCleanupInjectors()として利用できるようになった。従来のwithExperimentalAutoCleanupInjectors()は非推奨になった。

通常、ルートのインジェクタとそこで提供されるサービスは、別の画面に遷移しても保持される。この機能を有効にすると、ナビゲーション完了後に使用中のルートと再利用のために保存されたルートを確認し、不要になったインジェクタを破棄する。サービスのngOnDestroyやDestroyRefに登録した後処理も実行される。

import { ApplicationConfig } from '@angular/core';
import { provideRouter, withAutoCleanupInjectors } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes, withAutoCleanupInjectors())],
};

CSSネイティブネスト構文のカプセル化対応

CSSネイティブのネスト構文を、ViewEncapsulation.Emulatedのスタイルカプセル化で正しく扱うようになった。ネストした子セレクタにもコンポーネントのスコープ用属性を付けるようになり、親セレクタを参照する&も扱える。

.card {
  .title {
    color: red;
  }

  &:hover {
    background: lightgray;
  }
}

animate.enter・animate.leaveへの関数・Signalのバインディング

[animate.enter]と[animate.leave]に、クラス名を返す関数やSignalを直接渡した場合にアニメーションが実行されない不具合が修正された。これまではenterClass()のようにテンプレート側で呼び出す必要があったが、enterClassのように参照を渡してもクラス名を取得できるようになる。

import { Component, signal } from '@angular/core';

@Component({
  template: `
    <button (click)="show.set(!show())">切り替え</button>
    @if (show()) {
      <div [animate.enter]="enterClass" [animate.leave]="leaveClass">
        Hello
      </div>
    }
  `,
})
class Example {
  show = signal(true);
  enterClass = signal('fade-in');
  leaveClass = () => 'fade-out';
}

angular/angular-cli

Angular CLIの主な変更は以下。

MCPサーバーのルートディレクトリ指定

ng mcpに--rootオプションが追加された。MCPサーバーがファイルアクセスを許可する範囲と、Angularワークスペースを探索する起点を、起動時に明示できる。--rootは複数指定できるため、複数のワークスペースを扱う場合や、ワークスペースの外からサーバーを起動する場合にも使える。

ng mcp --root /path/to/app-a --root /path/to/app-b

MCPクライアントがlistRoots()でルートを提供する場合は、そちらが優先される。クライアントが対応していない場合や空のリストを返す場合は--rootの指定が使われ、どちらもなければカレントディレクトリが使われる。

テスト対象に合わせたコンパイル範囲の絞り込み

@angular/build:unit-testのVitest実行で、--includeで指定していないテストファイルの型エラーによってテストが失敗する不具合が修正された。これまでは実行するテストを絞っても、tsconfigに含まれるすべてのテストファイルがコンパイルされていた。

ng test --include='src/app/services/test.service.spec.ts'

ファイル監視のネイティブ実装への移行

@angular/buildのファイル監視が、watchpackから@parcel/watcherを中心とした実装に置き換わった。C++のネイティブバインディングを通じてOSのファイル監視APIを利用し、watchモードでのCPU・メモリ使用量を削減する。ポーリングを使う場合やネイティブ監視が利用できない環境では、chokidarにフォールバックする。

Sassコンパイラのネイティブ実装への移行

@angular/buildのSassコンパイルが、JavaScript版のDart Sassをワーカースレッドで実行する方式から、sass-embeddedを使う方式に移行した。DartのAOTコンパイル済みバイナリを別プロセスとして起動し、標準入出力を通じて非同期にコンパイルを依頼する。コンパイラのプロセスは複数のコンパイルで使い回される。

ベンチマークでは、以下の改善が報告されている。

  • Sass処理単体では、コンパイラ起動後のコンパイル時間の中央値で約1.3〜4.3倍、差分コンパイルで約2〜3倍の高速化。
  • ng build全体の実時間では、Sassファイル5つの小規模アプリで約8〜14%短縮。500ファイルの大規模アプリでは初回はほぼ同等で、2回目以降のビルドは約2〜3%短縮。
  • ng buildのピークメモリ使用量は、小規模アプリで約14〜29%、大規模アプリで約31〜33%削減。

計測例では、大規模アプリは時間短縮よりメモリ削減の効果が大きい。

SSRのCritical CSS処理の事前コンパイル

@angular/ssrのCritical CSSインライン化で、スタイルシートの解析をビルド時に行うようになった。Critical CSSは、描画に必要なCSSをHTML内に埋め込み、外部スタイルシートの読み込みを待たずに表示できるようにする処理。これまではリクエスト処理中にHTMLとCSSを解析していたが、CSSから処理用の「プラン」を事前に生成し、サーバーのマニフェストに含める方式に変わった。リクエストごとにCSSを解析する負担が減り、SSRのCPU使用量や応答までの待ち時間を大幅に削減する。

angular/components

Angular CDKやAria、Materialなどの主な変更点は以下。

Material Symbolsの自動判別

mat-iconが、読み込まれているMaterial Symbolsのフォントを判別して、対応するCSSクラスを自動的に付けるようになった。これまではデフォルトで旧Material Icons用のmaterial-iconsクラスが付いていたが、Material Symbolsだけが読み込まれている場合は、Outlined・Rounded・Sharpに応じたmaterial-symbols-*クラスが使われる。

<!-- index.htmlでフォントを読み込む -->
<link rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" />

<!-- テンプレートではfontSetの指定が不要 -->
<mat-icon>home</mat-icon>

上の例ではmaterial-symbols-outlinedクラスが自動的に付与される。フォント自体を自動で読み込む機能ではないので、フォントの読み込みは別途必要。旧Material IconsとMaterial Symbolsの両方が読み込まれている場合は、互換性のため従来のmaterial-iconsが優先される。

MatMenuItemのdisabledInteractive サポート

MatMenuItemにdisabledInteractive入力が追加された。disabledと併用すると、無効な見た目を維持したままフォーカスやホバーを受け付けるようになる。キーボードでの項目移動でもフォーカスできるため、操作できない理由をツールチップで説明する用途などに使える。

<button [matMenuTriggerFor]="menu">操作</button>

<mat-menu #menu="matMenu">
  <button mat-menu-item disabled [disabledInteractive]="true"
          matTooltip="この操作には管理者権限が必要">
    削除
  </button>
</mat-menu>

通常のdisabledと異なり、ネイティブのdisabled属性は付かず、aria-disabledで無効状態を伝える。

Angular Aria: MenuItemのvalue省略対応

@angular/aria/menuのMenuItemで、value入力を省略できるようになった。これまでは各項目の(click)で処理を実行する場合もvalueが必須だったが、値を使わないメニュー項目にダミーの値を付ける必要がなくなる。値を省略した項目が複数あっても、値の重複を知らせる警告は出ない。

<div ngMenu>
  <button ngMenuItem (click)="newFile()">新規作成</button>
  <button ngMenuItem (click)="openFile()">開く</button>
</div>

MatFormFieldControlのSignal Forms対応

MatFormFieldControlが、Signal Formsを使うカスタムコントロールを扱えるようになった。ngFieldプロパティでフォームフィールドを公開すると、mat-form-fieldがvalidやdirtyなどの状態を読み取り、対応するCSSクラスに反映する。

MatFormFieldControlとして提供するクラスにngFieldを追加することで、Signal Formsとの連携ができる。

import { Component, forwardRef, inject } from '@angular/core';
import { FORM_FIELD, FormField } from '@angular/forms/signals';
import { MatFormFieldControl } from '@angular/material/form-field';

@Component({
  selector: 'app-custom-name-input',
  templateUrl: './custom-input.html',
  providers: [{
    provide: MatFormFieldControl,
    useExisting: forwardRef(() => CustomNameInput),
  }],
})
export class CustomNameInput {
  // Signal Formsとの連携部分
  readonly ngField: FormField<string> | null =
    inject(FORM_FIELD, {optional: true, self: true}) as FormField<string> | null;
}

フォームを使う側は、通常どおり[formField]でフィールドを渡してmat-form-field内に配置する。このSignal Formsのディレクティブをコントロール内のinject(FORM_FIELD)が取得する。

<mat-form-field>
  <mat-label>名前</mat-label>
  <app-custom-name-input [formField]="profileForm.name" />
</mat-form-field>

従来はngControlを通じてAngular Formsの状態を参照していたが、ngFieldがある場合はそちらを優先してSignal Formsの状態を参照する。