Angular 22でcanMatchとparamsInheritanceStrategyが変わった影響をレビューする

Angular 22 のルーターでは、ガードの入口とパラメータの行き先がどちらも変わった。canMatch は引数が2つから3つになり、paramsInheritanceStrategy の既定は 'emptyOnly' から 'always' へ移った。前者は型で止まるが、後者は型にもテストにも現れない。

2つは別の変更でありながら、同じ場所で合流する。canMatch が新しく受け取る第3引数の中身は、paramsInheritanceStrategy の結果だからである。

第3引数が持っているもの

@angular/router 22.2.1 の CanMatchFn は次の形になった。

CanMatchFnの定義
type CanMatchFn = (
  route: Route,
  segments: UrlSegment[],
  currentSnapshot: PartialMatchRouteSnapshot,
) => MaybeAsync<GuardResult>;

第3引数の型は ActivatedRouteSnapshot ではない。Pick で10個のプロパティに絞ったものである。

PartialMatchRouteSnapshotの定義
type PartialMatchRouteSnapshot = Pick<
  ActivatedRouteSnapshot,
  | 'routeConfig' | 'url' | 'params' | 'queryParams' | 'fragment'
  | 'data' | 'outlet' | 'title' | 'paramMap' | 'queryParamMap'
>;

parent と children と pathFromRoot と component が入っていない。理由は型定義のコメントに書かれていて、canMatch が呼ばれる時点ではルートの照合が終わっていないためである。照合の途中なので、木構造を上下にたどることはできない。親の情報が要るなら、後述する params と data の継承を通して受け取ることになる。

同じ PartialMatchRouteSnapshot は redirectTo に関数を渡す RedirectFunction も受け取る。照合中に使える情報の範囲として、ルーターが1つの型にまとめた形である。

移行が書き足すマーカー

22 への移行では can-match-snapshot-required という schematic が走る。説明は Adds the required third argument to canMatch callsites. で、名前のとおり呼び出し側だけを直す。

移行が挿入するもの
expect(await guard(route, segments, {} as any /* added by migration */)).toBe(true);

宣言側が直らないのは、引数の少ない関数が引数の多い関数型へ代入できるという TypeScript の規則による。(route, segments) => boolean は CanMatchFn を満たすため、ガードの定義は手を入れずに通る。型エラーが出るのは、3つの引数を要求する型の関数を2つで呼んだ呼び出し側だけで、実際にそれが現れるのはほとんどが単体テストである。

ここから2つの作業が残る。

1つは {} as any の掃除である。挿入されたのは空オブジェクトで、params も queryParams も持たない。ガードが第3引数を使っていないうちはテストが通るので、マーカーはそのまま残る。後からガードが currentSnapshot.queryParams['token'] を読むようになった時点で、そのテストだけが undefined のプロパティ参照で落ちる。落ちた場所とマーカーを挿入した時期が離れているため、原因をたどる経路が長い。差分に /* added by migration */ が含まれていたら、その場で実際の値へ置き換えておくほうが安い。

もう1つは、宣言側が第3引数を使えるようになったことを前提に、既存のガードを見直す作業である。

Comment
@Reviewer: 移行が挿入した `{} as any` が残っています。このガードは `currentSnapshot.queryParams` を読むようになったので、このテストは期待する分岐を通っていません。`{ queryParams: { token: 'x' } } as PartialMatchRouteSnapshot` のように、確かめたい値を持つスナップショットを渡してください。

親のパラメータが子へ流れ込む経路

canMatch に渡るスナップショットは、照合しようとしているルート自身のものを作り、そこへ継承を適用して組み立てられる。継承を決めているのが paramsInheritanceStrategy で、22 の既定値は実装上も 'always' である。

継承の条件と合成
if (parent !== null && (paramsInheritanceStrategy === 'always'
    || routeConfig?.path === ''
    || (!parent.component && !parent.routeConfig?.loadComponent))) {
  inherited = {
    params: Object.keys(route.params).length === 0
      ? parent.params
      : Object.freeze({ ...parent.params, ...route.params }),
    data: Object.freeze({ ...parent.data, ...route.data }),
    // ...
  };
}

21 までは、条件の2番目と3番目しか真にならなかった。つまり子自身のパスが空であるか、親がコンポーネントを持たない場合に限って、親の params と data が子へ合成された。22 では1番目が常に真になるため、親子の関係があれば必ず合成される。

合成の向きは子が勝つ。同じキーがあれば子の値で上書きされるが、子が持たないキーは親の値がそのまま見える。

この差が症状になるのは、キーの不在を分岐に使っていた場合である。

