Angularの@forのtrack指定が招く入力値の取り違えをレビューで防ぐ

@for は track の指定を省けない。省いた時点で @for loop must have a "track" expression というコンパイルエラーになる。必須なので、レビューに回ってくる差分には必ず何かが書かれている。track $index と書いておけば通るため、埋めるだけの指定が残りやすい。

track が何を決めているかは、差分の更新処理を読むと分かる。新しい配列と、いま描かれている行を1つずつ突き合わせる判定は3通りに分かれる。

行の突き合わせ
function valuesMatching(liveIdx, liveValue, newIdx, newValue, trackBy) {
  if (liveIdx === newIdx && Object.is(liveValue, newValue)) {
    return 1;
  } else if (Object.is(trackBy(liveIdx, liveValue), trackBy(newIdx, newValue))) {
    return -1;
  }
  return 0;
}

1 は位置も値も同じで、何もしない。0 は別の行なので、作成、移動、破棄のいずれかを行う。問題が出るのは -1 のときである。track の返す値が一致したので同じ行だと判断し、描かれているビューを使い回す。

使い回しで入れ替わるのは値だけ

-1 のときに呼ばれるのは updateValue で、実装は1行である。

値の差し替え
updateValue(index, value) {
  this.getLView(index)[CONTEXT].$implicit = value;
}

行のコンテキストに入っている $implicit を新しい値に置き換える。それ以外は何も触らない。DOM ノードはそのまま、行の中のコンポーネントのインスタンスもそのままである。束縛を通じて渡している値は次の描画で更新されるが、束縛を通っていないものは位置に残る。

束縛を通っていないものは、たとえば次のようなものである。

  • <input> に利用者が入力した途中の値(束縛が書き戻していない間)
  • focus の位置と、テキストの選択範囲
  • 行の中のコンポーネントが自分で持っている状態(開閉のフラグ、下書き、検証の結果)
  • スクロール位置

track が一致を返した行は、これらを保ったまま別のデータと組み合わさる。track の式を「一意になっていればよい」ではなく「どの行とどの行を同じものとして扱うか」として読む理由はここにある。

track $indexで並びが変わったとき

track $index が返すのは位置そのものである。コンパイラはこの式を専用の関数へ置き換える。

位置を返すだけの関数
function ɵɵrepeaterTrackByIndex(index) {
  return index;
}

行が [A, B, C] から [B, C] へ変わる場合を追う。位置0の突き合わせは、いま描かれている A と新しい B を比べるが、track が返すのはどちらも 0 なので一致する。updateValue(0, B) が走り、A の行だった DOM に B の値が入る。位置1も同じで、B の行だった DOM に C の値が入る。最後に余った位置2が破棄される。

先頭の行が削除されたのに、破棄されるのは末尾の行である。1行ずつ上へずれたのではなく、各行のデータだけが差し替わっている。

この構造が利用者の操作と噛み合うと、保存先が入れ替わる。

入力中の行がずれる例
@for (line of lines(); track $index) {
  <div class="row">
    <span></span>
    <input #qty [value]="line.qty" (blur)="save(line, qty.value)" />
    <button (click)="remove(line)">削除</button>
  </div>
}

位置0の数量を直している最中に、別の操作(たとえば別のタブからの更新や、位置0より前への行の挿入)で配列が変わったとする。入力欄の DOM ノードは使い回されるので focus は残り、利用者は同じ欄に入力し続ける。だが (blur) が読む line は差し替わった後の値である。入力した数量が、画面に見えている行とは別の行へ保存される。

破棄される行が末尾だという点も、副作用を持つ。行のコンポーネントの ngOnDestroy で下書きを捨てる実装なら、捨てられるのは末尾の行の下書きである。削除した行の下書きは、位置をずらして残り続ける。

Comment
@Reviewer: `track $index` ですが、この一覧は行の削除と、ポーリングでの差し替えが入ります。位置で同一性を判断すると、入力中の行の DOM が別の行のデータと組み合わさり、`(blur)` の保存先が変わります。`track line.id` にしてもらえますか。`id` はサーバーが返しているので、再読み込みをまたいでも一意です。

track $index を選んでよい場面はある。配列が末尾への追加だけで変わる一覧、あるいは行が内部状態も focus も持たない表示専用の一覧である。差分に track $index があったら、この2つのどちらかに当てはまるかを確かめることになる。

一意でないキーを置いたとき

track line.id のように書いても、id が一意でなければ判定は壊れる。開発モードには検出の仕組みがある。

重複キーの警告
if (duplicatedKeysMsg.length > 0) {
  const message = formatRuntimeError(
    -955,
    'The provided track expression resulted in duplicated keys for a given collection. ' +
      'Adjust the tracking expression such that it uniquely identifies all the items in the collection. ' +
      'Duplicated keys were: \n' + duplicatedKeysMsg.join(', \n') + '.',
  );
  console.warn(message);
}

NG0955 として console.warn に出る。例外ではないので、気付かなければそのまま進む。この警告には、レビューで前提にできない条件が2つある。

1つは開発モードだけだという点である。本番ビルドでは重複の記録自体が行われない。

もう1つは、キーを記録する処理が差分の突き合わせの中にしか無い点である。初回の描画では、描かれている行が0件なので突き合わせのループに入らず、全件が作成の経路を通る。この経路はキーを記録しない。したがって初回の描画では、キーが重複していても NG0955 は出ない。出るのは一覧が一度でも更新されてからである。

