Angularの@deferが永久に読み込まれない条件をレビューで見抜く

@defer でくるんだ部分は、トリガーが発火するまで読み込まれない。発火しなければプレースホルダーが出たままになる。ここに例外も警告も無い。待っている状態と、永久に待つ状態が、画面の上で同じに見える。

テンプレートの型検査が止めてくれる誤りは一部にすぎない。コンパイルを通ったうえで発火しない組み合わせがあり、そのうちいくつかは実行環境によって変わる。@angular/core 22.2.1 と @angular/compiler 22.2.1 の実装から、止まる条件を順に確かめる。

コンパイルで止まる3つ

まず型検査が見ている範囲を押さえる。on interaction と on hover と on viewport は参照を省略できて、省略した場合はプレースホルダーの要素が対象になる。その省略を検査しているのが次の関数である。

@angular/compiler 22.2.1 の validateReferenceBasedDeferredTrigger
validateReferenceBasedDeferredTrigger(block, trigger) {
  if (trigger.reference === null) {
    if (block.placeholder === null) {
      this.tcb.oobRecorder.deferImplicitTriggerMissingPlaceholder(this.tcb.id, trigger);
      return;
    }
    let rootNode = null;
    for (const child of block.placeholder.children) {
      if (!this.tcb.hostPreserveWhitespaces && child instanceof Text$3 && child.value.trim().length === 0) {
        continue;
      }
      if (rootNode === null) {
        rootNode = child;
      } else {
        rootNode = null;
        break;
      }
    }
    if (rootNode === null || !(rootNode instanceof Element$1)) {
      this.tcb.oobRecorder.deferImplicitTriggerInvalidPlaceholder(this.tcb.id, trigger);
    }
    return;
  }
  if (this.tcb.boundTarget.getDeferredTriggerTarget(block, trigger) === null) {
    this.tcb.oobRecorder.inaccessibleDeferredTriggerElement(this.tcb.id, trigger);
  }
}

通らない形は3つある。1つ目は @placeholder が無いときで、NG8019 になる。

Trigger with no target can only be placed on an @defer that has a @placeholder block

2つ目は @placeholder の中身が要素1つに収まっていないときで、NG8020 になる。空白だけのテキストは読み飛ばされるが、文字が入れば節点として数えられる。

Trigger with no target can only be placed on an @defer that has a @placeholder block with exactly one root element node

つまり @placeholder { 読み込み中 } と書いた @defer (on interaction) はコンパイルを通らない。<p>読み込み中</p> のように要素でくるむ必要がある。<ng-container> は要素として扱われないので、これも通らない。

3つ目は参照を書いたが届かないときで、NG8010 になる。

Deferred blocks can only access triggers in same view, a parent embedded view or the root view of the @placeholder block.

届く範囲は getDeferredTriggerTarget が決めている。参照が自分のスコープで見つかり、かつその宣言位置が @defer ブロック自身でないこと、または @placeholder の中で見つかることが条件である。@defer の本体に書いた参照を自分のトリガーに使う形は、この条件で落ちる。

条件分岐の中にある要素

ここから先はコンパイルを通る。DOM 系のトリガーは、実行時に要素を探して購読を仕掛ける。探し方が待ち続ける形になっている。

22.2.1 の registerDomTrigger
function registerDomTrigger(initialLView, tNode, triggerIndex, walkUpTimes, registerFn, callback, type, options) {
  if (!shouldTriggerDeferBlock(type, initialLView)) {
    return;
  }
  const injector = initialLView[INJECTOR];
  const zone = injector.get(NgZone);
  let poll;
  function pollDomTrigger() {
    if (isDestroyed(initialLView)) {
      poll.destroy();
      return;
    }
    const lDetails = getLDeferBlockDetails(initialLView, tNode);
    const renderedState = lDetails[DEFER_BLOCK_STATE];
    if (renderedState !== DeferBlockInternalState.Initial && renderedState !== DeferBlockState.Placeholder) {
      poll.destroy();
      return;
    }
    const triggerLView = getTriggerLView(initialLView, tNode, walkUpTimes);
    if (!triggerLView) {
      return;
    }
    poll.destroy();
    // ... 要素を取り出して registerFn で購読する
  }
  poll = afterEveryRender({ read: pollDomTrigger }, { injector });
}

