Angularのsignalクエリがundefinedを返す経路とレビュー観点

@ViewChild から viewChild() への置き換えは機械的に見える。デコレーターを外して関数の呼び出しに書き換え、参照する側に () を足す。型も ElementRef | undefined と出るので、?. を付けて回れば型検査は通る。

通ったあとに残るのが、その undefined をいつ受け取るのかという問題である。デコレーターのクエリは、変更検知の中で一致を取り直してプロパティへ書き込む。signal クエリは書き込みを行わず、読んだ時点で一致を取り直す。この違いから、undefined が返る経路は置き換えの前とは変わる。

読むたびに一致を取り直す

@angular/core 22.2.1 の実装では、クエリの signal は computed である。

クエリのsignalの作り方
const signalFn = createComputed(() => {
  node._dirtyCounter();
  const value = refreshSignalQuery(node, firstOnly);
  if (required && value === undefined) {
    throw new RuntimeError(-951, ngDevMode && 'Child query result is required but no value is available.');
  }
  return value;
});

依存は _dirtyCounter という内部の signal 1つだけで、これは QueryList が dirty になったときに増える。

束縛の時点で仕掛ける通知
function bindQueryToSignal(target, queryIndex) {
  const node = target[SIGNAL];
  node._lView = getLView();
  node._queryIndex = queryIndex;
  node._queryList = loadQueryInternal(node._lView, queryIndex);
  node._queryList.onDirty(() => node._dirtyCounter.update(v => v + 1));
}

@if が切り替わって要素が作り直されると dirty が立ち、カウンターが増えて computed が無効になる。次に読んだ時点で一致が取り直される。したがって ngOnInit でクエリの結果を普通のプロパティへ写した実装は、そこで時間が止まる。差し替わった要素を追えなくなるうえ、写した時点の値が undefined ならその undefined が残る。読む側は毎回 signal として読む形に保つ。

ビューの生成中に読んだとき

一致が取り直される関数には、結果を見る前に抜ける条件がある。

解決の入口
function refreshSignalQuery(node, firstOnly) {
  const lView = node._lView;
  const queryIndex = node._queryIndex;
  if (lView === undefined || queryIndex === undefined || lView[FLAGS] & 4) {
    return firstOnly ? undefined : EMPTY_ARRAY;
  }
  // 一致の収集へ進む
}

3つ目の lView[FLAGS] & 4 は、そのビューが生成中であることを表す。同じビットを見ている関数が isCreationMode という名前で別に置かれている。生成中の印が落ちるのは、ビューの生成を行う renderView の最後である。

renderViewの終わり
} finally {
  lView[FLAGS] &= -5;
  leaveView();
}

つまりコンストラクターやフィールドの初期化子からクエリの signal を読むと、要素の節点がすでに作られていても undefined が返る。viewChild.required() なら NG0951 の例外になる。クエリを読む処理は afterNextRender や effect のような、生成の外で動く位置に置くことになる。

生成中に読んでしまう形
export class Chart {
  private readonly canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
  private readonly ctx = this.canvas().nativeElement.getContext('2d'); // NG0951
}
生成の外へ出した形
export class Chart {
  private readonly canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');

  constructor() {
    afterNextRender(() => {
      const ctx = this.canvas().nativeElement.getContext('2d');
      // 描画の初期化
    });
  }
}

required の例外が computed の中から投げられる点も、読む位置の判断に関わる。例外が出るのは宣言した行ではなく最初に読んだ行である。テンプレートの中で読んでいれば描画の最中に投げられ、effect の中で読んでいればその effect の実行時に投げられる。@if の内側にある要素へ required を付けた実装は、条件が偽の間ずっと例外を出し続ける。required は「必ず描画される要素」に限る、という線引きがここから出てくる。

束縛されていないフィールド

最初の2つの条件、_lView === undefined と _queryIndex === undefined は、bindQueryToSignal が呼ばれていない状態を表す。呼ぶのはコンパイラが生成した命令である。

生成コードから呼ばれる命令
function ɵɵviewQuerySignal(target, predicate, flags, read) {
  bindQueryToSignal(target, createViewQuery(predicate, flags, read));
  return ɵɵviewQuerySignal;
}

述語も read もこの命令の引数で渡される。viewChild('canvas') の呼び出しが実行時に受け取った文字列は、使われないまま捨てられる。実行時の値から探す対象を決める書き方は成立しない。

成立しない書き方
export class Panel {
  private readonly refName = input<string>('body');
  private readonly target = viewChild(this.refName()); // 引数は無視される
}

束縛が起きるのは、コンポーネントやディレクティブのクラスフィールドに書いた初期化子だけである。サービスの中で呼んだ場合、クラスフィールドから関数の戻り値として間接的に作った場合、条件分岐で作り分けた場合は、どれも束縛されない。開発モードでは assertInInjectionContext が注入コンテキストの外での呼び出しを捕まえるが、これは注入コンテキストの中かどうかを見ているだけで、クエリとして束縛されるかどうかは見ていない。コンポーネントのコンストラクターの中で viewChild() を呼べば検査は通り、結果は常に undefined を返す signal になる。しかも contentChildren() にはこの検査自体が無い。

レビューでは、クエリの宣言がクラスフィールドの初期化子になっているかを形で確かめる。ここを満たしていない宣言は、型が合っていて例外も出ないまま、何も見つけない。

