Angular 22でハイドレーションが既定になった後のSSR差分をレビューする
Angular 22でハイドレーションが既定になった後のSSR差分をレビューする
SSR を使うアプリで provideClientHydration() を1行足す形は 17 から変わっていない。変わったのは、その1行が何を有効にするかである。
bootstrapApplication(App, {
providers: [provideClientHydration()],
});19 から 21 までのコードには、ここに withIncrementalHydration() や withEventReplay() が並んでいることが多い。22.2.1 の @angular/platform-browser では前者が非推奨になり、後者は書かなくても積まれる。差分のレビューで問われるのは、feature を書き足したかではなく、既定で有効になった仕組みと描画のコードが噛み合っているかになる。
provideClientHydration() が既定で積むもの
実装は、渡された feature の種類を集めたうえで、積む provider を決める。
return makeEnvironmentProviders([
typeof ngDevMode !== 'undefined' && ngDevMode ? provideEnabledBlockingInitialNavigationDetector() : [],
typeof ngDevMode !== 'undefined' && ngDevMode ? provideStabilityDebugging() : [],
_withDomHydration(),
featuresKind.has(HydrationFeatureKind.NoHttpTransferCache) || hasHttpTransferCacheOptions ? [] : _withHttpTransferCache({}),
featuresKind.has(HydrationFeatureKind.NoIncrementalHydration) ? [] : _withIncrementalHydration(),
providers,
// ...インクリメンタルハイドレーションが積まれない条件は withNoIncrementalHydration() を渡した場合だけである。withIncrementalHydration() を渡しても同じ provider が足されるので挙動は変わらず、型定義の @deprecated に「22.0.0 から既定で有効、24 で削除予定」と書かれている。
積まれる中身には、イベント再生が含まれる。
function withIncrementalHydration() {
const providers = [withEventReplay(), {
provide: IS_INCREMENTAL_HYDRATION_ENABLED,
useValue: true
}, {
provide: DEHYDRATED_BLOCK_REGISTRY,
useFactory: createDehydratedBlockRegistry
}];したがって withEventReplay() の記述は 22 では冗長である。逆に、withNoIncrementalHydration() でインクリメンタルハイドレーションを切ると、イベント再生も一緒に消える。ハイドレーション完了前のクリックを拾いたいなら、切ると決めた時点で withEventReplay() を明示する必要がある。
HTTP の転送キャッシュも、withNoHttpTransferCache() か withHttpTransferCacheOptions() を渡さない限り _withHttpTransferCache({}) で入る。既定の設定ではヘッダーを1つも含めないので、認証情報で内容が変わる応答をキャッシュしている構成では、サーバーが拾った応答がそのままクライアントへ渡る。
矛盾する組み合わせには検査があるが、開発モードに限られる。
if (featuresKind.has(HydrationFeatureKind.IncrementalHydration) && featuresKind.has(HydrationFeatureKind.NoIncrementalHydration)) {
throw new _RuntimeError(5001, 'Configuration error: found both withIncrementalHydration() and withNoIncrementalHydration() in the same call to provideClientHydration(), which is a contradiction.');
}本番ビルドでは ngDevMode が偽になるため検査が走らず、後ろの判定(NoIncrementalHydration の有無)だけで決まる。開発モードの警告だけで本番では黙って進む構造は、このシリーズで繰り返し出ている形である。
@Reviewer: `provideClientHydration(withIncrementalHydration(), withEventReplay())` は 22 ではどちらも既定に含まれます。設定の意図を読み違えやすいので、既定のままでよければ引数なしに揃えてください。切る意図があるなら `withNoIncrementalHydration()` を渡したうえで、イベント再生が要るかを決める必要があります。サーバーとクライアントで分岐する描画
ハイドレーションはサーバーが出力した DOM を再利用する。クライアントの初回描画が別のノードを作ると、照合に失敗する。失敗したときの扱いは、ビルドの種類で変わる。
const native = locateNextRNode(hydrationInfo, tView, lView, tNode);
ngDevMode && validateMatchingNode(native, Node.ELEMENT_NODE, name, lView, tNode);
ngDevMode && markRNodeAsClaimedByHydration(native);
// ...
if (hydrationInfo) {
if (native == null) {
throw new RuntimeError(-502, ngDevMode && (() => { /* 期待したタグ名を含む文面 */ })());
}
if (native.nodeType !== Node.ELEMENT_NODE) {
throw new RuntimeError(-500, ngDevMode && `During hydration Angular expected an element at this location, but found a ${describeDomNode(native)} node instead.`);
}タグ名まで突き合わせる validateMatchingNode は ngDevMode の中にある。本番ビルドで残るのは、ノードが見つからない場合(-502)と、見つかったノードが要素でない場合(-500)の2つだけで、どちらもメッセージが ngDevMode && で消えるためコードだけが残る。要素はあるがタグが違う差分は、本番では例外にならず、その要素をそのまま使って束縛が進む。開発環境で一度も踏んでいない分岐があると、本番だけ画面が噛み合わない状態になりうる。
分岐を生むのは、サーバーとクライアントで値が変わる式である。Date.now()、乱数、window の参照、保存済みの表示設定の読み出しが、テンプレートの @if の条件に入っていれば、サーバーとクライアントで作るノードの数が変わる。
@Component({
template: `
@if (isNarrow) {
<app-mobile-nav />
} @else {
<app-desktop-nav />
}
`,
})
export class Shell {
readonly isNarrow = typeof window !== 'undefined' && window.innerWidth < 768;
@Reviewerサーバーでは `window` が無いため常に `false` になり、デスクトップ向けのノードが出力されます。クライアントの初回描画で `true` になる端末では、ハイドレーションの照合が外れます。}ノードの数が合っていても、HTML が後から書き換えられている場合は別の経路で止まる。
throw new RuntimeError(-507, typeof ngDevMode !== 'undefined' && ngDevMode && 'Angular hydration logic detected that HTML content of this page was modified after it ' + 'was produced during server side rendering. Make sure that there are no optimizations ' + 'that remove comment nodes from HTML enabled on your CDN. Angular hydration ' + 'relies on HTML produced by the server, including whitespaces and comment nodes.');ハイドレーションはコメントノードと空白を含めてサーバーの出力に依存する。CDN や中間のプロキシで HTML を最小化している構成は、アプリ側のコードが正しくても成立しない。SSR を足す差分では、配信経路の設定もレビューの対象に入る。
hydrate トリガーが付いた @defer のサーバー描画
@defer の扱いは、hydrate の有無で反転する。サーバー側では通常のトリガーが発火しない。
function shouldTriggerDeferBlock(triggerType, lView) {
if (triggerType === 0 && typeof ngServerMode !== 'undefined' && ngServerMode) {
return false;
}triggerType の 0 は通常のトリガーである。サーバーは本体を描かず、プレースホルダーを出力する。一方 hydrate のトリガー(2)はこの分岐を通らないため、ɵɵdeferHydrateOnIdle などが ngServerMode の枝で triggerDeferBlock(2, lView, tNode) を直接呼び、本体が出力される。
const hydrateTriggers = getHydrateTriggers(getTView(), tNode);
hydrateTriggers.set(0, null);
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
triggerDeferBlock(2, lView, tNode);
} else {
scheduleDelayedHydrating(onIdleWrapper({
timeout
}), lView, tNode);
}つまり hydrate を付けた領域は、初回の HTML に中身が入り、JavaScript の読み込みだけが遅れる。付けなければ初回の HTML はプレースホルダーのままで、本文は検索エンジンにもスクリーンリーダーにも出ない。どちらを選ぶかは、その領域が初回表示に必要かどうかで決まる。
クライアント側の待ち方は、通常のトリガーと同じ仕掛けを使う。hydrate on idle は onIdleWrapper を通り、requestIdleCallback が無い環境では cb => setTimeout(cb) に落ちて期限が消える。この代替経路の挙動はAngular 22のinjectAsyncで遅延させてよい依存の線引きで確かめた。
hydrate を書いたのにインクリメンタルハイドレーションが無効な場合は、警告が1度だけ出る。
if (ngDevMode) {
if (typeof ngHmrMode !== 'undefined' && ngHmrMode) {
logHmrWarning(injector);
}
if (hasHydrateTriggers(flags) && !incrementalHydrationEnabled) {
warnIncrementalHydrationNotConfigured();
}
}この警告(NG0508)も ngDevMode の中である。withNoIncrementalHydration() を渡した構成に hydrate 付きの @defer が残っていると、本番では何も出ないまま hydrate の指定が無視される。
通常のトリガーとhydrateトリガーの併記
1つの @defer に両方を書いた場合、どちらが見られるかは実行時に決まる。
function shouldAttachRegularTrigger(lView, tNode) {
const injector = lView[INJECTOR];
const tDetails = getTDeferBlockDetails(lView[TVIEW$1], tNode);
const incrementalHydrationEnabled = isIncrementalHydrationEnabled(injector);
const _hasHydrateTriggers = hasHydrateTriggers(tDetails.flags);
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
return !incrementalHydrationEnabled || !_hasHydrateTriggers;
}
const lDetails = getLDeferBlockDetails(lView, tNode);
const wasServerSideRendered = lDetails[SSR_UNIQUE_ID] !== null;
if (_hasHydrateTriggers && wasServerSideRendered && incrementalHydrationEnabled) {
return false;
}
return true;
}hydrate のトリガーが見られるのは、この関数が false を返すときである(shouldAttachTrigger が種類 2 に対して否定を返す)。条件は3つの積で、hydrate の指定があり、その位置がサーバーで描画されており、インクリメンタルハイドレーションが有効であることを要求する。
成立しない代表例は、クライアント側の遷移で初めて描かれた場合である。SSR_UNIQUE_ID が null なので wasServerSideRendered が偽になり、通常のトリガーが使われる。
@defer (on viewport; hydrate on interaction) {
<app-review-form />
} @placeholder {
<app-review-form-skeleton />
}この領域は、URL を直接開いた読者には hydrate on interaction で、一覧から遷移してきた読者には on viewport で読み込まれる。両方の条件が要件として意図されているかを確かめる。片方だけを想定して書いていると、もう一方の入口で「見えているのに操作できない時間」の長さが変わる。トリガーが成立しないまま止まる経路はAngularの@deferが永久に読み込まれない条件をレビューで見抜くに並べた。
hydrate neverを選んだ領域
hydrate never は、サーバーで描いた DOM をそのまま残し、ハイドレーションの対象から外す指定である。直列化の段階で2つの処理が飛ぶ。
if (!isHydrateNeverBlock) {
annotateDeferBlockRootNodesWithJsAction(tDetails, rootNodes, deferBlockId, context);
}
parentDeferBlockId = deferBlockId;
serializedView[DEFER_BLOCK_ID] = deferBlockId;
// ...
if (!isHydrateNeverBlock) {
const childHostElement = unwrapRNode(childLView[HOST]);
if (childLView[TVIEW].type !== 1 || childHostElement === null || !childHostElement.hasAttribute(SKIP_HYDRATION_ATTR_NAME)) {
Object.assign(serializedView, serializeLView(lContainer[i], parentDeferBlockId, context));
}
}内部のビューが直列化されず、イベントを拾うための属性も付かない。残るのは HTML だけで、その領域の (click) や [formField] は最後まで動かない。静的な文章や画像には向くが、ボタンや入力を含む領域に付けると、押せそうに見えて何も起きない画面になる。
レビューでは、hydrate never の中にイベント束縛が無いことを実際に確かめる。コンポーネントを後から差し替えたときに束縛が増えても、ビルドは通り、警告も出ない。
@Reviewer: この `@defer (hydrate never)` の中に `app-share-button` が入りました。`hydrate never` の領域は直列化もイベント属性の付与も飛ばされるため、このボタンはクリックしても反応しません。ボタンを外すか、`hydrate on interaction` へ変えてください。レビュー観点チェックリスト
withIncrementalHydration()が残っていないか(22 で非推奨、既定で有効)withEventReplay()を書いた意図が、既定との重複を知った上のものかwithNoIncrementalHydration()を渡した構成で、イベント再生の必要を判断したか- 転送キャッシュが既定で入ることを踏まえ、認証で内容が変わる応答を含めていないか
@ifの条件にwindowやDate.now()のようなサーバーで値が変わる式が入っていないかdocumentを直接触っている箇所がDOCUMENT経由になっているか- HTML を最小化する設定が配信経路に無いか(コメントノードの削除で NG0507 になる)
- 初回表示に必要な領域の
@deferにhydrateが付いているか hydrate付きの@deferが、インクリメンタルハイドレーションを切った構成に残っていないか- 通常のトリガーと
hydrateのトリガーを併記した領域で、両方の入口を想定しているか hydrate neverの中にイベント束縛やフォームが入っていないかngSkipHydrationを付けた範囲が、サードパーティの DOM 操作に限られているか
おわりに
22 の provideClientHydration() は、DOM の再利用に加えてインクリメンタルハイドレーション、イベント再生、HTTP の転送キャッシュを積む。feature を足す方向の指定は withIncrementalHydration() が非推奨になり、残っているのは減らす方向の withNoIncrementalHydration() と withNoHttpTransferCache() である。差分のレビューでは、何を足したかではなく、既定で入ったものを前提にコードが書かれているかを見る。
照合が外れたときの扱いは、ビルドの種類で変わる。タグ名を突き合わせる検査は開発モードだけで、本番ではノードの不在と種類の違いしか例外にならない。サーバーとクライアントで分岐する式は、本番で初めて噛み合わない形で現れうる。
@defer については、hydrate の有無がサーバー出力の中身を決める。hydrate 付きなら本体が HTML に入り、無ければプレースホルダーが入る。併記した場合はサーバーで描かれたかどうかで見られる側が変わり、hydrate never では直列化とイベント属性の付与が飛ぶ。どれも警告なしに成立するので、指定とその領域の性質が合っているかはレビューで確かめることになる。
SSR と描画コストの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「SSR・ハイドレーション・描画コスト」の群に並べてある。