Angular 22のrouter resourcesでresolverの直列読み込みを解く設計をレビューする

resolver を複数のルートに置いた画面では、親の解決が終わるまで子の取得が始まらない。@angular/router 22.2.1 の実装を読むと、この直列化は仕様として書かれている。resolver を走らせる段では、ルートを親から子の順に並べ、concatMap で1ルートずつ処理する。

resolveDataの中核
return from(routesNeedingDataUpdates).pipe(
  concatMap((route) => {
    if (routesWithResolversToRun.has(route)) {
      return runResolve(route, targetSnapshot, paramsInheritanceStrategy);
    }
    // 解決対象でないルートは継承だけ更新する
    return of(void 0);
  }),
  // ...
);

同じルートに属する resolver どうしは resolveNode が mergeMap で回すため並列になる。直列なのはルートの境界だけである。22.2 で developer preview として入った router resources は、この境界をなくす仕組みとして設計されている。

resolverとresourcesが走る位置

router resources を有効にするのは provideRouter(routes, withRouterResources()) である。有効にすると、Route の resources プロパティに置いた関数が遷移のたびに呼ばれる。

ルート定義
{
  path: 'orders/:orderId',
  loadComponent: () => import('./order-page').then((m) => m.OrderPage),
  resources: (ctx) => ({
    order: httpResource<Order>(() => `/api/orders/${ctx.params().orderId}`),
    shipments: httpResource<Shipment[]>(() => `/api/orders/${ctx.params().orderId}/shipments`),
  }),
}

ctx に入るのは params queryParams fragment data の4つで、いずれも Signal である。型定義には、この関数が遷移の Main Loading Phase で実行されると書かれている。実装での位置はもっと具体的で、ガードの判定、resolver の解決、loadComponent の読み込み、createRouterState がすべて終わったあとに来る。

順序がこうなっているので、同じ画面で resolver と resources を併用しても直列はほどけない。resources が動き出すのは resolver が全ルート分終わってからである。既存の resolver を残したまま resources を足した差分は、並列化の効果を得られないまま取得経路が2つに増えた状態になる。

Comment
@Reviewer: この画面は親ルートに `resolve: { account: accountResolver }` を残したまま、子ルートに `resources` を足しています。実装上 `resources` は resolver の完了後に動くため、`account` の取得が終わるまで `order` の取得は始まりません。並列にする意図でしたら、`account` も親ルートの `resources` へ移してもらえますか。

並列になるのはどこまでか

resources を置いたルートの処理は、一致したルートツリーを根から辿りながら、ルートごとの準備を配列へ積む形で書かれている。積んだものは Promise.all でまとめて待つため、親子の間に待ち合わせは入らない。

準備の積み方
const traverse = (stateNode) => {
  const route = stateNode.value;
  if (route) {
    initializeActivatedRoute(route);
    processRoute(route, newlyCreatedRoutes, resourceSetupPromises, abortSignal, blockingResourcePromises);
  }
  for (const childState of stateNode.children) {
    traverse(childState);
  }
};
traverse(targetRouterState._root);
return Promise.all(resourceSetupPromises).then(() => Promise.all(blockingResourcePromises));

並列なのはルート間と、1つの resources が返したレコードの各エントリである。ここで直列に戻せてしまう書き方が2つある。1つは resources 関数自体を async にして、途中で await してから次の Resource を作る形である。関数の戻り値は await されるので、待っている間はそのルートの Resource が1つも存在しない。

直列に戻ってしまう書き方
resources: async (ctx) => {
  const order = await fetchOrder(ctx.params().orderId);
  return {
    order: httpResource<Order>(() => `/api/orders/${order.id}`),
    shipments: httpResource<Shipment[]>(() => `/api/orders/${order.id}/shipments`),
  };
}

もう1つは、ある Resource の value() を別の Resource の params で読む形である。前者が解決するまで後者の要求が組み立てられないため、2本の通信が順番に並ぶ。識別子を URL から取れる取得を後段に置いてしまった差分は、ここで見つかる。

resources 関数はルートごとに作られた子の EnvironmentInjector の中で呼ばれる。inject() はこの文脈でだけ使えるので、async にした関数の await より後ろに inject() を書くと実行時に失敗する。関数を async にしない理由はここにもある。