一致が落ちる位置

ここまでの条件を抜けると、一致の収集に進む。収集の対象を決めているのは descendants の指定である。

対象かどうかの判定
isApplyingToNode(tNode) {
  if (this._appliesToNextNode && (this.metadata.flags & 1) !== 1) {
    const declarationNodeIdx = this._declarationNodeIndex;
    let parent = tNode.parent;
    while (parent !== null && parent.type & 8 && parent.index !== declarationNodeIdx) {
      parent = parent.parent;
    }
    return declarationNodeIdx === (parent !== null ? parent.index : -1);
  }
  return this._appliesToNextNode;
}

descendants が偽のとき、親をたどって宣言した節点に行き着くかを見る。途中で飛ばされるのは type & 8 の節点、つまり <ng-container> である。contentChild('item', { descendants: false }) は <ng-container> をまたいだ先までは見るが、<div> で1段包んだ先は見ない。投影の受け手を <div> で囲う変更が入ると、クエリの結果だけが静かに undefined へ変わる。viewChild には descendants の指定が無く、常に自分のテンプレート全体が対象である。

もう1つ、read を付けたときに一致そのものが落ちる経路がある。

readの解決
if (read === ElementRef || read === ViewContainerRef || read === Injector
    || read === TemplateRef && tNode.type & 4) {
  this.addMatch(tNode.index, -2);
} else {
  const directiveOrProviderIdx = locateDirectiveOrProvider(tNode, tView, read, false, false);
  if (directiveOrProviderIdx !== null) {
    this.addMatch(tNode.index, directiveOrProviderIdx);
  }
}

ElementRef と ViewContainerRef と Injector、それに <ng-template> に対する TemplateRef は特別扱いで必ず取れる。それ以外を read に書いた場合、その節点にそのディレクティブか provider が無ければ一致は登録されない。要素は一致しているのに結果が空になる状態である。例外も警告も出ないため、{ read: SomeService } と書いた差分は、そのサービスがその要素の providers に居るかを合わせて確かめる。

文字列の述語は , で分割される。viewChild('primary, secondary') はどちらかの参照変数に一致し、テンプレート上で先に現れたものが first として返る。2つの参照を1つのクエリで受ける書き方は、どちらが返るかがテンプレートの順序に依存する。

要素が存在しない間の表示

一致が無い場合の undefined は、仕組みではなく設計の問題である。@if や @defer の内側にある要素を指すクエリは、条件が成立するまで undefined を返す。この undefined を ?. で流して回ると、初期化の処理が「呼ばれなかった」ことに気付けない。条件と対応づけて読む形にする。

存在と初期化を対応づける
export class Editor {
  private readonly area = viewChild<ElementRef<HTMLTextAreaElement>>('area');

  constructor() {
    effect(() => {
      const el = this.area();
      if (el === undefined) {
        return; // まだ描画されていない
      }
      el.nativeElement.focus();
    });
  }
}

effect は area() を依存として記録するので、@if が真になって要素ができた回に改めて走る。afterNextRender を1回だけ仕掛ける形では、@defer の読み込みが終わる前に走り終えてしまう。どちらを使うかは、要素が出入りするかで決まる。出入りする要素には effect、最初から描画される要素には afterNextRender を選ぶ。effect と computed の使い分けの判断はAngularのeffectを状態同期に使うコードをレビューで止めるにまとめた。

viewChildren() の側は undefined を返さず、空配列を返す。空配列も EMPTY_ARRAY という共有の定数で、一致が無い間は同じ参照が返る。長さを見て分岐する処理は書けるが、結果を ngOnInit で配列へ写すと更新が止まる点は単数のクエリと同じである。

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

signalクエリを確かめる項目
  • クエリの宣言が、コンポーネントかディレクティブのクラスフィールドの初期化子になっているか
  • 結果を普通のプロパティや配列へ写していないか(写した時点で更新が止まる)
  • コンストラクターやフィールドの初期化子でクエリを読んでいないか(生成中は undefined)
  • viewChild.required() が、@if や @defer の外にある要素に限られているか
  • required を読む位置が、例外を出してよい位置か
  • contentChild の descendants: false が、投影の受け手の階層と合っているか
  • { read: ... } に書いた型が、その節点から取れるものか
  • 文字列の述語に , を書いて2つの参照を1つで受けていないか
  • 要素が出入りする場合に effect で、最初から在る場合に afterNextRender で読んでいるか
  • @ViewChild / @ContentChild デコレーターが残っていないか
  • テンプレート参照変数と signal クエリで同じ要素を二重に管理していないか

おわりに

undefined が返る経路は4つある。ビューが生成中であること、フィールドがクエリとして束縛されていないこと、descendants や read の指定で一致が落ちること、そして単純に要素が描画されていないこと。型の上ではどれも同じ undefined で、?. を付ければ4つとも通る。

区別するには、宣言の形と読む位置を見る。宣言がクラスフィールドの初期化子になっているか、読んでいる位置がビューの生成より後か、read に書いた型がその節点から取れるか。この3点を満たしたうえで残る undefined は、要素が描画されていない状態だけになる。そこまで絞れれば、required を選べる箇所と、条件と対応づけて扱う箇所が差分の上で分かれる。

クエリと宿主要素の観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「クエリとホスト」の群に並べてある。