Angularのsignalクエリがundefinedを返す経路とレビュー観点
Angularのsignalクエリがundefinedを返す経路とレビュー観点
@ViewChild から viewChild() への置き換えは機械的に見える。デコレーターを外して関数の呼び出しに書き換え、参照する側に () を足す。型も ElementRef | undefined と出るので、?. を付けて回れば型検査は通る。
通ったあとに残るのが、その undefined をいつ受け取るのかという問題である。デコレーターのクエリは、変更検知の中で一致を取り直してプロパティへ書き込む。signal クエリは書き込みを行わず、読んだ時点で一致を取り直す。この違いから、undefined が返る経路は置き換えの前とは変わる。
読むたびに一致を取り直す
@angular/core 22.2.1 の実装では、クエリの signal は computed である。
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 の最後である。
} 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 を付けたときに一致そのものが落ちる経路がある。
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 で配列へ写すと更新が止まる点は単数のクエリと同じである。
レビュー観点チェックリスト
- クエリの宣言が、コンポーネントかディレクティブのクラスフィールドの初期化子になっているか
- 結果を普通のプロパティや配列へ写していないか(写した時点で更新が止まる)
- コンストラクターやフィールドの初期化子でクエリを読んでいないか(生成中は
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観点の「クエリとホスト」の群に並べてある。