Angularの@boundaryでエラーを握りつぶさない設計とレビュー観点

@boundary は 22.2 で developer preview として入った制御フローブロックである。囲んだ範囲の描画中に例外が出たら、その範囲を外して @error ブロックに差し替える。

最小の形
@boundary {
  <order-summary [order]="order()" />
} @error {
  <p>注文の表示に失敗しました。</p>
  <button (click)="$reset()">再表示する</button>
}

置けば画面が落ちなくなるので、レビューでは「置いてあるか」で済ませたくなる。確かめる必要があるのは別のところで、捕まる例外の範囲、@error から戻る導線、エラーが運用側へ届くかの3点である。順に見ていく。

捕まるのは描画中の例外だけ

@boundary が差し替えを起こす経路は、描画のサイクルを回す関数の catch 節にある。refreshView は、テンプレートの実行、束縛の更新、ライフサイクルフックの呼び出し、ホスト束縛の更新をまとめて行う関数で、ここから投げられた例外が捕まる。

境界を探して渡す処理
} catch (e) {
  let handled = false;
  let errorToHandle = e;
  let currentLView = lView;
  while (currentLView !== null) {
    if (isLContainer(currentLView)) {
      currentLView = currentLView[PARENT];
      continue;
    }
    const onError = currentLView[ON_ERROR];
    if (onError) {
      try {
        onError(encapsulateBoundaryError(errorToHandle), details);
        handled = true;
        break;
      } catch (boundaryError) {
        errorToHandle = boundaryError;
      }
    }
    currentLView = currentLView[PARENT];
  }
  if (!handled) {
    throw errorToHandle;
  }
}

親のビューへ順に遡り、最初に見つけた境界へ渡す。見つからなければそのまま投げ直す。外側の @boundary が内側の例外を受け取れるのはこの遡りがあるからである。境界の処理自体が投げた場合は、その新しい例外を持ってさらに外側へ進む。

一方、(click) などの出力に書いた処理は別の経路を通る。

リスナーの実行
function executeListenerWithErrorHandling(lView, context, listenerFn, e) {
  try {
    return listenerFn(e) !== false;
  } catch (error) {
    handleUncaughtError(lView, error);
    return false;
  }
}

handleUncaughtError が渡すのはアプリケーションの ErrorHandler で、@boundary を素通りする。したがってボタンを押して走る処理の例外は、いくら @boundary で囲んでも @error に切り替わらない。非同期の処理や Promise の棄却も同じで、描画のサイクルの外で起きた例外は境界に届かない。

この区別は、@error に何を書くかを決める。描画の失敗、たとえば取得した値の形が想定と違ってテンプレートの式が落ちる場合は @boundary の範囲である。送信ボタンの処理が落ちる場合は範囲の外なので、送信の結果を状態として持ち、テンプレート側で分岐する形に寄せることになる。

Comment
@Reviewer: この `@boundary` は保存ボタンの失敗を受け止める意図だと読めますが、出力に書いた処理の例外は `ErrorHandler` へ直接渡るため `@error` には切り替わりません。保存の結果を signal で持って `@if` で分岐するか、失敗時に `@boundary` の中で読む値を壊す形にするか、どちらかに寄せてもらえますか。

$resetが戻すもの

@error ブロックの中では $error と $reset が使える。$error は捕まえた例外、$reset は境界の状態を初期化する関数である。実体は小さい。

境界の状態
class LBoundary {
  error = null;
  constructor(hostLView) {
    this.hostLView = hostLView;
  }
  reset() {
    this.error = null;
    markViewForRefresh(this.hostLView);
  }
}

どちらのブロックを描くかは、この error が null かどうかで決まる。コンパイラが生成する式は boundary.error === null ? 主ブロック : (whenの連鎖) の形になる。$reset() は error を消してビューに再描画を要求するので、次の描画で主ブロックが作り直される。

ここから、$reset() が戻すのは境界の状態だけだと分かる。例外の原因になった値はそのままなので、同じ値を読めばもう一度同じ例外が出る。再表示の導線として $reset() だけを置いた @error は、押すたびに @error へ戻ってくる。