一覧を開いて確かめただけでは重複に気付けないので、track の式が一意かどうかは、データの側で判断することになる。見分けの手がかりは次の3つである。

  • サーバーが発行した識別子か、クライアントで組み立てた値か
  • 複数の取得をつないだ配列で、識別子の空間が共通か
  • 新規追加の行に識別子が入るのはいつか(保存前は undefined ではないか)

3つ目は見落としやすい。追加した行の id が保存されるまで undefined なら、追加の直後は複数の行が同じキーを返す。この状態で差分の突き合わせが走ると、重複した値の管理に使う連想配列が開発モードで例外を投げる経路がある。

重複した値を検出する側
set(key, value) {
  if (this.kvMap.has(key)) {
    let prevValue = this.kvMap.get(key);
    if (ngDevMode && prevValue === value) {
      throw new Error(`Detected a duplicated value ${value} for the key ${key}`);
    }
    // 以降は値の連鎖として保持する
  }
}

保存前の行には、クライアント側で作った一時的な識別子を振るのが素直な形になる。crypto.randomUUID() でも、採番のカウンタでも成立する。

track itemを書いたとき

track item(ループ変数そのもの)を書くと、コンパイラは値をそのまま返す関数へ置き換える。

値をそのまま返す関数
function ɵɵrepeaterTrackByIdentity(_, value) {
  return value;
}

判定は Object.is なので、参照の同一性で行を見分けることになる。配列を破壊的に更新するコードでは成立するが、取得した結果を毎回新しいオブジェクトへ写す構成では、すべての行の参照が変わる。全行が作り直される。

これにも開発モードの警告がある。NG0956 で、文面は再作成が高くつくことを伝えるものである。条件が3つ重なったときだけ出る。

  • track の式がループ変数そのものである(track item.id などでは出ない)
  • 作成した数と破棄した数が一致し、どちらも一覧の件数と同じである(全件の作り直し)
  • 先頭の行のビューが「作り直しに値段がつく」大きさである(ビューの確保した枠が2を超える)

3つ目は isViewExpensiveToRecreate という関数で判定していて、要素が少ない行では警告が出ない。文字列を1つ表示するだけの一覧なら、全件作り直しが起きていても静かである。

全件作り直しは、画面の見た目には出にくい。出るのは行の中の状態が消えることと、ngOnInit が毎回走ることである。行のコンポーネントが初期化のときに通信していれば、一覧の更新ごとに件数分の通信が発生する。track の式と、一覧を作っている側の更新の仕方は対で読む。

Comment
@Reviewer: `track line` ですが、`lines` は `httpResource` の結果を `map` で写した配列です。更新のたびに全行のオブジェクトが別の参照になるため、参照で同一性を判断すると行が全件作り直されます。行の中で `ngOnInit` から通信しているので、件数分の要求が毎回出ます。`track line.id` にしてもらえますか。

track式に書けないもの

track の式には制限がある。パイプは使えない。

制限の実装
errors.push(new ParseError(parseSourceSpan, 'Cannot use pipes in track expressions'));

コンポーネントのメソッドを呼ぶ形は書ける。この場合、コンパイラは関数をコンポーネントのインスタンスへ束縛したうえで渡す。

インスタンスへの束縛
const boundTrackBy = trackByUsesComponentInstance
  ? trackByFn.bind(hostLView[DECLARATION_COMPONENT_VIEW][CONTEXT])
  : trackByFn;

束縛されるので this は使えるが、呼ばれる回数は差分の突き合わせの回数に比例する。track の式に、通信や整形のような値段のつく処理を置くと、一覧の更新ごとにその分だけ積み上がる。式はプロパティの参照か、せいぜい文字列の結合までに収めることになる。

もう1つ、track の式と @for の中で読む値は別物である点を確かめる。track に line.id を書いても、行の中で line の別のプロパティを読んでいれば、id が同じまま中身が変わった行は使い回される。これは期待どおりの挙動で、束縛を通じて新しい値が反映される。使い回しで困るのは、前の節で挙げた束縛を通らない状態だけである。

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

@forのtrackを確かめる項目
  • track の式が、再読み込みをまたいで一意な識別子になっているか
  • track $index が、末尾への追加だけで変わる一覧か、状態も focus も持たない一覧に限られているか
  • 行の中に入力欄があるとき、track が位置ではなく識別子を返しているか
  • (blur) や (change) の処理が読む値が、使い回しで差し替わる前提になっていないか
  • 新規追加の行に識別子が入っているか(保存前の undefined で重複しないか)
  • 複数の取得をつないだ配列で、識別子の空間が共通か
  • track item を書いた一覧が、参照を保つ更新の仕方になっているか
  • 行のコンポーネントが ngOnInit から通信していないか(全件作り直しで件数分になる)
  • track の式に、値段のつく処理が入っていないか
  • @empty を置くべき一覧で、空配列の表示を @if で二重に書いていないか

おわりに

track は必須の指定なので、差分には必ず何かが書かれている。読むべきは「書かれているか」ではなく、その式が同じものとして扱う行の範囲である。式が一致を返した行は、DOM と行の中の状態を保ったまま、新しいデータと組み合わさる。track $index が招く取り違えは、この使い回しが位置を単位に起きることから出てくる。

開発モードの警告は補助として使えるが、前提にはできない。NG0955 は初回の描画では出ず、NG0956 は条件が3つ重なったときだけ出る。どちらも console.warn である。判断はデータの側から行う。識別子を誰が発行しているか、保存前の行に入っているか、配列の更新で参照が保たれるか、の3点で track の式は決まる。

テンプレート制御フローの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「テンプレート制御フロー」の群に並べてある。