blockingとnonBlockingで入力に届く値が変わる

既定では、resources が返した Resource はすべて遷移をブロックする。待ち方は effect で書かれていて、isLoading() が偽になれば解決、status() が 'error' になれば棄却する。棄却は遷移の失敗になるため、読み込みに失敗した画面は表示されない。

ブロックしたくない取得には nonBlocking() を通す。実装は Resource にフラグを1つ立てるだけである。

nonBlockingの実装
function nonBlocking(res) {
  res[BLOCKING_SYMBOL] = false;
  return res;
}

このフラグは、待ち方のほかにもう1箇所を変える。withComponentInputBinding() を併用したとき、コンポーネントの入力へ何が渡るかである。ブロックする Resource は専用の effect で resource.value() を入力へ流し込む。ブロックしない Resource はその経路から外れ、ルートのデータと同じ仕組みで渡される。

入力へ流し込む側の判定
for (const { templateName } of mirror.inputs) {
  const resource = resources[templateName];
  if (!resource || !resource[BLOCKING_SYMBOL]) {
    continue;
  }
  componentRef.setInput(templateName, untracked(resource.value));
  // 以降は resource.value() を追う effect を登録する
}

外れた側がどう渡るかは、入力を束ねている側の実装で決まる。queryParams params data を合成した最後に activatedRoute.resources を重ねているので、入ってくるのは Resource オブジェクトそのものである。

入力の元になるデータの合成
data = {
  ...queryParams,
  ...params,
  ...data,
  ...activatedRoute.resources,
};

つまり同じキーでも、ブロックするなら値が、ブロックしないなら Resource が入力に届く。nonBlocking() を外す差分、あるいは付ける差分は、入力の型を変えている。

入力の宣言が噛み合う形
// blocking のまま受ける
readonly order = input.required<Order>();
// nonBlocking() を通した取得を受ける
readonly shipments = input.required<Resource<Shipment[]>>();

合成の順序からもう1つ分かることがある。resources のキーは params と data の同名のキーを上書きする。ルートパラメータの名前と Resource のキーを揃えると、パラメータを受けていた入力に Resource が入る。キーの衝突は型エラーにならない場合もあるので、resources を足した差分では、既存のパラメータ名と重ならないかを見る。

Comment
@Reviewer: `resources` のキー `orderId` が、`path: 'orders/:orderId'` のパラメータ名と同じになっています。入力へ渡すデータの合成では `resources` が後ろに重なるため、`orderId` を受けている入力にはパラメータの文字列ではなく Resource が入ります。キー名を `orderDetail` のように変えてもらえますか。

なお withComponentInputBinding() を付けていない構成では、Resource は入力へ届かない。読み込みとブロックは行われるので、取得そのものは動いたまま、コンポーネントが値を受け取る口だけが無い状態になる。その場合は inject(ActivatedRoute).resources から読む。

遷移中の表示が前の値で凍る

router resources の Resource は、素の resource() をそのまま渡しているわけではない。routerResource() が包んだうえで、遷移の開始から終了までスナップショットを固定する。

凍結の仕組み
const sub = router.events.subscribe((e) => {
  if (e instanceof NavigationStart) {
    isRollbackRecoveryPending.set(false);
    if (frozenSnapshot() === null) {
      frozenSnapshot.set(source.snapshot());
    }
  } else if (e instanceof NavigationEnd) {
    frozenSnapshot.set(null);
    isRollbackRecoveryPending.set(false);
  }
  // NavigationCancel / NavigationError では巻き戻しの復帰を待つ
});
// 読み出しは frozenSnapshot() ?? source.snapshot()

遷移が始まると、読み出しは凍結したスナップショットへ切り替わる。次の画面の読み込みが進んでいる間、表示に出るのは前の画面の値である。isLoading() も凍結した側の値なので、遷移中にスピナーを出す判定を Resource の isLoading() に任せた実装は、何も表示を変えない。遷移中の表示は router.events か Router の遷移状態から組み立てることになる。

reload() も凍結中は動かない。