原因を外さずに$resetを置いた形
@boundary {
  <order-summary [order]="order.value()!" />
} @error {
  <button (click)="$reset()">再表示する</button>
}

成立させるには、$reset() の前に原因を変える処理を挟む。取得に失敗した値なら Resource の reload()、計算の前提が壊れているなら状態の初期化である。

原因を外してから戻す形
@boundary {
  <order-summary [order]="order.value()!" />
} @error (let e = $error) {
  <p></p>
  <button (click)="retry()">再読み込みする</button>
}
コンポーネント側
retry(): void {
  this.order.reload();
}

この形では $reset() を呼んでいない。reload() で order.value() が変われば主ブロックの式は通るが、error が null に戻らない限り主ブロックは描かれない。両方を行う必要がある。テンプレートから $reset を渡すか、@error の中で両方を呼ぶ形にする。

両方を行う形
@error (let e = $error; let reset = $reset) {
  <p></p>
  <button (click)="retry(reset)">再読み込みする</button>
}

let で別名を付けられるのは $error と $reset の2つだけで、ほかの名前を書くと Unknown context variable でコンパイルが止まる。

Comment
@Reviewer: `@error` の再表示ボタンが `$reset()` だけを呼んでいます。`$reset()` は境界に記録した例外を消すだけで、例外の原因になった `order.error()` の状態は残るため、再描画でまた `@error` に落ちます。`reload()` と `$reset()` の両方を呼ぶ形にしてもらえますか。

whenで分けるときの網羅

@error は複数書ける。それぞれに when を付けて、例外の種類で出し分ける形になる。

例外の種類で分ける
@boundary {
  <order-summary [order]="order.value()!" />
} @error (when isNotFound($error); let e = $error) {
  <p>この注文は見つかりませんでした。</p>
} @error (when isForbidden($error)) {
  <p>この注文を見る権限がありません。</p>
} @error (let e = $error; let reset = $reset) {
  <p></p>
  <button (click)="retry(reset)">再読み込みする</button>
}

when を持たないブロックが受け皿になる。パーサーは受け皿を1つまでに制限し、位置も連鎖の最後に限っている。2つ書けば @boundary block can only have one unconditional @error block、途中に置けば Unconditional @error block must be the last @error block in the boundary chain でコンパイルが止まる。

受け皿を省いて when だけを並べると、どれも成立しない例外で描くブロックが無くなる。この場合の挙動は実装に書かれている。

どのブロックも選ばれなかったとき
} else {
  const boundary = hostLView[HEADER_OFFSET + slotIndex];
  throw new BoundaryError('Unhandled error in @boundary fell through.', {
    cause: boundary.error,
  });
}

元の例外を cause に入れた別の例外が投げられる。投げられた先は外側の境界か、無ければアプリケーションの ErrorHandler である。つまり when だけを並べた @boundary は、想定した種類の例外には働き、それ以外では囲んでいない場合と同じになる。想定の外に備える仕組みとして置いたなら、受け皿が無い時点で目的を満たしていない。

when の式の中では $error を参照できる。コンパイラが let で付けた別名を境界の error への参照に置き換える。型は any 相当なので、instanceof や形の確認は自分で書くことになる。

エラーが運用側へ届くか

@boundary が例外を捕まえたあと、何も通知しないわけではない。差し替えと同時に ErrorHandler へ渡している。

通知の部分
const errorHandler = hostLView[INJECTOR]?.get(ErrorHandler, null);
if (errorHandler) {
  details.boundary = {
    type: boundaryType,
    reset: () => boundary.reset(),
  };
  if (errorHandler.onViewError) {
    errorHandler.onViewError(error, details);
  } else {
    errorHandler.handleError(error);
  }
}

ErrorHandler に onViewError が実装されていればそちらが呼ばれ、無ければ handleError が呼ばれる。onViewError は 22.2 で ErrorHandler に足された省略可能なメソッドで、第2引数に ErrorDetails を受ける。

ErrorDetailsの中身
interface ErrorDetails {
  readonly declarationType: Type<unknown>;
  readonly declarationInstance: unknown;
  readonly boundary?: {
    readonly type: Type<unknown>;
    readonly reset: () => void;
  };
  readonly caughtBy?: Function;
}

