Angular 22でOnPushが既定になった後に残るEagerをどう見抜くか

Angular 22 で changeDetection を書かないコンポーネントは OnPush として扱われる。CLI の schematic が既定値を書き足すようになったのではなく、@angular/core 側の既定が変わった。

この変更の前後で、レビューで問う内容が入れ替わる。21 までは「OnPush を付けているか」が観点だった。22 では付けないほうが OnPush なので、問いは「明示された Eager に理由があるか」と「移行が触らなかったコンポーネントが OnPush のまま動くか」の2つに移る。

列挙値と既定値

@angular/core 22.2.1 の型定義で、ChangeDetectionStrategy は3つの名前を持つ。

ChangeDetectionStrategyの定義
enum ChangeDetectionStrategy {
  OnPush = 0,
  Eager = 1,
  Default = 1, // @deprecated Use `Eager` instead.
}

OnPush が 0 で、@Component の changeDetection のコメントに NOTE: OnPush is enabled by default. と書かれている。毎回チェックする側の名前は Eager になり、Default は同じ値のまま非推奨になった。「既定(Default)」という語が指す対象が反転したため、名前だけを見ると意図を読み違える。

Eager の説明には、何がビューを dirty にするかも書かれている。「変更検知の走査が到達した時点で確かめる。markForCheck、テンプレートで読んだ signal の変化といった特定の条件のときだけ確かめるのではなく」という対比である。裏を返せば、OnPush のビューが再チェックされるのは、入力の参照が変わる、自ビュー内のバインド済みリスナーが発火する、テンプレートで読んだ signal が変わる、markForCheck() が呼ばれる、のいずれかに限られる。

ng updateが書き込むEager

22 への移行では change-detection-eager という schematic が走る。登録されている説明は Adds ChangeDetectionStrategy.Eager to all components. である。21 までの挙動をそのまま保つための処置で、画面の挙動は変わらない。

実装を読むと、分岐は3つある。

移行前の changeDetection 移行後
書かれていない changeDetection: ChangeDetectionStrategy.Eager を挿入する
ChangeDetectionStrategy.Default Eager へ書き換える
ChangeDetectionStrategy.OnPush そのまま

つまり移行直後のコードベースには、意図して付けた Eager が1つもない。すべて「21 の挙動を保つ」という理由だけで付いている。レビューで問えるのはここからで、Eager が残っている差分を見たら、OnPush にできない理由があるのか、移行が置いたまま誰も触っていないのかを分けることになる。

Comment
@Reviewer: この `Eager` は 22 への移行で機械的に付いたものと見えます。入力は signal で受けていて、テンプレートからも signal しか読んでいないため、OnPush へ変えても再描画の条件は満たせます。外した状態でこの画面の操作を一通り確かめてもらえますか。

移行が触らなかったコンポーネント

移行が機械的だという点は、裏側の問題も作る。この schematic が Eager を書き込む対象は、次の条件をすべて満たすクラスに限られる。

  • ファイルの直下に書かれたクラス宣言である
  • @angular/core の @Component が付いている
  • デコレーターの引数がちょうど1つで、オブジェクトリテラルである

条件を外れたコンポーネントは、警告も出ないまま飛ばされる。そして飛ばされた結果は「移行前のまま」ではない。changeDetection が書かれていないのだから、22 の既定である OnPush に変わる。挙動を保つための移行が、保てなかった箇所だけ挙動を変える形になっている。

実際に外れやすいのは次の2つである。

デコレーターにメタデータの定数を渡している
const PANEL_META = {
  selector: 'app-summary-panel',
  template: `<p></p>`,
};

@Component(PANEL_META)
export class SummaryPanel {
  total = 0;
  add(value: number): void {
    this.total += value; // 呼び出し元のイベントでは dirty にならない
  }
}

引数がオブジェクトリテラルではないため、この差分に Eager は入らない。total は signal ではないので、親から add() を呼んでも、このビューが dirty になる経路がない。add() の呼び出しが親のテンプレートのリスナーから始まっていれば親のビューは dirty になるが、OnPush の子は入力の参照が変わらなければ走査の対象に入らない。

もう1つは、テストやストーリーの中で宣言したコンポーネントである。

describe の中で宣言したホストコンポーネント
describe('SummaryPanel', () => {
  @Component({
    template: `<app-summary-panel [items]="items" />`,
    imports: [SummaryPanel],
  })
  class HostComponent {
    items: Item[] = [];
  }
  // ...
});

ファイルの直下ではないので、ここにも Eager は入らない。本番側のコンポーネントは Eager のまま、テストのホストだけ OnPush になる。fixture.componentInstance.items.push(...) のような書き方で通っていたテストは、ここで落ちる。落ちてくれるぶんには気付けるが、原因が移行の取りこぼしだと分かるまで時間を取られる。

