Blazor WebAssembly アプリを .NET 8 の Auto レンダーモードに移行
ユーザーが選択したカラーテーマ (自動/ライトテーマ/ダークテーマ) を、ブラウザのローカルストレージに保存し、次回起動時にその設定を復元する、そのような Blazor WebAssembly がありました。ブラウザのローカルストレージの読み書きには、「Blazored LocalStorage」NuGet パッケージを利用しています。
以下はそのコード断片です。
@inject ILocalStorageService localStorageService
...
@code
{
// 3種のカラーテーマを表現する列挙型
private enum Theme { Auto, Light, Dark }
// 現在のカラーテーマを示すフィールド変数
private Theme _currentTheme = Theme.Auto;
protected override async Task OnInitializedAsync()
{
// このコンポーネントの初期化時に、ブラウザのローカルストレージから
// ユーザーが選択したカラーテーマを取得
_currentTheme = await localStorageService.GetItemAsync<Theme>("theme");
}
private async Task SetThemeAsync(Theme theme)
{
_currentTheme = theme;
// ユーザー操作によってカラーテーマの選択が変更されたら、
// ブラウザのローカルストレージにその選択を保存しておく
await localStorageService.SetItemAsync("theme", theme);
}
}
この Blazor WebAssembly アプリケーションを、.NET 8 から登場した Auto レンダーモードで動作するように移植しました。
そうして実行したところ、下記の例外が発生して動作しなくなってしまいました。
An unhandled exception occurred while processing the request.
InvalidOperationException:
JavaScript interop calls cannot be issued at this time.
This is because the component is being statically rendered.
When prerendering is enabled, JavaScript interop calls can only be performed during the OnAfterRenderAsync lifecycle method.
Microsoft.AspNetCore.Components.Server.Circuits.RemoteJSRuntime.BeginInvokeJS(long asyncHandle, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId)
さらにスタックトレースを見ていくと、以下の記載があり、例外発生箇所は、ローカルストレージからカラーテーマ設定を取得するところで発生しているのがわかります。
BlazorAutoApp.Client.Pages.Home.OnInitializedAsync() in Home.razor
+ _currentTheme = await localStorageService.GetItemAsync<Theme>("theme");
.NET 8 の Auto レンダーモードに移行する前の、オリジナルの Blazor WebAssembly 版では問題なく動作していたこの処理が、どうして Auto レンダーモード化することで例外発生してしまうようになったのでしょうか。
例外の発生原因
Auto レンダーモードそれ自体ではなく、サーバー側プリレンダリングが原因
このような例外が発生してしまったのは、既存の Blazor WebAssembly アプリを Auto レンダーモードに移行したことにより、サーバー側プリレンダリング (サーバーサイド事前レンダリング) も行なわれるようになったことが原因にあります。実のところ、"Auto レンダーモード" それ自体 (つまり、ある Razor コンポーネントが、あるときは Blaor Server として、そしてまたあるときは Blazor WebAssembly として動作する仕組み) は、この例外の直接原因ではありません。
サーバー側プリレンダリングは、ブラウザからのページ要求 (HTTP GET) に対して、単に SPA としてのフォールバックページを返すのではなく、サーバー側でブラウザ表示用 (あるいは検索エンジンのクローラーに返却する用) のレンダリング済みの HTML コンテンツを生成して返す、という仕組みです。
サーバー側プリレンダリング、つまり、サーバープロセス内での、ブラウザへの初回応答用の HTML 文字列を生成している最中は、まだブラウザとの双方向通信が確立する前ですから、ブラウザの API や DOM とやりとりすることはできません、というか、意味を成しません。とはいえ、応答用 HTML 文字列生成のために、Razor コンポーネントはインスタンス化され、その Razor コンポーネントの OnInitializedAsync
を含む各種ライフサイクルメソッドも、この処理のために、それなりに呼び出されます。
ただ、前述のとおり、サーバー側プリレンダリング処理中における OnInitializedAsync
実行時は、ブラウザとの接続が確立する前です。それで今回の Blazor アプリケーションのように、この OnInitializedAsync
ライフサイクルメソッド内でブラウザのローカルストレージからの読み取りを行なうと、それは無理な相談ですから、例外発生に至るわけです。
例外メッセージにある「JavaScript interop calls cannot be issued at this time. (現時点では、JavaScript 相互運用呼び出しを発行できません)」は、このような状況を説明していたのです。
.NET 8 固有の現象ではない / .NET Core 3.1 でも発生することはあった
なお、Blazor のサーバー側プリレンダリングは、.NET 8 より前、.NET Core 3.1 の頃からちゃんと Microsoft の公式サイトでも説明がされていた技法です。実際、その当時からこのような例外が発生することはありました。つまり今回の例は、.NET 8 特有の現象というわけではありません。とくに Blazor Server においては、.NET SDK 標準のプロジェクトテンプレートにて生成されるコードでは既定でサーバー側プリレンダリングが有効になっていましたので、同様の現象に遭遇されていた方も少なくないのでは、と想像されます。
対応方法 1. OnAfterRenderAsync
メソッドで処理する
以上のとおり、OnInitializedAsync
ライフサイクルメソッドは、サーバー側プリレンダリングが有効だと、ブラウザとの接続が確立する前にも呼び出されます。デバッガで見てみると、OnInitializedAsync
ライフサイクルメソッドは、サーバー側プリレンダリングのために一度、そしてブラウザとの接続確立後に DOM の実行時構築のためにもう一度、計2回呼び出されるのがわかります。
いっぽうで、OnAfterRenderAsync
ライフサイクルメソッドは、実際にブラウザ上で実行時の DOM 構築が完了した時点で呼び出されます。すなわち、OnAfterRenderAsync
ライフサイクルメソッドが呼び出される時点では、確実にブラウザとの接続が確立しています。
そこで、ブラウザの API や DOM を使用する処理を OnAfterRenderAsync
ライフサイクルメソッドで行なうようにすれば、すなわち、ブラウザのローカルストレージからの読み取り処理を OnAfterRenderAsync
ライフサイクルメソッドで行なうことで、今回の例外発生も回避することができます (下記コード例)。
...
@code
{
...
// OnAfterRenderAsync 実行時は、確実に、ブラウザとの接続が確立している。
// なので、ローカルストレージをはじめ、ブラウザの機能にアクセスできる。
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// とはいえ、描画発生のたびに毎回、OnAfterRenderAsync が呼び出されるので、
// firstRender 引数を見て、初回の描画成功時にのみ、ローカルストレージからの
// 前回設定の読み取り・復元を行なう。
if (firstRender)
{
_currentTheme = await localStorageService.GetItemAsync<Theme>("theme");
StateHasChanged();
}
}
...
}
このことは、先の例外メッセージにて、
「When prerendering is enabled, JavaScript interop calls can only be performed during the OnAfterRenderAsync lifecycle method. (事前レンダリングが有効な場合、JavaScript 相互運用呼び出しは OnAfterRenderAsync ライフサイクル メソッド中にのみ実行できます)」
として説明されています。
対応方法 2. サーバー側プリレンダリングを行なわないようにする
先の説明のとおり、今回の例外発生は、サーバー側プリレンダリングの処理中に発生するものです。なので、サーバー側プリレンダリングを行なわないようにすれば、このような例外発生はなくなります。
.NET 8 で Blazor のサーバー側プリレンダリングを無効にするには、@rendermode
の指定において、prerender
コンストラクタ引数に false
を指定してインスタンス化した IComponentRenderMode
オブジェクトを渡せばよいです。
例えば以下のようなコード例になります。
@* App.razor *@
...
<Route @rendermode="@(new InteractiveAutoRenderMode(prerender: false))" />
...
ただし、この方法は、当然のことながら、プリレンダリングによる初期表示の高速化や、検索エンジンのクローラー対策はなされません。その点は承知しておく必要があります。
その他の方法
その他の方法としては、OnInitializedAsync
ライフサイクルメソッド内で、今現在、サーバー側プリレンダリングの処理中なのかどうかを判定して、サーバー側プリレンダリング処理中なら何もしない、といった条件分岐で初期化処理を実装する方法も考えられます。
ただ現時点では、自分は、どうすれば「現在はサーバー側プリレンダリング処理中なのか」を判定できるのか、その方法をわかっていません。
まとめ
既存の Blazor WebAssembly アプリケーションを .NET 8 の新しい形態に移行すると、サーバー側プリレンダリングが有効になっているせいで、OnInitialized
あるいは OnInitializedAsync
ライフサイクルメソッド内で行なっていたブラウザ API を扱う初期化処理が例外になってしまうことがあります。
このような場合は、サーバー側プリレンダリングを行なわないようにするか、あるいは、OnAfterRender
/OnAfterRenderAsync
ライフサイクルメソッドでそのようなブラウザ API を扱う初期化処理を実装することで例外を回避できます。
.NET 8 より前の SDK における Blazor WebAssembly のプロジェクトテンプレートでは、サーバー側プリレンダリングは有効になっていなかったいっぽうで、.NET 8 からは、サーバー側プリレンダリングが有効なコードが出力されることが増えました。その結果として、.NET 8 への移行後に、今回紹介したような例外に遭遇する人も増えるかもしれません。
そうしたときにこの記事がそのトラブルシューティングの役に立てば幸いです。
Learn, Practice, Share!