@ngrx/entityのsortComparerの有無で変わる一覧の並び順をレビューする

@ngrx/entity の EntityState は ids と entities の2つだけを持つ。

@ngrx/entity 22.0.1 の EntityState
interface EntityState<T> {
    ids: string[] | number[];
    entities: Dictionary<T>;
}

entities は識別子から実体を引く辞書なので、順序の情報はない。一覧の並びを決めるのは ids の配列である。getSelectors() が返す selectAll も ids.map((id) => entities[id]) で組み立てる。

したがって「一覧がどの順で出るか」は、どの updater を呼んだとき ids がどう書き換わるかで決まる。createEntityAdapter は sortComparer の有無で中身の違う adapter を返すので、この書き換えの規則が2通りある。

22.0.1 の createEntityAdapter(抜粋)
const stateAdapter = sortComparer
    ? createSortedStateAdapter(selectId, sortComparer)
    : createUnsortedStateAdapter(selectId);

sortComparerを省いた一覧の並び

sortComparer を渡さない場合、ids は追加した順に伸びる。addOne は末尾に push し、setAll は ids を空にしてから引数の配列を順に追加する。

22.0.1 の createUnsortedStateAdapter(抜粋)
function addOneMutably(entity, state) {
    const key = selectIdValue(entity, selectId);
    if (key in state.entities) {
        return DidMutate.None;
    }
    state.ids.push(key);
    state.entities[key] = entity;
    return DidMutate.Both;
}
function setAllMutably(entities, state) {
    state.ids = [];
    state.entities = {};
    addManyMutably(entities, state);
    return DidMutate.Both;
}

setAll で全置換すれば、API が返した順がそのまま表示の順になる。addOne で1件足せば末尾に出る。

sortComparer なしの adapter(name を並べた)
1) setAll([c(id:3), a(id:1), b(id:2)]): ids=[3,1,2] c,a,b
2) そのあと addOne(z(id:0)):            ids=[3,1,2,0] c,a,b,z
3) そのあと updateOne(id:3 の rank 変更): ids=[3,1,2,0] c,a,b,z

3行目のように、更新では ids が動かない。updateManyMutably は、更新後の実体から識別子を取り直して変化があったときだけ ids を書き換えるためである。並び順をサーバーの応答に任せる設計では、この性質がそのまま要件に合う。

22.0.1 の updateManyMutably(抜粋)
const didMutateIds = updates.filter((update) => takeNewKey(newKeys, update, state)).length > 0;
if (didMutateIds) {
    state.ids = state.ids.map((id) => newKeys[id] || id);
    return DidMutate.Both;
}
else {
    return DidMutate.EntitiesOnly;
}

問題になるのは、並び順に意味がある一覧で setAll 以外の経路が混ざる場合である。一覧を setAll で入れ替えつつ、新規作成の応答だけ addOne で足すと、新しい行は常に末尾に出る。サーバー側が更新日の降順で返しているなら、画面の先頭に出るべき行が最後に並ぶ。

Comment
@Reviewer: この一覧は更新日の降順で表示する仕様ですが、`createEntityAdapter()` に `sortComparer` を渡していないため `ids` は追加順のままです。一覧取得は `setAll` で応答の順を保つ一方、作成の成功を `addOne` で反映しているので、新しいタスクだけ末尾に出ます。作成後も一覧を取り直すか、`sortComparer` で `ids` 側に並び順を持たせるかを決めてください。

同値の要素が入れ替わる経路

sortComparer を渡すと createSortedStateAdapter に切り替わり、ids は常に comparer の順に保たれる。挿入は merge が担う。

22.0.1 の merge(抜粋)
function merge(models, state) {
    models.sort(sort);
    const ids = [];
    let i = 0;
    let j = 0;
    while (i < models.length && j < state.ids.length) {
        const model = models[i];
        const modelId = selectIdValue(model, selectId);
        const entityId = state.ids[j];
        const entity = state.entities[entityId];
        if (sort(model, entity) <= 0) {
            ids.push(modelId);
            i++;
        }
        else {
            ids.push(entityId);
            j++;
        }
    }
    ...
}

新しく入れる側を models、既存の並びを state.ids として、2つの列をマージする。比較が sort(model, entity) <= 0 なので、comparer が 0 を返したとき、つまり並び順の鍵が等しいとき、新しく入れる側が既存の要素より前に置かれる。