挿入位置にも癖がある。プロパティが1つ以上あるときは最後のプロパティの直前に挿入される。スプレッドで共通のメタデータを展開している場合、changeDetection はスプレッドの中にあっても PropertyAssignment として見つからないため、挿入が起きる。

挿入がスプレッドの指定を上書きする
@Component({
  ...BASE_META,           // ここに changeDetection: OnPush が入っていても
  changeDetection: ChangeDetectionStrategy.Eager, // 挿入はこちら
  selector: 'app-order-row',
})

後から書いたプロパティが勝つため、共通化していた OnPush の指定が Eager に差し替わる。型も通りテストも通るので、差分を読む以外に気付く手がかりがない。

Comment
@Reviewer: `ng update` の差分です。`BASE_META` 側に `changeDetection: ChangeDetectionStrategy.OnPush` があるので、ここで挿入された `Eager` が上書きしています。挿入行を消してください。

Eagerを外せない理由を差分から読む

Eager を外すかどうかは、OnPush の4つの条件のどれかで再チェックが起こるかに還元できる。差分で見分けやすいのは、入力を破壊的に更新している箇所である。

配列の中身を書き換えている親
@Component({
  selector: 'app-order-page',
  template: `
    <app-order-list [orders]="orders" />
    <button (click)="append()">追加</button>
  `,
  changeDetection: ChangeDetectionStrategy.Eager,
})
export class OrderPage {
  orders: Order[] = [];
  append(): void {
    this.orders.push(createOrder()); // 参照は変わらない
  }
}

orders の参照は変わらないため、app-order-list が OnPush なら入力の変化としては検出されない。親が Eager でも子の OnPush は親の走査では dirty にならないので、Eager を付けるべき位置は子のほうになる。この構造のまま子を OnPush へ変えると、追加したはずの行が出なくなる。

外せる形にするなら、更新を参照の差し替えにするか、状態を signal にする。

参照を差し替える
export class OrderPage {
  readonly orders = signal<readonly Order[]>([]);
  append(): void {
    this.orders.update((list) => [...list, createOrder()]);
  }
}

signal にした場合、子のテンプレートで読んだ値の変化がそのまま dirty の条件になる。Eager を外せるかどうかは、この書き換えの有無で決まる。

同じ判断は markForCheck() の呼び出しにも当てはまる。markForCheck() が残っている箇所は、signal にしていない状態を手で dirty にしている箇所である。zoneless の変更検知が v21 から既定になり、setTimeout のコールバックで値を書いただけでは再描画が始まらない。この回避策として markForCheck() が足された箇所は、signal へ移せば消える。

移行後の棚卸しの順序

Eager が付いたコンポーネントを一度に OnPush へ戻す作業は、画面の数だけ確認が要る。レビューの単位で進めるなら、次の順で見ると取りこぼしが少ない。

  1. 差分に Eager が現れたら、新規に書いたものか移行が置いたものかを確かめる
  2. 新規なら、OnPush で足りない理由を差分の中から指摘する
  3. 移行が置いたものなら、そのコンポーネントの入力とテンプレートが signal で揃っているかを見る
  4. 揃っていれば Eager を外す変更をその差分に含める

Eager の残留を一覧にして別タスクへ送ると、ほぼ消化されない。触った差分の中で外していくほうが進む。

レビュー観点チェックリスト

変更検知の既定が変わった後に確かめる項目
  • ChangeDetectionStrategy.Eager が、移行で機械的に付いたものとして残っていないか
  • ChangeDetectionStrategy.Default を新しく書いていないか(Eager と同値で非推奨)
  • @Component の引数がオブジェクトリテラルでないコンポーネントが、OnPush 前提で動くか
  • ファイル直下にないコンポーネント(テストのホストなど)が、OnPush 前提で動くか
  • スプレッドで共通化した changeDetection が、挿入された Eager に上書きされていないか
  • 入力として渡す配列やオブジェクトを、破壊的に書き換えていないか
  • markForCheck() が、signal にしていない状態の回避策になっていないか
  • detectChanges() の呼び出しが、signal で置き換えられる場面でないか

おわりに

既定値の変更は、書いていないコードの意味を変える。Eager が付いた差分は読めば分かるが、移行が飛ばしたコンポーネントは差分に何も現れないまま OnPush へ移る。後者を見つける手がかりは、デコレーターの引数の形とクラスの宣言位置という、変更検知とは無関係に見える2点にある。

移行の直後に全件を確かめるのは現実的ではない。触った差分の範囲で、Eager の理由と OnPush の条件を1つずつ確かめていくことになる。