afterEveryRender に登録された関数が、描画のたびに呼ばれる。要素の属するビューが取れなければ return するだけで、poll は生き続ける。要素が現れるまで何度でも試す作りである。

22.2.1 の getTriggerLView
function getTriggerLView(deferredHostLView, deferredTNode, walkUpTimes) {
  if (walkUpTimes == null) {
    return deferredHostLView;
  }
  if (walkUpTimes >= 0) {
    return walkUpViews(walkUpTimes, deferredHostLView);
  }
  const deferredContainer = deferredHostLView[deferredTNode.index];
  ngDevMode && assertLContainer(deferredContainer);
  const triggerLView = deferredContainer[CONTAINER_HEADER_OFFSET] ?? null;
  // ...
  return triggerLView;
}

待ち続ける作りは、要素が後から現れる場合に正しく働く。問題になるのは現れない場合である。参照先の要素が @if の偽の側にあると、ビューが作られないので購読は仕掛からない。@defer はプレースホルダーのまま止まり、警告は出ない。

コンパイルは通るが発火しない
@if (hasFilters()) {
  <button #moreButton>絞り込みを追加</button>
}

@defer (on interaction(moreButton)) {
  <app-filter-editor />
} @placeholder {
  <div class="skeleton"></div>
}

hasFilters() が偽のままなら、ボタンは存在しない。型検査は参照が同じビューにあることだけを見るので通る。この形を見たら、参照先が無条件に描画される位置にあるかを確かめる。条件付きで描画される要素をトリガーにしたいのであれば、@defer 自体を同じ @if の中に入れて、条件が真になったときだけ存在させる。

Comment
@Reviewer: トリガーの `#moreButton` が `@if (hasFilters())` の中にあります。偽のあいだはボタンのビューが作られないため、この `@defer` はプレースホルダーのまま止まります。型検査は同じビューにあることだけを見るので通ってしまう形です。`@defer` ごと同じ `@if` の中に移してください。

同じ理由で、@for の中の要素を外から参照する形も届かない。こちらは繰り返しのどの行を指すかが決まらないため、型検査の側で NG8010 になる。

発火し得ない viewport の指定

on viewport は共有の IntersectionObserver を使う。コールバックは交差しているときだけ通知を回す。

22.2.1 の createIntersectionObserver
function createIntersectionObserver(options) {
  const key = getIntersectionObserverKey(options);
  return new IntersectionObserver(entries => {
    for (const current of entries) {
      if (current.isIntersecting && viewportTriggers.has(current.target)) {
        viewportTriggers.get(current.target)?.get(key)?.listener();
      }
    }
  }, options);
}

22 から on viewport はオブジェクトリテラルでオプションを取れる。受け取った値はそのまま IntersectionObserver の第2引数になる。

@angular/compiler 22.2.1 の createViewportTrigger(検査部)
if (!(parsed.ast instanceof LiteralMap)) {
  throw new Error('Options parameter of the "viewport" trigger must be an object literal');
} else if (parsed.ast.keys.some(key => key.kind === 'spread')) {
  throw new Error('Spread operator are not allowed in this context');
} else if (parsed.ast.keys.some(key => key.kind === 'property' && key.key === 'root')) {
  throw new Error('The "root" option is not supported in the options parameter of the "viewport" trigger');
}

root は書けない。trigger は参照として取り出され、残りがオプションとして渡る。値はリテラルに限られ、signal から渡すことはできない。

