NgRxのmeta-reducerによる永続化で復元が漏れる条件をレビューする

state を localStorage に残す実装は、meta-reducer に置くのが定番である。root reducer を関数で包み、INIT のときに保存した値を返し、それ以外のときは次の state を書き出す形になる。

よくある永続化の meta-reducer
export function persistState(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
  return (state, action) => {
    if (action.type === INIT) {
      const stored = localStorage.getItem('app-state');
      return reducer(stored ? JSON.parse(stored) : state, action);
    }
    const next = reducer(state, action);
    localStorage.setItem('app-state', JSON.stringify(next));
    return next;
  };
}

この形が動くかどうかは、meta-reducer が root reducer のどこに差し込まれるかと、root reducer が state のどのキーを通すかで決まる。どちらも @ngrx/store の実装で読める。

包みの順序

provideStore は META_REDUCERS と USER_PROVIDED_META_REDUCERS の2つを連結して reducer のファクトリーへ渡す。

@ngrx/store 22.0.1(抜粋)
function _concatMetaReducers(metaReducers, userProvidedMetaReducers) {
    return metaReducers.concat(userProvidedMetaReducers);
}
function createReducerFactory(reducerFactory, metaReducers) {
    if (Array.isArray(metaReducers) && metaReducers.length > 0) {
        reducerFactory = compose.apply(null, [...metaReducers, reducerFactory]);
    }
    ...
}

compose は配列の最後を内側に置いて左へ重ねるので、配列の先頭が一番外側になる。provideStore({ metaReducers: [persistState] }) で渡した関数は USER_PROVIDED_META_REDUCERS に入り、連結では後ろに付く。したがって自分で書いた meta-reducer は、runtime checks の内側に入る。

配列 [outer, inner, persist] を渡して1つ action を流したときの通過順
outer-in: inc
  inner-in: inc
persist: inc -> keys=["counter"]
  inner-out: inc
outer-out: inc

runtime checks のうち strictStateImmutability は、内側の reducer が返した state を Object.freeze で深く凍結して返す。

22.0.1 の immutabilityCheckMetaReducer
function immutabilityCheckMetaReducer(reducer, checks) {
    return function (state, action) {
        const act = checks.action(action) ? freeze(action) : action;
        const nextState = reducer(state, act);
        return checks.state() ? freeze(nextState) : nextState;
    };
}

凍結は自分の meta-reducer が値を返したあとに起きる。自分の meta-reducer が受け取る state は前の周回で凍結済みなので、ここで state に書き込むと開発モードでは例外になり、本番では黙って無視される。保存の前に state を加工する実装では、複製してから触る位置になる。

全actionが通る経路

meta-reducer は root reducer そのものを包むため、ストアが流す内部の action も通る。@ngrx/store/init と @ngrx/store/update-reducers の2つである。

22.0.1 の ReducerManager.updateReducers
updateReducers(featureKeys) {
    this.next(this.reducerFactory(this.reducers, this.initialState));
    this.dispatcher.next({
        type: UPDATE,
        features: featureKeys,
    });
}

遅延ルートで provideState を呼ぶたびに update-reducers が流れる。上の persistState はこの action でも書き込むので、画面遷移のたびに localStorage への書き込みが1回増える。書き込みの頻度を気にする実装では、保存する action を絞る条件が要る。

書き込みが走る経路はもう1つある。DevTools の時間旅行である。provideStoreDevtools は meta-reducer を足さず、StateObservable と ReducerManagerDispatcher を差し替える。

@ngrx/store-devtools 22.0.1 の computeNextEntry(抜粋)
let nextState = state;
let nextError;
try {
    nextState = reducer(state, action);
}
catch (err) { ... }

recomputeStates がこれを action の履歴ぶん繰り返す。差し替えているのは state の供給元なので、ここで呼ばれる reducer は meta-reducer を含んだ root reducer である。DevTools で action を1つ飛ばすと履歴が全部再計算され、そのたびに保存の副作用が走る。保存を meta-reducer に置く設計では、DevTools を使った調査が localStorage の内容を書き換える。

復元が消える条件

combineReducers が返す関数は、登録されている reducer のキーだけで次の state を組み立てる。

