😸

.NET 8 での Blazor のエラーハンドリングの注意点

2023/11/20に公開

.NET 8 の ASP.NET Core Blazor で結構変わりました。それについては以下の記事で簡単にですが解説しています。

https://zenn.dev/microsoft/articles/aspnetcore-blazor-dotnet8-overview

結構変わったおかげで、普通の SSR と Blazor Server と Blazor WASM が 1 つのプロジェクトで共存できるようになりました。これはとてもいいことなのですが、違う世界の住人が 1 つの所で同居するようになったので、ちょっと注意が必要なことがあります。その一番大きなところがエラーハンドリングです。

Blazor のエラー ハンドリングは ErrorBoundary というコンポーネントを使って、アプリ全体のエラーをざくっとハンドリングすることが出来ます。.NET 6 で追加された機能で、以下の記事で解説しています。

https://zenn.dev/microsoft/articles/blazor-net6-errorboundary

従来であれば MainLayout.razor でページを囲うように ErrorBoundary を配置することでページ内でおきた catch されていない例外をまとめてエラー画面を表示することが出来ました。端的にいうと .NET 8 の Blazor Web App のプロジェクト テンプレートで作成したプロジェクトでは、この方法ではエラー画面を表示することが出来ないケースが多々あります。

OK なパターン

SSR や Streaming SSR の場合は、従来どおり MainLayout.razorErrorBoundary を配置することでエラー画面を表示することが出来ます。
Blazor Web App で作ったプロジェクト テンプレートに対して以下のように ErrorBoundary を配置して動作を確認してみます。

MainLayout.razor
@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            <ErrorBoundary>
                @Body
            </ErrorBoundary>
        </article>
    </main>
</div>

<div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>

そして SSR の Home.razor を以下のようにして絶対にエラーが出るようにします。

Home.razor
@page "/"

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

@code {
    protected override void OnInitialized()
    {
        throw new Exception("だめ"); // エラー!
    }
}

次に Streaming SSR の Weather.razor も以下のように変更して絶対にエラーが出るようにします。

Weather.razor
@page "/weather"
@attribute [StreamRendering]

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates showing data.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        throw new Exception("だめ!!"); // エラー!

        // Simulate asynchronous loading to demonstrate streaming rendering
        await Task.Delay(500);

        var startDate = DateOnly.FromDateTime(DateTime.Now);
        var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
        forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = startDate.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = summaries[Random.Shared.Next(summaries.Length)]
        }).ToArray();
    }

    private class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

この状態で Home.razor を開くと以下のようになります。期待通りですね。

次に Weather.razor を開きます。こちらも期待通りです。

ダメなケース

先ほどは OK だった Weather.razor ですが OnInitializedAsync で一度 await をして Streaming SSR のストリーミングの部分に突入した段階で例外を出すように throw を移動させます。

Weather.razor
@page "/weather"
@attribute [StreamRendering]

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates showing data.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        // Simulate asynchronous loading to demonstrate streaming rendering
        await Task.Delay(500);

        var startDate = DateOnly.FromDateTime(DateTime.Now);
        var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
        forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = startDate.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = summaries[Random.Shared.Next(summaries.Length)]
        }).ToArray();

        throw new Exception("だめ!!"); // エラー!
    }

    private class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

こうすると ErrorBoundary が効かなくなります。ずっと Loading... の状態で止まってしまいます。

次にダメなケースは @rendermode InteractiveServer@rendermode InteractiveWebAssembly@rendermode InteractiveAuto を指定したケースです。

以下のようにカウンター ページに絶対エラーになるようなコードを仕込みます。

Counter.razor
@page "/counter"
@rendermode InteractiveAuto

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    protected override void OnInitialized()
    {
        throw new Exception("エラー!!!!!!!");
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}

ややこしいのですが、これは実は OK なケースです。以下のようにエラーがちゃんと ErrorBoundary でハンドリングされます。OnInitializedOnInitializedAsync で例外が発生する場合は大丈夫なのですが、以下のようにボタンクリックのインタラクティブなイベント ハンドラーで例外が発生すると ErrorBoundary が効かなくなります。

Counter.razor
@page "/counter"
@rendermode InteractiveAuto

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        throw new Exception("エラー!!!!!!!");
        currentCount++;
    }
}

以下のような MainLayout.razor にある blazor-error-ui で設定されたエラー用の要素が表示されてしまいます。

これは、考えてみれば至極当然なのですが違う世界の人に例外が伝搬されないために起きています。今回の ErrorBoundary は SSR の世界でレンダリングされる部分に置かれているので、それでハンドリングできる例外については面倒を見てくれますが、それ以外の Streaming SSR のストリーミング時や InteractiveServerInteracitveWebAssembly では違う世界での例外なので ErrorBoundary が効かないのです。

ちなみに InteractiveAutoOnInitializedErrorBoundary が効いたのはデフォルトでサーバーサイドでのプリレンダリングが有効になっているからです。例えば Home.razor を以下のように変更して InteractiveServer にしてプリレンダリングをオフにすると OnInitialized で例外が発生しても ErrorBoundary では補足されません。

Home.razor
@page "/"
@rendermode InteractiveServerWithoutPrerender

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

@code {
    // プリレンダリングをオフにした状態
    private static InteractiveServerRenderMode InteractiveServerWithoutPrerender = new(false);
    protected override void OnInitialized()
    {
        throw new Exception("だめ");
    }
}

実行すると blazor-error-ui で設定されたエラー用の要素が表示されてしまいます。

どうすればいいの?

とりあえず ErrorBoundary が出る前の状態と同じ対処になります。嫌ですけど例外の発生する可能性のあるメソッドではちゃんと例外処理をしましょう。もしくは ErrorBoundary で SSR 時の例外に対応しつつ blazor-error-ui で設定されたエラー用の要素を表示するという形での対応になります。エラーが起きた時の画面が違いすぎるので本当に最後の砦としての対応になります。そして、基本的にはエラーが起きる可能性のあるメソッドは、ちゃんと例外処理をしましょうということになります。ここらへんは今後のアップデートで改善されることを期待したいですね。

まとめ

ということで、Blazor のめでたい話が多い昨今ですが、結構地味に辛い挙動もあったりします。今回のエラー処理は個人的に一番辛いです…。このようなエラーが起きた時に、きちんと対応できるようにするためにも .NET 8 の Blazor を使うときには今自分が書いているコードは、何処で動いているのか、どのレンダリングモードで動いているのか、その時にはどのように動くのかということを、きちんと把握して実装することが、より大事になっています。

まぁ、この挙動を差し引いても Blazor は楽しいフレームワークなので今後もウォッチしていって気づいたことは記事に書いていこうと思います。

それでは楽しい Blazor ライフを!

Microsoft (有志)

Discussion