凍結中のreload
res.reload = function () {
  if (frozenSnapshot() !== null) {
    return false;
  }
  return source.reload?.() ?? false;
};

戻り値の false は「再読み込みを始めなかった」を表す。再読み込みボタンの押下を遷移と並行して受けつける画面では、reload() の戻り値を見ずに成功として扱っていないかを確かめる。

再遷移でresources関数が呼ばれない条件

resources 関数が実行されるのは、そのルートが新しく作られたときだけである。同じルートを使い回す遷移、たとえば orders/1 から orders/2 への遷移では、既存の Resource がそのまま残る。

使い回す側の処理
function updateExistingResources(route, blockingResourcePromises, abortSignal) {
  const currentResources = route.snapshot?.resources;
  if (!currentResources) {
    return;
  }
  for (const r of Object.values(currentResources)) {
    const underlyingRes = r[SOURCE_RESOURCE_SYMBOL];
    if (underlyingRes.status() === 'error') {
      underlyingRes.reload?.();
    }
  }
  route._futureSnapshot.resources = currentResources;
  setupBlocking(route, currentResources, blockingResourcePromises, abortSignal);
}

新しい値を取りに行かせるのは ctx.params などの signal である。resources の中で ctx.params() を読まず、関数が呼ばれた時点のパラメータを変数に取り出して使うと、再遷移でパラメータが変わっても要求は組み立て直されない。前の注文の内容が表示されたままになる。

読み方の違い
// 再遷移で追従する
order: httpResource<Order>(() => `/api/orders/${ctx.params()['orderId']}`),

// 追従しない。関数が呼ばれた時点の値で固定される
const orderId = ctx.params()['orderId'];
order: httpResource<Order>(() => `/api/orders/${orderId}`),

誤りが残っていても、別のルートから入り直せば resources 関数が呼ばれるため動いて見える。一覧から詳細へ入る導線だけを試すと気付かない。レビューでは、同じルートのパラメータだけが変わる遷移が画面にあるかを先に確かめ、あるなら ctx の読み方を見る。

status() が 'error' の Resource だけは、使い回しの際に reload() が呼ばれる。失敗した取得は次の遷移で自動的にやり直される。再試行ボタンを置く場合は、この自動の再試行と二重にならないかを見ることになる。

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

router resourcesを足した差分で確かめる項目
  • withRouterResources() を provideRouter に渡しているか
  • 同じ画面に resolver と resources が併存していないか(resolver が先に全ルート分終わる)
  • resources 関数が async になっていないか(戻り値が待たれる間、Resource が存在しない)
  • ある Resource の value() を別の Resource の params で読んでいないか
  • inject() が resources 関数の同期の範囲で呼ばれているか
  • nonBlocking() の有無と、受け手の入力の型(値か Resource)が噛み合っているか
  • resources のキーが、ルートパラメータ名や data のキーと衝突していないか
  • 入力で受ける構成なら withComponentInputBinding() があるか
  • 遷移中のスピナーを Resource の isLoading() で判定していないか(凍結中は変わらない)
  • reload() の戻り値 false(凍結中)を成功として扱っていないか
  • resources の中で ctx.params() を signal として読んでいるか
  • パラメータだけが変わる遷移で、表示が追従するか
  • 取得の失敗で遷移を止めてよいのか(ブロックする Resource のエラーは遷移の失敗になる)

おわりに

resolver の直列は、1ルートずつ concatMap で処理するという実装から来ている。router resources はルートツリーを一度に辿って並列に待つので、この直列はなくなる。ただし置き換えは取得の位置を変えるだけでは済まない。ブロックするかどうかで入力に届く値の形が変わり、遷移中は表示が凍り、同じルートの使い回しでは関数が呼ばれない。

レビューの順序としては、まず resolver が残っていないかを見る。残っていれば並列化は成立していない。次に nonBlocking() の有無と入力の宣言を突き合わせ、最後に ctx の読み方を見る。ルーティングの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「ルーティング」の群に並べてある。canMatch の形の変更についてはAngular 22でcanMatchとparamsInheritanceStrategyが変わった影響をレビューするで扱った。

router resources と nonBlocking() は 22.2 時点で developer preview である。採用を勧めるレビューコメントでは、この段階にあることを添える。