sortComparer: (a, b) => a.rank - b.rank
4) setAll([c(rank:1), a(rank:1), b(rank:2)]): ids=[3,1,2] c,a,b
5) そのあと addOne(d(rank:1)):               ids=[4,3,1,2] d,c,a,b
6) さらに addOne(e(rank:1)):                 ids=[5,4,3,1,2] e,d,c,a,b

同じ rank の要素を足すたびに、それがグループの先頭に入る。setAll の時点では引数の順が保たれているので(4行目)、一覧を取り直すと並びが戻る。追加のたびに先頭へ、再取得で元の順へ、という揺れになる。

更新でも同じ経路を通る。createSortedStateAdapter の updateManyMutably は、対象を entities から削除して models へ移し、merge で入れ直す。

22.0.1 の takeUpdatedModel(抜粋)
const original = state.entities[update.id];
const updated = Object.assign({}, original, update.changes);
const newKey = selectIdValue(updated, selectId);
delete state.entities[update.id];
models.push(updated);
return newKey !== update.id;

並び順に関係のない項目だけを変えても、merge をもう一度通る。同値のグループの中では、更新した要素がグループの先頭へ移る。

全件 rank=1 の状態(ids=[1,2,3] a,b,c)から1件だけ更新した
15) updateOne(id:2 の name のみ変更): ids=[2,1,3] B,a,c
16) updateOne(id:3 の name のみ変更): ids=[3,1,2] C,a,b
17) updateMany(id:2, id:3 を変更):    ids=[2,3,1] B,C,a

チェックボックスの切り替えやインライン編集を updateOne で反映している一覧で、その行が上に飛ぶ。comparer が全順序になっていれば起きないので、レビューで見るのは comparer が 0 を返しうるかどうかである。日付(時刻を落とした値)、状態の区分、優先度のような鍵は 0 を返す。

同値を残したまま並びを固定するなら、comparer の末尾に識別子の比較を足す。

同値を残さない comparer
const adapter = createEntityAdapter<Task>({
  sortComparer: (a, b) =>
    b.priority - a.priority || a.id.localeCompare(b.id),
});
Comment
@Reviewer: `sortComparer: (a, b) => a.dueDate.localeCompare(b.dueDate)` は同じ期限の行で 0 を返します。`createSortedStateAdapter` の `merge` は `sort(model, entity) <= 0` で新しい側を先に置き、`updateOne` も対象を削除して入れ直すので、完了フラグを切り替えた行が同じ期限のグループの先頭へ移動します。comparer の第2キーに `id` を足して同値をなくしてください。

comparer自体の比較の誤り

sortComparer は (a: T, b: T) => number をそのまま使う。比較の中身は検査されないので、数値を文字列として比べる実装はそのまま通る。

sortComparer: (a, b) => String(a.rank).localeCompare(String(b.rank))
21) setAll([a(rank:2), b(rank:10), c(rank:9)]): ids=[2,1,3] b,a,c

rank が 10 の行が先頭に来る。localeCompare が "10" < "2" と判定するためである。Reducer のテストで ids の順まで確かめていれば落ちるが、selectAll の件数や entities の中身だけを見るテストは通る。

selectId の側にも検査がない箇所がある。識別子が取れなかった場合、開発モードでは console.warn が出るが処理は進む。

22.0.1 の selectIdValue
function selectIdValue(entity, selectId) {
    const key = selectId(entity);
    if (isDevMode() && key === undefined) {
        console.warn('@ngrx/entity: The entity passed to the `selectId` implementation returned undefined.', ...);
    }
    return key;
}
selectId: (e) => e.key で key を持たない実体を addOne した
22) ids には undefined が入り、entities のキーは文字列 'undefined' になる

isDevMode() の中なので、本番ビルドでは警告が出ない。ids に undefined が入った状態でも selectAll は1件を返すため、画面上は1行表示されたまま、2件目を足すとキーが衝突して上書きされる。API の応答に識別子が無い項目が混ざる可能性があるなら、adapter へ渡す前に弾く位置を決めておく。

selectAllの参照とメモ化の深さ

getSelectors() が返す selectAll は createSelector で組んである。ids と entities のどちらかが変わると、新しい配列を作って返す。

同じ adapter の selectAll を読んだ結果の参照
25) 同じ state を2回読む:                   同一
26) 並びの変わらない更新のあとに読む:       別物(ids の参照は同一)
27) 別の state を読んだあとに元の state:    別物

26 行目で ids の参照が保たれるのは、createStateOperator が DidMutate.EntitiesOnly のとき entities だけを差し替えて返すためである。それでも selectAll は entities を入力に持つので再計算され、配列の参照は変わる。テンプレートで @for に渡す値はこの配列なので、行の同一性は track の式だけで決まる。track の選び方はAngularの@forのtrack指定が招く入力値の取り違えをレビューで防ぐで扱った。