declarationType が例外の出たコンポーネント、boundary.type が捕まえた @boundary を宣言しているコンポーネントである。この2つは別物で、境界を画面の外側に1つだけ置いた構成では boundary.type が常に同じ値になる。送信するログを boundary.type で分類すると、どの部品が壊れたのか分からない記録になる。分類に使うのは declarationType のほうである。

握りつぶしが起きるのは、ErrorHandler を差し替えて handleError を上書きし、onViewError を実装していない場合である。この構成では描画の失敗も handleError の実装次第で消える。@boundary を足す差分では、ErrorHandler の差し替えがあるかを合わせて見る。

Comment
@Reviewer: `provideBrowserGlobalErrorListeners()` と差し替えた `ErrorHandler` のどちらも `handleError` で送信していますが、`@boundary` が捕まえた例外も同じ経路を通ります。境界の情報を残したいので、`onViewError(error, details)` を実装して `details.declarationType` を一緒に送ってもらえますか。

境界の置き場所

@boundary の範囲は、復旧の単位と一致させる。範囲が広いと、壊れた1行のために画面全体が @error に切り替わる。狭すぎると、同じ @error の文面を何箇所にも書くことになる。

実装から言えるのは、範囲を決めるときに外せない条件が1つあることである。@error が読む値は、主ブロックが読む値と別でなければならない。@error の中で例外の原因になった式を読み直すと、@error の描画で例外が出る。その例外は境界の catch ではなく、主ブロックではない側の分岐として扱われるため、投げ直される。

errorブロック側で出た例外
} catch (e) {
  if (matchingTemplateIndex === primaryTemplateIndex) {
    // 主ブロックの作成に失敗した場合は境界が受け止める
    return;
  }
  throw e;
}

主ブロックの作成に失敗した場合は境界が受け止めるが、@error ブロックの作成に失敗した場合は投げ直される。@error の中身は、壊れている可能性のある値に触らない範囲に収める。

プログラムから部品を作る構成にも同じ仕組みが用意されている。ViewContainerRef.createComponent と createEmbeddedView、それに createComponent() 関数が onError を受け取る。型定義には、生成そのもので投げられた例外は捕まえないと書かれている(Note that this will not catch errors thrown during the component's construction.)。テンプレートの @boundary は主ブロックの作成も try の中に入れているので、この点で範囲が違う。

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

@boundaryを足した差分で確かめる項目
  • 捕まえたい例外が描画中に出るものか(出力に書いた処理と非同期の例外は通らない)
  • @error に復旧の導線があるか、または意図して表示だけにしているか
  • $reset() の前に、例外の原因を変える処理があるか
  • reload() だけを呼んで $reset() を省いていないか(境界の error は残る)
  • when を並べた @boundary に、条件の無い受け皿があるか
  • @error の中身が、壊れている可能性のある値を読み直していないか
  • ErrorHandler を差し替えている場合、onViewError を実装しているか
  • ログの分類に declarationType を使っているか(boundary.type は境界の宣言元)
  • 境界の範囲が、復旧の単位と一致しているか
  • developer preview であることを前提に採用しているか

おわりに

@boundary が受け止めるのは、描画のサイクルの中で投げられた例外である。この範囲を取り違えると、押しても何も起きないボタンや、切り替わらない @error が残る。範囲が合っていても、$reset() だけの復旧導線は同じ @error へ戻るだけで、原因を外す処理と対で書く必要がある。

レビューの順序としては、まず捕まえたい例外がどの経路で出るかを確かめる。描画の外なら @boundary の話ではない。次に @error の中身を読み、復旧の導線と、読んでいる値が主ブロックと分かれているかを見る。最後に ErrorHandler の実装を見て、記録が残るかを確かめる。

配置の粒度そのものの判断は、フレームワークをまたいで同じ形になる。React での議論はReactのError Boundary配置で障害範囲が読めない実装をレビューする観点にある。テンプレート制御フローの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「テンプレート制御フロー」の群に並べてある。