Angular 22でハイドレーションが既定になった後のSSR差分をレビューする

SSR を使うアプリで provideClientHydration() を1行足す形は 17 から変わっていない。変わったのは、その1行が何を有効にするかである。

22 の構成
bootstrapApplication(App, {
  providers: [provideClientHydration()],
});

19 から 21 までのコードには、ここに withIncrementalHydration() や withEventReplay() が並んでいることが多い。22.2.1 の @angular/platform-browser では前者が非推奨になり、後者は書かなくても積まれる。差分のレビューで問われるのは、feature を書き足したかではなく、既定で有効になった仕組みと描画のコードが噛み合っているかになる。

provideClientHydration() が既定で積むもの

実装は、渡された feature の種類を集めたうえで、積む provider を決める。

@angular/platform-browser 22.2.1 の provideClientHydration(抜粋)
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 で削除予定」と書かれている。

積まれる中身には、イベント再生が含まれる。

@angular/core 22.2.1 の withIncrementalHydration(先頭)
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つも含めないので、認証情報で内容が変わる応答をキャッシュしている構成では、サーバーが拾った応答がそのままクライアントへ渡る。

矛盾する組み合わせには検査があるが、開発モードに限られる。

22.2.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 の有無)だけで決まる。開発モードの警告だけで本番では黙って進む構造は、このシリーズで繰り返し出ている形である。

Comment
@Reviewer: `provideClientHydration(withIncrementalHydration(), withEventReplay())` は 22 ではどちらも既定に含まれます。設定の意図を読み違えやすいので、既定のままでよければ引数なしに揃えてください。切る意図があるなら `withNoIncrementalHydration()` を渡したうえで、イベント再生が要るかを決める必要があります。

サーバーとクライアントで分岐する描画

ハイドレーションはサーバーが出力した DOM を再利用する。クライアントの初回描画が別のノードを作ると、照合に失敗する。失敗したときの扱いは、ビルドの種類で変わる。

22.2.1 の locateOrCreateElementNodeImpl(抜粋)
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 が後から書き換えられている場合は別の経路で止まる。

22.2.1 の verifySsrContentsIntegrity(抜粋)
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 の有無で反転する。サーバー側では通常のトリガーが発火しない。

22.2.1 の shouldTriggerDeferBlock(先頭)
function shouldTriggerDeferBlock(triggerType, lView) {
  if (triggerType === 0 && typeof ngServerMode !== 'undefined' && ngServerMode) {
    return false;
  }

triggerType の 0 は通常のトリガーである。サーバーは本体を描かず、プレースホルダーを出力する。一方 hydrate のトリガー(2)はこの分岐を通らないため、ɵɵdeferHydrateOnIdle などが ngServerMode の枝で triggerDeferBlock(2, lView, tNode) を直接呼び、本体が出力される。

22.2.1 の ɵɵdeferHydrateOnIdle(抜粋)
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度だけ出る。

22.2.1 の ɵɵdefer(抜粋)
if (ngDevMode) {
  if (typeof ngHmrMode !== 'undefined' && ngHmrMode) {
    logHmrWarning(injector);
  }
  if (hasHydrateTriggers(flags) && !incrementalHydrationEnabled) {
    warnIncrementalHydrationNotConfigured();
  }
}

この警告(NG0508)も ngDevMode の中である。withNoIncrementalHydration() を渡した構成に hydrate 付きの @defer が残っていると、本番では何も出ないまま hydrate の指定が無視される。

通常のトリガーとhydrateトリガーの併記

1つの @defer に両方を書いた場合、どちらが見られるかは実行時に決まる。

22.2.1 の shouldAttachRegularTrigger
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つの処理が飛ぶ。

22.2.1 の serializeLContainer(抜粋)
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 の中にイベント束縛が無いことを実際に確かめる。コンポーネントを後から差し替えたときに束縛が増えても、ビルドは通り、警告も出ない。

Comment
@Reviewer: この `@defer (hydrate never)` の中に `app-share-button` が入りました。`hydrate never` の領域は直列化もイベント属性の付与も飛ばされるため、このボタンはクリックしても反応しません。ボタンを外すか、`hydrate on interaction` へ変えてください。

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

22 の既定を踏まえて SSR の差分を確かめる項目
  • 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・ハイドレーション・描画コスト」の群に並べてある。