Blazor - .NET 9 の新機能 RenderInfo プロパティが、ブラウザ機能の使用タイミングを変えるかもしれない

## サーバー側レンダリングを行なう場合、ブラウザ機能へのアクセスはタイミングに注意

Blazor、とくに Blazor Server や rendermode = InteractiveServer なコンポーネントを含む、サーバー側レンダリング (プリレンダリング) が行なわれる場合において、JavaScript コードの呼び出しをはじめとしたブラウザ機能の使用タイミングは、少々気を付ける必要があります。

例えば、プリレンダリングが有効な Blazor アプリケーションにおいて、以下のようなよくある "カウンター" コンポーネントが実装されているとします。

```cs:何かしら.razor
<p>カウント: @_count</p>

<button @onclick="Increment">カウントアップ</button>

@code {
  private int _count = 0;

  private void Increment() {
    _count++;
  }
}
```

そして、カウントの最終状態をブラウザの `localStorage` に保存しておき、次回アクセス時に復元することを考えます。まず、カウントが加算されるたびに、ブラウザの `localStorage` に最新のカウントを保存するよう、JavaScript 呼び出しを含む実装に、上記のコードを以下のように変更します。

```cs:何かしら.razor
@inject IJSRuntime JSRuntime

<p>カウント: @_count</p>

<button @onclick="Increment">カウントアップ</button>

@code {
  private int _count = 0;

  private async Task Increment() {
    _count++;

    // 👇ブラウザの localStorage に最終のカウンタ値を保存する
    await this.JSRuntime.InvokeVoidAsync("localStorage.setItem", "count", _count);
  }
}
```

ここまではいいでしょう。

次に、このコンポーネントの初期化時に、ブラウザの `localStorage` から前回保存されたカウントを取得、復元するようプログラムを変更することを考えます。"コンポーネントの初期化時に..." ということなので、以下のように `OnInitializedAsync` ライフサイクルメソッド内で `localStorage` からの読み取りを実装したくなりますが...

```cs:何かしら.razor
...
@code {
  ...
  protected override async Task OnInitializedAsync()
  {
    var countText = await this.JSRuntime.InvokeAsync<string?>("localStorage.getItem", "count");
    _count = int.TryParse(countText, out var count) ? count : _count;
  }
  ...
```

このコードは、このカウンターコンポーネントを含むページの**プリレンダリングが行なわれる際**に、**以下の例外**で落ちます。

