Angularの@deferが永久に読み込まれない条件をレビューで見抜く
Angularの@deferが永久に読み込まれない条件をレビューで見抜く
@defer でくるんだ部分は、トリガーが発火するまで読み込まれない。発火しなければプレースホルダーが出たままになる。ここに例外も警告も無い。待っている状態と、永久に待つ状態が、画面の上で同じに見える。
テンプレートの型検査が止めてくれる誤りは一部にすぎない。コンパイルを通ったうえで発火しない組み合わせがあり、そのうちいくつかは実行環境によって変わる。@angular/core 22.2.1 と @angular/compiler 22.2.1 の実装から、止まる条件を順に確かめる。
コンパイルで止まる3つ
まず型検査が見ている範囲を押さえる。on interaction と on hover と on viewport は参照を省略できて、省略した場合はプレースホルダーの要素が対象になる。その省略を検査しているのが次の関数である。
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 系のトリガーは、実行時に要素を探して購読を仕掛ける。探し方が待ち続ける形になっている。
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 は生き続ける。要素が現れるまで何度でも試す作りである。
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 の中に入れて、条件が真になったときだけ存在させる。
@Reviewer: トリガーの `#moreButton` が `@if (hasFilters())` の中にあります。偽のあいだはボタンのビューが作られないため、この `@defer` はプレースホルダーのまま止まります。型検査は同じビューにあることだけを見るので通ってしまう形です。`@defer` ごと同じ `@if` の中に移してください。同じ理由で、@for の中の要素を外から参照する形も届かない。こちらは繰り返しのどの行を指すかが決まらないため、型検査の側で NG8010 になる。
発火し得ない viewport の指定
on viewport は共有の IntersectionObserver を使う。コールバックは交差しているときだけ通知を回す。
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引数になる。
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 が購読するイベント
発火する操作の範囲は、購読するイベント名で決まっている。
const interactionEventNames = ['click', 'keydown'];
const hoverEventNames = ['mouseenter', 'mouseover', 'focusin'];on interaction は click と keydown を対象の要素に仕掛ける。keydown が入っているので、キーボードからも発火できる作りである。ただし keydown は焦点のある要素から伝播するので、対象の要素が焦点を受け取れなければ届かない。<div> をプレースホルダーに置いた場合、マウスでは発火してキーボードでは発火しない。操作でしか開かない内容を @defer に入れるなら、プレースホルダーは <button> にするか、tabindex を持つ要素にする。
on hover の側には focusin が入っている。こちらは焦点が内側に入ったときにも発火するので、焦点を受け取れる要素が中にあればキーボードでも開く。mouseenter は伝播しないが mouseover が伝播するため、子要素の上に乗せた場合も発火する。
@Reviewer: `on interaction` のプレースホルダーが `` です。購読されるのは `click` と `keydown` で、`keydown` は焦点のある要素から伝播するため、焦点を受け取れないこの `` ではキーボード操作で開けません。`hydrate トリガーを併記したとき
hydrate on ... を書いた @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;
}ブラウザ側で 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 と書いても効果が無い。
@Reviewer: `hydrate on interaction` を足した差分ですが、このアプリは `ng update` が挿入した `withNoIncrementalHydration()` が残っています。段階的ハイドレーションが無効なあいだ `hydrate` トリガーは見られないため、この指定は何もしません。退避を外す判断と合わせて進めてください。when が片方向であること
when は依存が変わったときに評価される通常のバインディングで、真になれば本体を読み込む。
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 の冒頭には、購読を仕掛ける前に抜ける条件がある。
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 を一律に指定したうえで要素への操作やスクロールを再現して確かめようとしている箇所があれば、そのテストは待っているだけで何も検証していない。
@Reviewer: このテストは `deferBlockBehavior: DeferBlockBehavior.Manual` を共通の設定に置いたまま、プレースホルダーの `レビュー観点チェックリスト
- 参照を省略したトリガーに対し、
@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観点の「テンプレート制御フロー」の群に並べてある。