ここで threshold を書くと、発火し得ない指定が作れる。threshold: 1 は対象の全体が交差したときにだけ通知する意味になるので、対象がビューポートより高ければ比率は 1 に届かない。プレースホルダーの高さを画面いっぱいに取る実装と組み合わせると、画面の大きさによって発火するかどうかが変わる。レビューでは threshold に 0 以外が書かれている差分を、対象の高さと合わせて見る。

対象の高さは 0 でも問題になる。プレースホルダーに何も入れずに要素だけ置くと、高さを持たないので画面の中に入っても交差の判定が成立しない。@placeholder には寸法を持つ要素を置く。

interaction と hover が購読するイベント

発火する操作の範囲は、購読するイベント名で決まっている。

22.2.1 のイベント名
const interactionEventNames = ['click', 'keydown'];
const hoverEventNames = ['mouseenter', 'mouseover', 'focusin'];

on interaction は click と keydown を対象の要素に仕掛ける。keydown が入っているので、キーボードからも発火できる作りである。ただし keydown は焦点のある要素から伝播するので、対象の要素が焦点を受け取れなければ届かない。<div> をプレースホルダーに置いた場合、マウスでは発火してキーボードでは発火しない。操作でしか開かない内容を @defer に入れるなら、プレースホルダーは <button> にするか、tabindex を持つ要素にする。

on hover の側には focusin が入っている。こちらは焦点が内側に入ったときにも発火するので、焦点を受け取れる要素が中にあればキーボードでも開く。mouseenter は伝播しないが mouseover が伝播するため、子要素の上に乗せた場合も発火する。

Comment
@Reviewer: `on interaction` のプレースホルダーが `
` です。購読されるのは `click` と `keydown` で、`keydown` は焦点のある要素から伝播するため、焦点を受け取れないこの `
` ではキーボード操作で開けません。`

hydrate トリガーを併記したとき

hydrate on ... を書いた @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;
}

ブラウザ側で false が返るのは、hydrate トリガーがあり、そのブロックがサーバーで描画されており、段階的ハイドレーションが有効なときである。この3つが揃うと通常のトリガーは1つも仕掛からない。hydrate の側だけが働く。

逆に、同じテンプレートをクライアント側の遷移で描画した場合は wasServerSideRendered が偽になるので、通常のトリガーが仕掛かり、hydrate の側は使われない。つまり併記した @defer は、最初に開いた画面と遷移してきた画面で別の条件で開く。

条件が入口で変わる書き方
@defer (when ready(); hydrate on viewport) {
  <app-recommendations />
} @placeholder {
  <div class="placeholder-block"></div>
}

直接このURLを開いた場合は hydrate on viewport だけが見られ、ready() は参照されない。別の画面から遷移してきた場合は when ready() だけが見られる。ready() が真になる条件とスクロールで画面に入る条件のどちらかしか満たせない作りだと、入口によって開かない側ができる。レビューでは併記そのものを疑うのではなく、2つの条件がそれぞれ単独で満たせるかを確かめる。

段階的ハイドレーションは 22 で既定になった。provideClientHydration(withNoIncrementalHydration()) で外している場合は incrementalHydrationEnabled が偽になり、hydrate トリガーは無視されて通常のトリガーだけが働く。移行の schematic(incremental-hydration)がこの退避を挿入しているリポジトリでは、hydrate と書いても効果が無い。

Comment
@Reviewer: `hydrate on interaction` を足した差分ですが、このアプリは `ng update` が挿入した `withNoIncrementalHydration()` が残っています。段階的ハイドレーションが無効なあいだ `hydrate` トリガーは見られないため、この指定は何もしません。退避を外す判断と合わせて進めてください。

when が片方向であること

when は依存が変わったときに評価される通常のバインディングで、真になれば本体を読み込む。

22.2.1 の ɵɵdeferWhen(判定部)
const value = Boolean(rawValue);
const lDetails = getLDeferBlockDetails(lView, tNode);
const renderedState = lDetails[DEFER_BLOCK_STATE];
if (value === false && renderedState === DeferBlockInternalState.Initial) {
  renderPlaceholder(lView, tNode);
} else if (value === true && (renderedState === DeferBlockInternalState.Initial || renderedState === DeferBlockState.Placeholder)) {
  triggerDeferBlock(0, lView, tNode);
}

偽でプレースホルダーに戻す経路は、状態が Initial のときにしか通らない。一度読み込んだあとに条件が偽へ戻っても、本体は出たままになる。@defer (when isOpen()) と書いて開閉を表そうとすると、閉じない。開閉は @if で表し、@defer は「一度だけ読み込む」ための指定として使う。

この片方向性は、止まる条件の側にも関わる。when が参照している signal が一度も真にならなければ、何も起きない。トリガーが when だけの @defer は、条件を満たす経路が本当にあるかを読む。

テストでトリガーが仕掛からない設定

registerDomTrigger の冒頭には、購読を仕掛ける前に抜ける条件がある。

22.2.1 の shouldTriggerDeferBlock
function shouldTriggerDeferBlock(triggerType, lView) {
  if (triggerType === 0 && typeof ngServerMode !== 'undefined' && ngServerMode) {
    return false;
  }
  const injector = lView[INJECTOR];
  const config = injector.get(DEFER_BLOCK_CONFIG, null, { optional: true });
  if (config?.behavior === DeferBlockBehavior.Manual) {
    return false;
  }
  return true;
}

テストの既定は Playthrough で、ブラウザと同じようにトリガーが働く。TestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Manual }) を書いた場合はこの関数が偽を返し、DOM 系のトリガーは1つも仕掛からない。DeferBlockFixture から状態を指定して確かめる書き方に揃っていればよいが、Manual を一律に指定したうえで要素への操作やスクロールを再現して確かめようとしている箇所があれば、そのテストは待っているだけで何も検証していない。

Comment
@Reviewer: このテストは `deferBlockBehavior: DeferBlockBehavior.Manual` を共通の設定に置いたまま、プレースホルダーの `

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