```
InvalidOperationException: JavaScript interop calls cannot be issued at this time. 
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/49d90783-f4fe-4648-a8de-aaaacef287fc.png)


プリレンダリングということは、ブラウザからの初回ドキュメント要求の HTTP GET リクエストに対する、応答 HTML 文字列を生成するとき、ということを意味します。そのような、まだブラウザ上で何かプログラムが動作し始める前のタイミングでは、JavaScript 呼び出しをはじめ、ブラウザの各種 API に触ることはできません。それにもかかわらず、上記実装では、JavaScript 呼び出しを実行しているため、プリレンダリング時に例外に至ってしまいます。

より正しい実装は次のとおりです。

```cs:何かしら.razor
...
@code {
  ...
  protected override async Task OnAfterRenderAsync(bool firstRender)
  {
    if (firstRender) {
      var countText = await this.JSRuntime.InvokeAsync<string?>("localStorage.getItem", "count");
      _count = int.TryParse(countText, out var count) ? count : _count;
      this.StateHasChanged();
    }
  }
  ...
```

"コンポーネントの初期化時に、前回カウント値の復元を..." という処理なのに、`OnAfterRenderAsync`、すなわち、レンダリング完了時に呼び出されるライフサイクルメソッド内でそれを実装するのは、**なんだか違和感を感じなくもない**です。しかしBlazor プログラミングではこれがベストプラクティスとされています。というのも、Blazor における様々なホスティングモデル、レンダーモードにおいて、ブラウザの API に触れることが保証されているタイミングというのが、`OnAfterRenderAsync` ライフサイクルメソッドであるためです。実際、プリレンダリング処理 (Blazor SSR もそうですが) においては、`OnAfterRenderAsync` は呼び出されません。

しかしいっぽうで、`OnAfterRenderAsync` は再描画も含めて毎回呼び出されます。"最初の1回目の描画完了時" にのみカウンタ値を復元するためには、同メソッドに渡される引数 `firstRender` の値を参照して if 文で条件分岐する必要があります。また、カウンタ値を復元しても、それだけでは再描画されません。明示的に `StateHasChanged()` メソッドを呼び出して、Blazor に再描画を促す必要もあります。

繰り返しになりますが、自分の直感に反するような実装をしなくちゃならないのと、あと、定形コードとはいえ、少々面倒臭いなぁ、と、実は内心、常々思っていました。

しかし .NET 9 からは、この状況が改善されるかもしれません。

## .NET 9 で追加された `RenderInfo` プロパティ

先日 2024年11月12日にリリースされた .NET 9 から、Blazor のコンポーネントに、`RenderInfo` プロパティが追加されました。この `RenderInfo` プロパティには、さらに `IsInteractive` という `bool` 型のプロパティが公開されており、この `RenderInfo.IsInteractive` を参照することで、いま、そのコンポーネントが、対話モードが機能している状態にあるか、すなわち、ブラウザの API を呼び出せる状態にあるか否かを、いつでも判定できるようになりました。

この `RenderInfo.IsInteractive` プロパティを活用すると、先のカウンターコンポーネントにおける、前回カウント値の復元処理は、以下のように、`OnInitializedAsync` 内で実装できるようになります。 


```cs:何かしら.razor
...
@code {
  ...
  protected override async Task OnInitializedAsync()
  {
    if (this.RendererInfo.IsInteractive) {
      var countText = await this.JSRuntime.InvokeAsync<string?>("localStorage.getItem", "count");
      _count = int.TryParse(countText, out var count) ? count : _count;
    }
  }
  ...
```

プリレンダリングの最中は `RenderInfo.IsInteractive` は `false` を返します。そのため、上記 if 文の判定によって、プリレンダリングの最中にブラウザ API に触ってしまうことを回避できます。

また、`OnInitializedAsync` メソッド内の処理がすべて完了すると、Blazor は自動でコンポーネントの再描画を行います。つまり、明示的な `StateHasChanged()` 呼び出しも不要です。

何よりも、**やっていることやその意図が、こちらのコードのほうが明確に伝わる**ように感じます。

先に紹介した .NET 8 以前の方式では、Blazor のあらゆるホスティングモデル、あらゆるレンダーモードにおいてブラウザ API へのアクセスが保証されている最大公約数的なタイミングは `OnAfterRenderAsync` 以降である、という予備知識がないと、「なぜ `OnInitializedAsync` ではなくて、描画が終わったあとの `OnAfterRenderAsync` で初期化処理をやっているの??」と理解が困難になる危険があります。

その点、.NET 9 の `RenderInfo.IsInteractive` プロパティを参照する実装方式であれば、
「コンポーネントの "初期化" の時点で、前回カウント値の復元をしたい」
「ただし、プリレンダリングの最中など、ブラウザ API にアクセスできない場合は除外する」
という実装意図が素直にコードに表現できています。

また「いったん初回の描画が行なわれた後で、ブラウザ API 呼び出し」する .NET 8 以前の方式よりは、「初回描画が行なわれるより前にブラウザ API 呼び出しを開始」する .NET 9 の `RenderInfo.IsInteractive` プロパティを参照する方式のほうが、処理速度的な性能面でも、もしかすると、ほんのちょっとだけ有利かもしれません。つまり、後者の .NET 9 の場合は、ブラウザ API を呼び出したあと、その結果が返ってくるまでの非同期処理の待機の間に、描画処理を遂行できているのではないか、と想像した次第です。自分はこの点、ちゃんと計測・評価したことはなく、あまりに僅差にすぎる可能性も高そうですが、気になるところです。

## おわりに

.NET 9 がリリースされるまで、長らくややこしかったブラウザ機能が使用可能になるタイミングでしたが、今後は  `RenderInfo.IsInteractive` を参照することで、より可読性の高い・保守容易なコードで実現できるようになりそうです。

ぜひお試しください。