22.0.1 の combineReducers(抜粋)
return function combination(state, action) {
    state = state === undefined ? initialState : state;
    let hasChanged = false;
    const nextState = {};
    for (let i = 0; i < finalReducerKeys.length; i++) {
        const key = finalReducerKeys[i];
        const reducer = finalReducers[key];
        const previousStateForKey = state[key];
        const nextStateForKey = reducer(previousStateForKey, action);
        nextState[key] = nextStateForKey;
        hasChanged = hasChanged || nextStateForKey !== previousStateForKey;
    }
    return hasChanged ? nextState : state;
};

nextState は空のオブジェクトから作られるので、reducer を持たないキーは入らない。一方、どのキーも変わらなければ state がそのまま返る。復元した値のうち遅延登録の機能に属するキーは、state を変える action が1つ通るまで残り、通った瞬間に落ちる。

ReducerManager と ActionsSubject を直接組み立てて、保存済みの state に lazyFeature のキーが入っている状況を2通り試した。

A) INIT のあと何も dispatch せずに lazyFeature を登録した
INIT  -> {"counter":7,"lazyFeature":{"items":["restored"]}}
@ngrx/store/update-reducers -> {"counter":7,"lazyFeature":{"items":["restored"]}}

B) 登録の前に root の state を変える action が1つ入った
INIT  -> {"counter":7,"lazyFeature":{"items":["restored"]}}
inc -> {"counter":8}
@ngrx/store/update-reducers -> {"counter":8,"lazyFeature":{"items":[]}}

A では復元した値が機能の reducer へ渡り、B では初期値になる。復元が成功するかどうかが、遅延登録までに state を変える action が通ったかで決まる。認証の確認やテーマの読み込みのように、起動直後に1つ action を流す実装があれば B になる。

この経路は仕様として書けるものではないので、遅延登録する機能の state を永続化するなら、復元を INIT ではなく機能側で行う形に変える。機能の reducer に復元用の action を1つ置き、provideState のあとに dispatch する。root の meta-reducer が触るのは、起動時から登録されているキーに限る。

provideState が reducer を登録する時機とルートから出たときの残り方はNgRxのprovideStateを遅延ルートで登録する設計とレビュー観点で確かめた。

Comment
@Reviewer: `metaReducers: [persistState]` で root の state 全体を保存し、`INIT` で復元しています。`cart` は遅延ルートの `provideState` で登録されるため、`combineReducers` は登録前の `INIT` の時点でこのキーの reducer を持ちません。起動時に `auth/checkSession` が流れると `cart` が落ちて、復元されないまま初期値になります。復元は `cart` 側の reducer に `cart/restore` を置いて、登録のあとに dispatch する形へ移してください。

復元した値の形を確かめる位置

combineReducers は state[key] をそのまま機能の reducer へ渡す。createReducer で書いた初期値が使われるのは、渡った値が undefined のときだけである。

createReducer の初期値が使われる条件
export const reducer = createReducer(
  initialState,                  // 第1引数が使われるのは state === undefined のとき
  on(loaded, (s, { items }) => ({ ...s, items })),
);

保存した JSON が前の版のアプリで書かれていた場合、キーの構成が変わっていても、その値がそのまま reducer へ渡る。足したプロパティは undefined のまま読まれ、消したプロパティは残る。型検査はテンプレートにも Selector にも通るので、落ちるのは値を使う側である。

状態の形を確かめる仕掛けは @ngrx/store に無い。runtime checks の strictStateSerializability は直列化できない値を探す検査で、形は見ない。JSON を往復した state は文字列と数値と配列とプレーンオブジェクトだけになるため、この検査には必ず通る。Date を保存して復元すると文字列になるが、これも検査には引っかからない。

復元する側に版の番号を持たせ、一致しないときは捨てる判断が要る。meta-reducer の中で書くなら、JSON.parse の直後に版を見る位置である。

本番ビルドでの runtime checks

strictStateImmutability を明示しても、本番ビルドでは働かない。createActiveRuntimeChecks の本番側の分岐は、利用者の設定を読まずに固定の値を返す。

22.0.1 の createActiveRuntimeChecks
function createActiveRuntimeChecks(runtimeChecks) {
    if (isDevMode()) {
        return {
            strictStateSerializability: false,
            strictActionSerializability: false,
            strictStateImmutability: true,
            strictActionImmutability: true,
            strictActionWithinNgZone: false,
            strictActionTypeUniqueness: false,
            ...runtimeChecks,
        };
    }
    return {
        strictStateSerializability: false,
        strictActionSerializability: false,
        strictStateImmutability: false,
        strictActionImmutability: false,
        strictActionWithinNgZone: false,
        strictActionTypeUniqueness: false,
    };
}