不在を分岐に使っている実装
export const detailOrListGuard: CanMatchFn = (route, segments, currentSnapshot) => {
  const id = currentSnapshot.paramMap.get('orderId');
  return id ? true : false; // 21 までは子で undefined だった
};

この guard を /orders/:orderId の下にある summary のような子ルートで使っていた場合、21 までは orderId が取れず false を返していた。22 では親の orderId が継承されるので true になる。ルートの照合結果が変わるため、表示されるコンポーネントが別のものになる。

data 側も同じ合成を受ける。親に静的な data を置いて権限を表現している構成では、子の data にその値が無くても親の値が見える。

親のdataが子にも見える
export const routes: Routes = [
  {
    path: 'admin',
    component: AdminShell,
    data: { requiredRole: 'admin' },
    children: [
      { path: 'audit', component: AuditPage, canMatch: [roleGuard] },
      { path: 'help', component: HelpPage, canMatch: [roleGuard] }, // ここも admin 扱いになる
    ],
  },
];

help に requiredRole を書いていなくても、22 では roleGuard が 'admin' を読む。権限を緩めるほうへ倒れるのではなく厳しくするほうへ倒れるので、事故としては軽い。それでも「書いていないものは効果を持たない」という読み方は成り立たなくなる。逆に親の data に { requiredRole: 'viewer' } のような緩い値が入っていれば、子が何も書かないまま緩い側を継承する。

なお canMatch の時点ではリゾルバが走っていない。スナップショットの data に入っているのは、ルート定義に書いた静的な data を継承で合成したものだけである。解決済みのデータを前提にした分岐は、ここには書けない。

Comment
@Reviewer: 22 から `paramsInheritanceStrategy` の既定が `'always'` なので、この子ルートでも親の `requiredRole` が見えます。子ごとに権限を決める設計であれば、各子ルートに `data` を明示するか、親から `data` を外してください。

既定を戻す場合の範囲

旧来の継承に戻す指定は withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' }) である。これはアプリ全体に適用される設定で、ルートごとには選べない。

戻す判断には、戻したあとに何が壊れるかの確認が付いてくる。22 の既定で書いた箇所、たとえば子ルートのコンポーネントが親の :id を自分の paramMap から読んでいる実装は、'emptyOnly' に戻すと undefined になる。新規に書いた部分と移行した部分が混在している段階では、全体を戻すほうが確認の範囲が広くなりやすい。

既定のまま進めるなら、キーの不在に依存した分岐を洗い出す作業が要る。差分で探す手がかりは次の3つである。

  • paramMap.get(...) の結果を真偽値として使っている箇所
  • snapshot.data[...] の不在を既定値の採用に使っている箇所
  • 親子で同じキー名を使っているルート定義

3番目は、22 では子が勝つため症状が出ない。出ないことを確かめる意味で、同じキー名が意図したものかどうかは見ておく。

canMatchとredirectToの併記

関連して、ルート定義の検査が1つ入っている。redirectTo と canMatch を同じルートに書くと、設定の検査で例外になる。

Invalid configuration of route '...': redirectTo and canMatch cannot be used together.
Redirects happen before guards are executed.

リダイレクトがガードより先に処理されるため、併記しても canMatch は走らない。走らないことを黙って受け入れるのではなく、設定の誤りとして扱う形になっている。条件付きのリダイレクトを書きたい場合は、redirectTo に関数を渡すか、ガードから RedirectCommand を throw する。

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

ルーターの既定変更を確かめる項目
  • canMatch の関数が第3引数 currentSnapshot を受け取る形で宣言されているか
  • 移行が挿入した {} as any /* added by migration */ が残っていないか
  • currentSnapshot から parent や children をたどろうとしていないか
  • paramMap.get(...) の結果の不在を、分岐の条件に使っていないか
  • 親の data が子へ合成される前提で、各子ルートの権限が決まっているか
  • 子ルートが親と同じキー名のパラメータを持つ場合、上書きが意図したものか
  • canMatch の中で、リゾルバの結果を前提にした分岐を書いていないか
  • paramsInheritanceStrategy: 'emptyOnly' を指定する場合、22 の既定で書いた箇所への影響を確かめたか
  • redirectTo と canMatch を同じルートに併記していないか

おわりに

canMatch のシグネチャ変更は型検査で止まり、移行の schematic も走る。手当てが要るのは、その schematic が残していくマーカーのほうである。

paramsInheritanceStrategy の既定変更には、止める仕掛けが何も無い。ルート定義も guard の実装も変わらないまま、親の値が子に見えるようになる。差分に現れるのは、既定が変わった後に書かれたコードだけなので、既存のルート定義を読み直す作業が別に必要になる。コンポーネントとルーティングの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の第6群に並べてある。