While researching and playing with .NET 8's Blazor I have stumbled upon the new InteractiveAuto mode. Theoretically this could allow applications to render pages server side, until the client is ready to use the clientside WASM code, which in theory should be faster and provide a better interactive experience. Reading this, it reminded me of how far SSR and hydration has come in JavaScript land (NextJS, Astro and more), and made me wonder how well Blazor stacks up to those technologies. In this post I will however not be drawing a comparison between Blazor and these JavaScript frameworks, I will solely be focusing on how Blazor implemented this feature.
To demonstrate how the InteractiveAuto mode works, we will be creating a component that: - Fetches data on the server. - Runs clientsided in WASM when the WASM bundle is ready. - If needed fetches the data clientsided in WASM if it has not been fetched yet.
Let's generate a new Blazor application with InteractiveMode on auto. This generates a Server and Client Project. Generating from this template will include some boilerplate code regarding a Weather component that we will be reusing. The idea is that both the client as the server project will be using this weather forecast table to render forecasts.
dotnet new blazor -o BlazorApp -int Auto
As we want the server and the client to be able to generate our forecasts, we will be doing three things.
WeatherForecast
DTO to the Shared projectnamespace Shared;
public 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);
}
public interface IWeatherForecastService
{
IAsyncEnumerable<WeatherForecast> GetAllAsync();
}
With our newly created interface, we can create a server implementation to fetch our forecasts.
You will recognise most of this logic, as it was contained in the Weather.razor
page, which we will be changing later on.
using Shared;
namespace BlazorApp.Services;
internal class WeatherForecastService : IWeatherForecastService
{
private readonly static DateOnly StartDate = DateOnly.FromDateTime(DateTime.Now);
private readonly static string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly List<WeatherForecast> _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)]
}).ToList();
public async IAsyncEnumerable<WeatherForecast> GetAllAsync()
{
for (var i = 0; i < _forecasts.Count; i++)
{
await Task.Delay(400);
yield return _forecasts[i];
}
}
}
You might have noticed that the interface uses IAsyncEnumerable
, combined with the implementation that delays before yielding values in this IAsyncEnumerable
. We are purposely using IAsyncEnumerable
as it plays nicely with Blazor's StreamRendering. We introduce the Task.Delay
to make it visually easier to spot what is going on with our data fetching.
Next we will be wiring up this service in the Server's Program.cs
.
We're basically handling static data here, so we can opt for registering the service as a singleton.
builder.Services.AddSingleton<IWeatherForecastService, WeatherForecastService>();
As the client runs in the browser, we will need to fetch the data from the server. The most straightforward way to do that is executing HTTP calls.
First we will need to expose the Server's IWeatherForecastService
through an endpoint.
In the Server's Program.cs
app.MapGet("/api/weather", (IWeatherForecastService weatherService) => weatherService.GetAllAsync());
In the Client we will call this endpoint using the HttpClient
internal sealed class ClientWeatherForecastService(HttpClient httpClient) : IWeatherForecastService
{
public IAsyncEnumerable<WeatherForecast> GetAllAsync() =>
httpClient.GetFromJsonAsAsyncEnumerable<WeatherForecast>("/api/weather")!;
}
Subsequently we wire up our HttpClient
and ClientWeatherForecastService
in the client's Program.cs
.
builder.Services.AddSingleton(new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
builder.Services.AddSingleton<IWeatherForecastService, ClientWeatherForecastService>();
In the client project, we will be creating our Weather page.
@page "/weather"
@using Shared
@attribute [StreamRendering]
@rendermode InteractiveAuto
@inject IWeatherForecastService WeatherForecastService
@inject PersistentComponentState ApplicationState
@implements IDisposable
<PageTitle>Weather</PageTitle>
@if (_forecasts.Count == 0)
{
<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 List<WeatherForecast> _forecasts = [];
private PersistingComponentStateSubscription _subscription;
protected override async Task OnInitializedAsync()
{
_subscription = ApplicationState.RegisterOnPersisting(Persist);
var foundInState = ApplicationState.TryTakeFromJson<List<WeatherForecast>>("weather", out var forecasts);
if (foundInState)
{
_forecasts = forecasts!;
Console.WriteLine($"{DateTime.Now:O} Took forecast from storage");
}
else
{
Console.WriteLine($"{DateTime.Now:O} Took forecast from service");
Console.WriteLine(WeatherForecastService is ClientWeatherForecastService
? $"{DateTime.Now:O} Client forecast service"
: $"{DateTime.Now:O} Server forecast service");
await foreach (var forecast in WeatherForecastService.GetAllAsync())
{
_forecasts.Add(forecast);
StateHasChanged();
}
}
}
private Task Persist()
{
Console.WriteLine("Persisting state");
ApplicationState.PersistAsJson("weather", _forecasts);
return Task.CompletedTask;
}
public void Dispose() => _subscription.Dispose();
}
A few things to take a look at:
await foreach
syntax to get values from our IAsyncEnumerable
which is feeding our records one at a time leveraging [StreamRendering]
which makes sure our DOM updates one record at a time.PersistentComponentState
. Here be dragons though, which we will discover below.Theres two scenarios we are we want to look at: 1) How does the data get fetched when the WASM bundle was not preloaded, and we hotlink to the page? 2) How does the data get fetched when the WASM bundle has already been downloaded, and we execute an enhanced page navigation to our weather page?
2024-03-23T16:57:25.7357450+01:00 Took forecast from service
2024-03-23T16:57:25.7358220+01:00 Server forecast service
2024-03-23T16:57:26.9087260+01:00 Persisting state
2024-03-23T16:57:29.4020000+01:00 Took forecast from storage
Logically, as the WASM bundle is not ready yet, our content gets generated on the server, and rehydrated from the PersistentComponentState
during rerenders.
We boot up the application, visit the home page, wait for the WASM bundle to fully load, and navigate to the weather page.
2024-03-23T16:50:53.9740720+01:00 Took forecast from service
2024-03-23T16:50:53.9757560+01:00 Server forecast service
2024-03-23T16:50:55.1677860+01:00 Persisting state
2024-03-23T16:50:56.6540000+01:00 Took forecast from storage
If we navigate to the home page and back to the weather page, we see a different result:
2024-03-23T16:52:12.5185440+01:00 Took forecast from service
2024-03-23T16:52:12.5186120+01:00 Server forecast service
2024-03-23T16:52:13.6435220+01:00 Persisting state
2024-03-23T16:52:13.6470000+01:00 Took forecast from service
2024-03-23T16:52:13.6480000+01:00 Client forecast service
Visually this also results in a different UX:
I'm no UX expert, but it is pretty obvious to me that this looks like a very confusing and jarring experience.
I see a few alarming problems here:
The data gets fetched twice. Somehow we are using the server's IWeatherForecastService
twice:
IWeatherForecastService
implementation.This can't be how Blazor team intended for this work right? Well... no, not really. This GitHub issue is part of the problem as it explains us that:
Well that's a bummer. Looks like I made some assumptions there. What about not using PersistentComponentState
? In this case it does not matter as it does not function in the way I imagined it to work. I've built a 'dumb' weather component to test this out:
@page "/dumbweather"
@using Shared
@using System.Globalization
@attribute [StreamRendering]
@rendermode InteractiveAuto
@inject IWeatherForecastService WeatherForecastService
<PageTitle>Weather</PageTitle>
@if (_forecasts.Count == 0)
{
<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.ToString(CultureInfo.InvariantCulture)</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private readonly List<WeatherForecast> _forecasts = [];
protected override async Task OnInitializedAsync()
{
{
Console.WriteLine($"{DateTime.Now:O} Took forecast from service");
Console.WriteLine(WeatherForecastService is ClientWeatherForecastService
? $"{DateTime.Now:O} Client forecast service"
: $"{DateTime.Now:O} Server forecast service");
await foreach (var forecast in WeatherForecastService.GetAllAsync())
{
_forecasts.Add(forecast);
StateHasChanged();
}
}
}
}
Expectedly, it gives us the same result. We get the same 'flashing' effect.
2024-03-30T01:28:56.7847000+01:00 Took forecast from service
2024-03-30T01:28:56.7857840+01:00 Server forecast service
2024-03-30T01:28:59.6380000+01:00 Took forecast from service
2024-03-30T01:28:59.6690000+01:00 Client forecast service
So Alex, what's the solution? I want to use this glorious mix of serverside and clientside rendering!
The way I see it there are two options:
All the code above can be found on my GitHub.