@defer が止まる条件を確かめる項目
  • 参照を省略したトリガーに対し、@placeholder が要素1つになっているか(テキストや <ng-container> では通らない)
  • トリガーの参照先が、条件分岐の外にある要素か
  • トリガーの参照先が条件付きなら、@defer も同じ条件の中にあるか
  • on viewport の threshold が 0 以外のとき、対象の高さがビューポートに収まるか
  • @placeholder の要素が寸法を持っているか(高さ 0 では交差が成立しない)
  • on interaction のプレースホルダーが焦点を受け取れる要素か(keydown の経路)
  • hydrate トリガーと通常のトリガーを併記した場合、2つの条件がそれぞれ単独で満たせるか
  • withNoIncrementalHydration() が残っているリポジトリで hydrate トリガーを足していないか
  • when で開閉を表していないか(偽へ戻しても本体は消えない)
  • when が参照する条件が真になる経路が実際にあるか
  • テストで deferBlockBehavior を Manual にしていないか(DOM 系のトリガーが仕掛からない)

おわりに

止まる経路は3つに分けられる。要素が見つからない経路、交差や操作の条件を満たせない経路、トリガー自体が仕掛からない経路である。1つ目は afterEveryRender の走査が要素を待ち続ける作りから来て、条件分岐の中にある参照先で起きる。2つ目は threshold の指定、寸法を持たないプレースホルダー、焦点を受け取れない要素で起きる。3つ目は hydrate との併記とテストの既定の変更で起きる。

どれも例外にならない。プレースホルダーが出たままであることが、読み込み中なのか止まっているのかを区別しないためである。差分の上で区別するには、トリガーごとに「この条件を満たす操作が実際に起きるか」を1つずつ言葉にする。参照先は常に在るか、交差は起きるか、キーボードで届くか、この入口でそのトリガーが見られるか。言葉にできないトリガーが残ったら、そこが止まる箇所である。

テンプレート制御フローの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「テンプレート制御フロー」の群に並べてある。