開発モードの分岐だけが ...runtimeChecks を展開する。provideStore({ runtimeChecks: { strictStateSerializability: true } }) と書いても、本番では展開されないので検査が走らない。検査を「本番で切るかどうか」を議論する余地は無く、既に切れている。

この性質は永続化の実装に直接関わる。state に直列化できない値が混ざったとき、開発では例外が出て気付けるが、strictStateSerializability は開発モードでも既定が false である。有効にしなければ、JSON.stringify が値を落としたまま保存が続く。保存を meta-reducer で行うなら、この検査を開発モードで有効にしておく価値がある。

Comment
@Reviewer: `runtimeChecks: { strictStateSerializability: true }` を本番の設定として書いていますが、`createActiveRuntimeChecks` は本番ビルドで利用者の設定を展開しないため、この指定は働きません。開発モードでだけ有効になる検査として扱ってください。永続化で `Map` や `Date` が state に入っていないことを確かめる用途なら、この指定のままで目的は果たせます。

保存する範囲を決める位置

meta-reducer が受け取るのは root の state 全体である。範囲を絞る仕掛けは無いので、保存するキーの一覧は自分で書くことになる。

保存するキーを明示する
const PERSISTED_KEYS = ['preferences', 'recentQueries'] as const;

export function persistState(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
  return (state, action) => {
    const next = reducer(state, action);
    if (action.type === INIT || action.type === UPDATE) {
      return next;
    }
    const picked = Object.fromEntries(PERSISTED_KEYS.map((k) => [k, next[k]]));
    localStorage.setItem('app-state', JSON.stringify({ v: 2, state: picked }));
    return next;
  };
}

除外する形(保存しないキーを列挙する)にすると、機能を足したときに既定で保存される。トークンや一時的な UI の状態が後から入っても気付けないので、許可する側を列挙する形にしておく。

保存の対象を絞ると、復元の対象も自動的に絞られる。INIT で返すのは保存したキーだけを含む部分的なオブジェクトになるため、残りのキーは state[key] が undefined になり、各 reducer の初期値が使われる。この経路は combineReducers が state[key] を渡す仕組みのままなので、追加の仕掛けは要らない。

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

meta-reducer による永続化を確かめる項目
  • 保存するキーを許可リストで列挙しているか(除外リストになっていないか)
  • トークンや一時的な UI の状態が保存の対象に入っていないか
  • 遅延ルートで provideState するキーを、root の meta-reducer で復元していないか
  • 復元を機能側の action に移しているか
  • 保存した JSON に版の番号があり、一致しないときに捨てる経路があるか
  • 復元した値が reducer の初期値と混ざらない形になっているか(undefined のときだけ初期値が使われる)
  • Date や Map を state に置いていないか(往復で型が変わる)
  • strictStateSerializability を開発モードで有効にしているか
  • 本番で runtime checks が全部無効になる前提を把握しているか
  • 書き込みが走る action を絞っているか(init と update-reducers も通る)
  • DevTools の時間旅行で保存の副作用が履歴ぶん走ることを把握しているか
  • meta-reducer の中で state を複製してから加工しているか(凍結済みの値が渡る)
  • 保存の失敗(容量超過、プライベートモード)を処理しているか

おわりに

meta-reducer は root reducer を包む位置にあるため、INIT と update-reducers を含む全 action が通る。自分で渡した関数は runtime checks の内側に入るので、受け取る state は凍結済みで、返した値が凍結される。

復元が漏れるのは combineReducers の組み立て方による。nextState は空のオブジェクトから作られ、reducer を持たないキーは入らない。復元した値のうち遅延登録の機能に属するキーは、state を変える action が1つ通った時点で落ちる。起動直後に action が流れるかどうかで結果が変わるので、復元は機能側の reducer へ移す。

形の検査はどこにも無い。strictStateSerializability は直列化できない値を探すだけで、保存した JSON の構成が現行の reducer と合っているかは見ない。しかもこの検査は開発モードでだけ働き、本番では利用者の設定が展開されない。版の番号を持たせて捨てる判断を書く位置は、JSON.parse の直後にある。

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