@ngrx/entityのsortComparerの有無で変わる一覧の並び順をレビューする
@ngrx/entityのsortComparerの有無で変わる一覧の並び順をレビューする
@ngrx/entity の EntityState は ids と entities の2つだけを持つ。
interface EntityState<T> {
ids: string[] | number[];
entities: Dictionary<T>;
}entities は識別子から実体を引く辞書なので、順序の情報はない。一覧の並びを決めるのは ids の配列である。getSelectors() が返す selectAll も ids.map((id) => entities[id]) で組み立てる。
したがって「一覧がどの順で出るか」は、どの updater を呼んだとき ids がどう書き換わるかで決まる。createEntityAdapter は sortComparer の有無で中身の違う adapter を返すので、この書き換えの規則が2通りある。
const stateAdapter = sortComparer
? createSortedStateAdapter(selectId, sortComparer)
: createUnsortedStateAdapter(selectId);sortComparerを省いた一覧の並び
sortComparer を渡さない場合、ids は追加した順に伸びる。addOne は末尾に push し、setAll は ids を空にしてから引数の配列を順に追加する。
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件足せば末尾に出る。
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,z3行目のように、更新では ids が動かない。updateManyMutably は、更新後の実体から識別子を取り直して変化があったときだけ ids を書き換えるためである。並び順をサーバーの応答に任せる設計では、この性質がそのまま要件に合う。
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 で足すと、新しい行は常に末尾に出る。サーバー側が更新日の降順で返しているなら、画面の先頭に出るべき行が最後に並ぶ。
@Reviewer: この一覧は更新日の降順で表示する仕様ですが、`createEntityAdapter()` に `sortComparer` を渡していないため `ids` は追加順のままです。一覧取得は `setAll` で応答の順を保つ一方、作成の成功を `addOne` で反映しているので、新しいタスクだけ末尾に出ます。作成後も一覧を取り直すか、`sortComparer` で `ids` 側に並び順を持たせるかを決めてください。 同値の要素が入れ替わる経路
sortComparer を渡すと createSortedStateAdapter に切り替わり、ids は常に comparer の順に保たれる。挿入は 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 を返したとき、つまり並び順の鍵が等しいとき、新しく入れる側が既存の要素より前に置かれる。
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 で入れ直す。
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 をもう一度通る。同値のグループの中では、更新した要素がグループの先頭へ移る。
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 の末尾に識別子の比較を足す。
const adapter = createEntityAdapter<Task>({
sortComparer: (a, b) =>
b.priority - a.priority || a.id.localeCompare(b.id),
});@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 をそのまま使う。比較の中身は検査されないので、数値を文字列として比べる実装はそのまま通る。
21) setAll([a(rank:2), b(rank:10), c(rank:9)]): ids=[2,1,3] b,a,crank が 10 の行が先頭に来る。localeCompare が "10" < "2" と判定するためである。Reducer のテストで ids の順まで確かめていれば落ちるが、selectAll の件数や entities の中身だけを見るテストは通る。
selectId の側にも検査がない箇所がある。識別子が取れなかった場合、開発モードでは console.warn が出るが処理は進む。
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;
}22) ids には undefined が入り、entities のキーは文字列 'undefined' になるisDevMode() の中なので、本番ビルドでは警告が出ない。ids に undefined が入った状態でも selectAll は1件を返すため、画面上は1行表示されたまま、2件目を足すとキーが衝突して上書きされる。API の応答に識別子が無い項目が混ざる可能性があるなら、adapter へ渡す前に弾く位置を決めておく。
selectAllの参照とメモ化の深さ
getSelectors() が返す selectAll は createSelector で組んである。ids と entities のどちらかが変わると、新しい配列を作って返す。
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つ置くだけで済む。
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で識別子と並び順をどう決めるかで確かめた。
@Reviewer: 一覧の並びはクエリパラメータの `sort` で決まる仕様なので、`sortComparer` で `ids` 側にも並び順を持たせると真実が2つになります。`sort` を変えたときに `setAll` で入れ直す経路があるなら、adapter は挿入順のままにして、表示用の並べ替えが要る画面だけ Selector を足す形にできます。レビュー観点チェックリスト
- 一覧の並び順が、サーバーの応答と 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」の群に並べてある。