Angular 22でHttpClientがFetch既定になった後の通信コードのレビュー観点
Angular 22でHttpClientがFetch既定になった後の通信コードのレビュー観点
Angular 22 の provideHttpClient() は、何も指定しなければ FetchBackend を使う。@angular/common 22.2.1 の実装では、HttpBackend の provider が useFactory: () => inject(FetchBackend) になっており、withFetch() のほうが非推奨になった。型定義の非推奨コメントは withFetch is not required anymore. FetchBackend is the default HttpBackend. である。
ところが 22 へ移行したアプリの多くは、XHR で動いたままになっている。移行の schematic が withXhr() を書き足すためである。既定が入れ替わったのに挙動が変わらない、という状態が差分の出発点になる。
移行が足す1行
@angular/core の schematics/migrations.json には http-xhr-backend という項目がある。説明は Adds 'withXhr' to 'provideHttpClient' function calls when the 'HttpXhrBackend' is used. だが、schematics/bundles/http-xhr-backend.cjs の実装は HttpXhrBackend という名前をどこにも見ていない。判定しているのは次の条件だけである。
const withFetchNode = node.arguments.find(
(arg) => ts.isCallExpression(arg) && ts.isIdentifier(arg.expression)
&& arg.expression.text === 'withFetch');
const withXhrNode = node.arguments.find(/* 同様に withXhr を探す */);
if (!withFetchNode && !withXhrNode) {
// withXhr() を引数の先頭へ挿入する
}withFetch() も withXhr() も書かれていない provideHttpClient() の呼び出しすべてに withXhr() が入る。21 までの既定が XHR だったのだから、挙動を保つ移行としては筋が通っている。ただし結果として、21 までと同じ挙動のアプリには withXhr() が1行だけ増え、それ以外は何も変わらない。22 の既定で動いているのは、移行を通していない新規のアプリか、withXhr() を手で外したアプリだけである。
この schematic が触らない書き方も3つある。provideHttpClient が @angular/common/http から名前付きでインポートされていないファイル、呼び出しが識別子でない形(名前空間インポート経由の http.provideHttpClient(...) など)、そして provideHttpClient() を呼ばず HttpClientModule をインポートしている構成である。最後のものは移行の対象外でありながら挙動も変わらない。HttpClientModule の provider は実装上 provideHttpClient(withInterceptorsFromDi(), withXhr()) であり、NgModule のまま使っているかぎり XHR が維持される。
@Reviewer: この差分で増えているのは `withXhr()` の1行だけなので、通信の挙動は 21 までと同じままです。22 の既定(Fetch)へ寄せる判断をいつ行うかだけ、別に決めておきたいです。移行時点ではこの1行で止めておくのが安全だと思います。逆方向の誤りもある。withFetch() と withXhr() を同じ呼び出しに並べても例外にはならない。どちらも HttpBackend の provider を足すだけなので、引数の後ろに書いたほうが勝つ。検査が入っているのは withRequestsMadeViaParent() との併記と、XSRF の相反する2つの組み合わせだけである。
上りの進捗が消える経路
HttpRequest の reportProgress は 22.0 で非推奨になり、reportUploadProgress と reportDownloadProgress の2つに分かれた。分けた理由は、Fetch が上りの進捗を扱えないことにある。
FetchBackend の createRequestInit は、reportUploadProgress が真なら送信前に例外を投げる。
createRequestInit(req) {
if (req.reportUploadProgress) {
throw new RuntimeError(2824, ngDevMode &&
'The FetchBackend does not support upload progress reporting. ...');
}
// ...
}新しいプロパティを使った場合は NG2824 で止まるので、レビューでなくても気付く。問題は非推奨になった reportProgress: true のほうである。FetchBackend はこれを下りの進捗としてのみ読む。
upload(file: File): Observable<number> {
const form = new FormData();
form.append('file', file);
return this.http.post('/api/files', form, { reportProgress: true, observe: 'events' }).pipe(
filter((event) => event.type === HttpEventType.UploadProgress),
map((event) => Math.round((100 * event.loaded) / (event.total ?? 1))),
@ReviewerFetch 既定では `UploadProgress` のイベントが1つも流れないため、この `filter` は何も通しません。進捗の表示が初期値のまま止まります。 );
}HttpXhrBackend は req.reportProgress || req.reportUploadProgress で上りを、req.reportProgress || req.reportDownloadProgress で下りを判定する。つまり XHR では reportProgress: true が両方を意味していた。Fetch では下りだけが残り、上りのイベントは発生しない。例外も警告も出ないまま、進捗のパーセント表示が動かなくなる。
症状が出るのは画面を操作したときだけで、型検査にもビルドにも現れない。アップロードを扱う差分では、reportProgress という文字列と UploadProgress という文字列の両方を探す。前者だけなら非推奨の指定が残っているという話で済むが、後者があれば挙動が変わる箇所である。
なお httpResource 側の reportProgress は非推奨ではない。HttpResourceRequest のコメントに「HttpResource.progress シグナルへ届く」と書かれており、実装では reportDownloadProgress へ渡される。下りの進捗を指す別のオプションなので、HttpRequest の非推奨と混ぜて読まない。
withXhr()に戻したときに捨てられる指定
上りの進捗が必要なら、withXhr() を指定するのが公式の案内である。withXhr の型定義にも Use this feature if you want to report progress on uploads as the Xhr API supports it. と書かれている。
ただし XHR では、Fetch を前提にしたリクエストの指定が無視される。HttpXhrBackend.handle は開発モードで validateXhrCompatibility(req) を呼び、9個のプロパティを順に見て警告を出す。
const unsupportedOptions = [
{ property: 'keepalive', errorCode: 2813 },
{ property: 'cache', errorCode: 2814 },
{ property: 'priority', errorCode: 2815 },
{ property: 'mode', errorCode: 2816 },
{ property: 'redirect', errorCode: 2817 },
{ property: 'credentials', errorCode: 2818 },
{ property: 'integrity', errorCode: 2820 },
{ property: 'referrer', errorCode: 2821 },
{ property: 'referrerPolicy', errorCode: 2823 },
];出るのは console.warn で、本番ビルドでは ngDevMode の分岐に入らないため何も出ない。指定は黙って捨てられる。keepalive: true で離脱時の送信を担保していた計測の通信や、priority で優先度を下げていた通信が、withXhr() を足した時点から指定どおりに動かなくなる。
「アップロードだけ XHR」という分け方は、1つの provideHttpClient() の中ではできない。provideHttpClient() が返すのは EnvironmentProviders で、ルートの providers には置けるためルート単位では分けられるが、そのルートの HttpClient はインターセプターの構成も別になる。
@Reviewer: 上りの進捗のために `withXhr()` を足していますが、このアプリは計測の送信で `keepalive: true` を使っています。XHR では黙って無視されるため、離脱時のイベントが落ちます。進捗の表示が本当に必要かを決めたうえで、必要ならアップロードの画面のルートだけ別の `provideHttpClient()` へ分ける形を検討したいです。応答サイズの上限とサーバー側の実行
FetchBackend には応答の保持量に上限がある。決めているのは HTTP_FETCH_MAX_RESPONSE_SIZE というトークンで、公開 API としては ɵ 付きの名前(ɵHTTP_FETCH_MAX_RESPONSE_SIZE)でのみ出ている。既定値の factory は次の形である。
const DEFAULT_SSR_MAX_RESPONSE_BODY_SIZE = 1024 * 1024;
const HTTP_FETCH_MAX_RESPONSE_SIZE = new InjectionToken(/* ... */, {
factory: () => (typeof ngServerMode !== 'undefined' && ngServerMode)
? DEFAULT_SSR_MAX_RESPONSE_BODY_SIZE
: null,
});サーバーでの実行なら 1 MiB、ブラウザでは null(上限なし)である。判定は2段で入る。content-length ヘッダーが上限を超えていれば本体を読む前に body.cancel() し、ヘッダーが無い、あるいは信用できない場合は受信の累積が上限を超えた時点で reader.cancel() する。どちらも NG2825 を投げ、メッセージ(Fetch response body exceeded the configured buffer limit)は開発モードでしか付かない。
この非対称が意味するのは、1 MiB を超える応答を扱う通信が SSR の実行時だけ落ちるということである。ブラウザでは通る。一覧の全件取得や CSV の取り込みのように応答が伸びうる通信が、サーバー側のレンダリングに含まれているなら、上限を引き上げるか、その通信をブラウザ側へ寄せるかの判断が要る。21 までの XHR 既定では、この上限そのものが無かった。
21 の構成で問題が出なかったことは、22 の既定で問題が出ないことを示さない。レビューでは、SSR を使っている構成かどうかと、応答サイズに上限のある通信かどうかを対にして見る。
timeoutとエラーの形
timeout オプションはどちらのバックエンドでも使えるが、エラーの形が違う。
FetchBackend は setTimeout で AbortController を abort する。中断の理由として new DOMException('signal timed out', 'TimeoutError') を渡し、fetch の reject が catch 節へ入って HttpErrorResponse に包まれる。status は error.status ?? 0 で 0、statusText は init.statusText || 'Unknown Error' の既定どおり 'Unknown Error' になる。
HttpXhrBackend は xhr.timeout を設定し、timeout イベントで HttpErrorResponse を作る。こちらは statusText に 'Request timeout' が入る。
private describe(error: HttpErrorResponse): string {
if (error.statusText === 'Request timeout') {
@ReviewerFetch 既定では `statusText` が `'Unknown Error'` になるため、この分岐に入りません。`error.error instanceof DOMException && error.error.name === 'TimeoutError'` で判定してください。 return '時間内に応答がありませんでした';
}
return '通信に失敗しました';
}両方で共通して見られるのは error.error に入る DOMException の name で、どちらも 'TimeoutError' である。statusText やメッセージの文字列に依存した判定は、バックエンドを入れ替えた時点で静かに外れる。
timeout の値の検査は HttpRequest の側にあり、正の整数でなければ NG2822 で止まる。こちらは実行時に必ず出るので、レビューで追う対象ではない。
購読を解除したときの中断は、どちらのバックエンドでも起きる。Fetch は AbortController.abort() を呼び、XHR は readyState が DONE でなければ xhr.abort() を呼ぶ。switchMap で前の通信を捨てる書き方の挙動は、この点では変わらない。高階 Operator による中断の設計はRxJSのswitchMapが実際には何を止めないのかをレビューで確認するにある。
Fetch だけにある経路が1つある。本体を読んでいる途中で DestroyRef が破棄済みになると、FetchBackend は reader を cancel し、エラーではなく complete() で終える。値が1つも流れないまま正常終了するため、firstValueFrom() で受けていれば EmptyError になり、subscribe の next だけを見ている箇所では何も起きない。アプリの終了やテストの後始末でこの経路に入る。
JSONPと非推奨の整理
22.1 で JSONP が非推奨になった。理由は型定義のコメントに書かれていて、JSONP is deprecated as it can cause XSS vulnerabilities. である。対象は withJsonpSupport() と HttpClientJsonpModule、JsonpClientBackend、JsonpInterceptor で、将来の版で削除される予定になっている。
SSR で XHR を選ぶことにも別の注意が付いた。withXhr の型定義には、サーバーでの XHR 対応が非推奨であり Angular 23 で削除する予定だと書かれている。根拠として挙がっているのは、下敷きの xhr2 がリダイレクトを安全に扱わないこと(クロスオリジンのリダイレクトへ Authorization ヘッダーを転送しうること、リダイレクトのループでサービス不能に陥りうること)である。
ここで前節の話が戻ってくる。移行の schematic は、SSR を使っているアプリの provideHttpClient() にも withXhr() を足す。足された先がサーバー側でも実行される構成なら、削除予定かつ安全上の指摘がある経路を選んだことになる。移行直後の差分では、withXhr() がサーバー側の設定にも入っていないかを確かめる。
| 名前 | 状態 | 代わりに使うもの |
|---|---|---|
withFetch() |
非推奨(既定になったため) | 何も指定しない |
HttpRequest の reportProgress |
22.0 で非推奨 | reportUploadProgress / reportDownloadProgress |
withJsonpSupport() ほか JSONP 一式 |
22.1 で非推奨 | 通常の HTTP |
サーバーでの withXhr() |
非推奨、23 で削除予定 | Fetch(既定) |
httpResource の reportProgress |
非推奨ではない | — |
レビュー観点チェックリスト
provideHttpClient()にwithXhr()が入っているか。入っていれば、それが移行の結果か意図した選択かを確かめたかwithFetch()が残っていないか。残っていても挙動は変わらないが、非推奨の指定であるwithFetch()とwithXhr()が同じ呼び出しに並んでいないか(後ろが勝ち、例外は出ない)HttpClientModuleをインポートしている構成が残っていないか。残っていれば XHR のままであるreportProgress: trueで上りの進捗を表示している箇所が無いか。Fetch ではイベントが流れないHttpEventType.UploadProgressを読む箇所が、XHR を選んだ注入器の下にあるかwithXhr()を足した構成で、keepalive/priority/mode/cacheなどの Fetch 前提の指定を使っていないか- SSR の実行経路に 1 MiB を超えうる応答の通信が含まれていないか
timeoutのエラー判定がstatusTextの文字列ではなくDOMExceptionのnameに基づいているか- JSONP を使っている箇所が無いか
- サーバー側で実行される構成に
withXhr()が入っていないか
おわりに
既定の入れ替えは、挙動を保つ移行が付いている以上、単体では事故にならない。手当てが要るのは、保たれた挙動が差分の1行としてだけ記録される点にある。withXhr() が1行あるだけで、上りの進捗も Fetch 固有の指定も応答サイズの上限も、すべて 21 までの読み方で正しい。その1行を外す日が来たときに、何が同時に変わるかを先に並べておく作業になる。
通信とエラー処理の観点はAngular 22のRxJS運用と購読の寿命をレビューする100観点の「HttpClient と通信」と「インターセプター」に並べてある。取得だけの通信を httpResource へ寄せる判断についてはAngularのResourceでhasValue()を省いた実装をレビューで止めるが隣接する。