Angular 22でOnPushが既定になった後に残るEagerをどう見抜くか
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つの名前を持つ。
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 にできない理由があるのか、移行が置いたまま誰も触っていないのかを分けることになる。
@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('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 に差し替わる。型も通りテストも通るので、差分を読む以外に気付く手がかりがない。
@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 へ戻す作業は、画面の数だけ確認が要る。レビューの単位で進めるなら、次の順で見ると取りこぼしが少ない。
- 差分に
Eagerが現れたら、新規に書いたものか移行が置いたものかを確かめる - 新規なら、OnPush で足りない理由を差分の中から指摘する
- 移行が置いたものなら、そのコンポーネントの入力とテンプレートが signal で揃っているかを見る
- 揃っていれば
Eagerを外す変更をその差分に含める
Eager の残留を一覧にして別タスクへ送ると、ほぼ消化されない。触った差分の中で外していくほうが進む。
レビュー観点チェックリスト
ChangeDetectionStrategy.Eagerが、移行で機械的に付いたものとして残っていないかChangeDetectionStrategy.Defaultを新しく書いていないか(Eagerと同値で非推奨)@Componentの引数がオブジェクトリテラルでないコンポーネントが、OnPush 前提で動くか- ファイル直下にないコンポーネント(テストのホストなど)が、OnPush 前提で動くか
- スプレッドで共通化した
changeDetectionが、挿入されたEagerに上書きされていないか - 入力として渡す配列やオブジェクトを、破壊的に書き換えていないか
markForCheck()が、signal にしていない状態の回避策になっていないかdetectChanges()の呼び出しが、signal で置き換えられる場面でないか
おわりに
既定値の変更は、書いていないコードの意味を変える。Eager が付いた差分は読めば分かるが、移行が飛ばしたコンポーネントは差分に何も現れないまま OnPush へ移る。後者を見つける手がかりは、デコレーターの引数の形とクラスの宣言位置という、変更検知とは無関係に見える2点にある。
移行の直後に全件を確かめるのは現実的ではない。触った差分の範囲で、Eager の理由と OnPush の条件を1つずつ確かめていくことになる。