Angular 22のrouter resourcesでresolverの直列読み込みを解く設計をレビューする
Angular 22のrouter resourcesでresolverの直列読み込みを解く設計をレビューする
resolver を複数のルートに置いた画面では、親の解決が終わるまで子の取得が始まらない。@angular/router 22.2.1 の実装を読むと、この直列化は仕様として書かれている。resolver を走らせる段では、ルートを親から子の順に並べ、concatMap で1ルートずつ処理する。
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つに増えた状態になる。
@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つ立てるだけである。
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 を足した差分では、既存のパラメータ名と重ならないかを見る。
@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() も凍結中は動かない。
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() が呼ばれる。失敗した取得は次の遷移で自動的にやり直される。再試行ボタンを置く場合は、この自動の再試行と二重にならないかを見ることになる。
レビュー観点チェックリスト
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 である。採用を勧めるレビューコメントでは、この段階にあることを添える。