27 行目は、createSelector の既定のメモ化が直前の1組の引数しか覚えないためである。1つの getSelectors() の結果を2つのコレクションで使い回すと、交互に読むたびに再計算が走る。コレクションごとに getSelectors(selectFeatureState) を呼び分けるのが前提になる。Selector の合成そのものはNgRx SelectorをEffect内のcombineLatestで代用しない理由にまとめてある。

並び順をadapterに持たせるかSelectorで決めるか

ここまでの挙動は、2つの設計のどちらを採るかに整理できる。

sortComparer を与えて ids に並び順を持たせる場合、どの updater を呼んでも並びが保たれる。表示側は selectAll をそのまま描画でき、ページングのない一覧では書く量が少ない。引き換えに、同値のグループが追加と更新で入れ替わり、comparer を変えると state の形が変わらないまま表示が変わる。

sortComparer を省いて挿入順のまま保ち、表示の並びを Selector で決める場合、ids はサーバーが返した順を保持する。並べ替えは createSelector の中で [...entities].sort() を書くことになるので、同値の扱いも第2キーも自分で決められる。画面ごとに別の並びを出す要件なら、Selector を2つ置くだけで済む。

並べ替えを Selector 側に置く
export const selectTasks = createSelector(
  adapter.getSelectors(selectTaskState).selectAll,
  (tasks) => tasks,
);
export const selectTasksByDue = createSelector(selectTasks, (tasks) =>
  [...tasks].sort((a, b) => a.dueDate.localeCompare(b.dueDate) || a.id.localeCompare(b.id)),
);

判断の分かれ目は、並び順が state の一部なのか表示の都合なのかにある。URL やサーバーの応答で並びが決まる一覧は後者で、ids に持たせると二重管理になる。並び順が業務上の順序(手動で並べ替えた順など)なら前者で、その順を state に保つ意味がある。

@ngrx/signals/entities へ移す場合、この選択は片方に固定される。あちらの withEntities に sortComparer は無く、並び順は ids が追加順で持つだけなので、派生を足す形になる。移行の判断は@ngrx/signals/entitiesで識別子と並び順をどう決めるかで確かめた。

Comment
@Reviewer: 一覧の並びはクエリパラメータの `sort` で決まる仕様なので、`sortComparer` で `ids` 側にも並び順を持たせると真実が2つになります。`sort` を変えたときに `setAll` で入れ直す経路があるなら、adapter は挿入順のままにして、表示用の並べ替えが要る画面だけ Selector を足す形にできます。

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

@ngrx/entity の並び順を確かめる項目
  • 一覧の並び順が、サーバーの応答と state のどちらで決まる仕様か書かれているか
  • 並び順に意味がある一覧で sortComparer の有無が仕様と合っているか
  • sortComparer を省いた一覧に setAll 以外の追加経路(addOne、upsertMany)が混ざっていないか
  • sortComparer が 0 を返しうる鍵(日付、区分、優先度)で第2キーを置いているか
  • 同値のグループで updateOne を呼ぶ画面があるか(その行が先頭へ移る)
  • sortComparer の比較が、数値を文字列として比べていないか
  • Reducer のテストが ids の順序まで確かめているか
  • selectId が undefined を返しうる応答を、adapter へ渡す前に弾いているか
  • entities を直接走査して並びを組み立てている箇所がないか
  • getSelectors() の結果をコレクションごとに作り分けているか
  • selectAll の配列が更新ごとに別物になる前提で @for の track を書いているか
  • 表示用の並べ替えを Selector に置くか adapter に持たせるかが、画面をまたいで揃っているか

おわりに

sortComparer を省いた adapter は ids を追加順で持ち、更新では並びを動かさない。与えた adapter は merge で ids を保ち続けるが、比較が sort(model, entity) <= 0 であるために、同値の要素では新しく入れた側と更新した側が前へ出る。更新だけで行が動く経路は、comparer が全順序でないときに限って現れる。

どちらの挙動も、型検査にも開発モードの警告にもかからない。確かめられるのは ids の順を見るテストだけで、selectAll の件数や entities の中身を見るテストは通る。並び順に意味がある一覧では、ids の順序を固定するテストを1つ置く位置にある。

@ngrx/entity の観点の全体はNgRx StoreのAction・Reducer・Effectをレビューする100観点の「@ngrx/entity」の群に並べてある。