dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
34.89k stars 9.85k forks source link

Re-add proper API calls into Blazor WebAssembly template #55307

Open SteveSandersonMS opened 2 months ago

SteveSandersonMS commented 2 months ago

As of .NET 8, the Blazor Web template with WebAssembly interactivity does not contain any example of calling a server API. The "weather forecast" page simply generates data on the client and thus only demonstrates how to render a table, not how to fetch data.

Actually calling a server API in all generality, accounting for all the main ways of setting up a site, is complicated by several factors, as pointed out by @mrpmorris:

While typical apps don't usually have to solve all these problems simultaneously, we need to be able to demonstrate convenient, flexible patterns that do compose well with all the features and are clean enough to put them in the project template.

Solving this nicely likely depends on implementing some other features like https://github.com/dotnet/aspnetcore/issues/51584

Note for community: Recommendation for .NET 8 developers

As mentioned above, typical apps don't normally have to solve all these problems. It's always been simple to load data from the server with Blazor WebAssembly and that is still the case in .NET 8.

The easiest path is simply to disable WebAssembly prerendering, because then just about all the complexity vanishes. Whenever you used rendermode WebAssembly (e.g., in App.razor), replace it with new WebAssemblyRenderMode(prerender: false).

At that point your UI is just running in the browser and you can make HttpClient calls to the server as normal.

mrpmorris commented 2 months ago

Thanks for adding this!

I would like to propose this is two separate issues.

  1. Double-render
  2. WASM vs Server-side rendering of data using the same page

I think 1 is a user experience issue, whereas 2 is a developer experience issue - and as such should be dealt with at separate times.

I think it would be easy to solve item 1 without any changes to the Blazor framework by updating the default template to something like the attached. This uses the following approach

  1. A new "Contracts" project that contains the DTOs used by the web api, and also an interface for fetching that data, e.g. IWeatherForecastService
  2. A class implementing said interface is added to the WASM app and is simply a proxy to make HttpClient calls through to the API server endpoint.
  3. The server registers a class that implements said interface by (simulating) fetching data from a database (Task.Delay).
  4. The server API endpoint uses that service to fetch data and return it to the client.

This means we can inject IWeatherForcecastService into the /Weather page in order to retrieve the data. When server rendering it will use the server implementation and go straight to the simulated Db, and WASM rendering it will be injected with the proxy class instead and call to the API.

The benefits of this are

  1. The I{Weather}Service naming pattern is well-known, and easy to understand for those who've never seen it before.
  2. The client's implementation merely acts as a proxy through to the server service (via an API call), similar to good old RPC.
  3. The page works on both WASM and Server-Side rendering without modification to the Razor page.
  4. When server rendering, the server doesn't have to call itself via HTTP - instead it executes the same code that the API endpoint would have, which is obviously faster.

MyHostedBlazorWasmApp.zip

EdCharbeneau commented 2 months ago

I recently wrote a Blazor Interactive WebAssembly basics article. https://www.telerik.com/blogs/building-interactive-blazor-apps-webassembly

The end product was quite lengthy because I had to explain and provide examples of all the issues that arise when using the new Web App template. From the beginning, a selling point of Blazor is it's "low learning curve" for .NET developers. However, we now have a host of new features that improve the framework, but also increase complexity. A template to deal with some of the complexity and set best practices is desperately needed.

I think @mrpmorris has a good sample, this is a better organized version of what I wrote in my article.

Hona commented 2 months ago

At the moment, the current template doesn't show the intention or reason behind the web app project/client project.

@mrpmorris Has a good suggestion with the contracts project, demonstrating an interface, the double injection using different providers.

An awesome improvement (no idea on the feasibility) would be to make it 'feel' like writing traditional WASM or Server code, but the prerender persistence/auto issues are handled for you (opt in/out?).

The ideal dev ex would be quick to figure out intention & not have to google issues (find out you should disable prerendering, which is a must have for SEO).

I think optimising for the best DevEx of prerender ON & Auto render mode, will fix a lot of issues on the side.

For example, an app I'm playing with using the Blazor Web App paradigm has to write a lot of code to call 1 Web API endpoint, then display that data as is.

Example page here: https://github.com/Hona/TempusHub2/blob/main/UI/src/TempusHub.WebUI/TempusHub.WebUI.Client/Components/Pages/Leaderboards/Map/MapPage.razor

mrpmorris commented 2 months ago

With regards to the double-render, the following code works fine. Note the code to simulate fetching data has been moved into FetchDataAsync for brevity.

public partial class Weather : ComponentBase
{
    private WeatherForecast[]? forecasts;

    [Inject]
    private PersistentComponentState PersistentComponentState { get; set; } = null!;

    protected override async Task OnInitializedAsync()
    {
        PersistentComponentState.RegisterOnPersisting(SaveStateAsync);
        if (!PersistentComponentState.TryTakeFromJson(nameof(forecasts), out forecasts))
        {
            await FetchDataAsync();
        }
    }

    private Task SaveStateAsync()
    {
        PersistentComponentState.PersistAsJson(nameof(forecasts), forecasts);
        return Task.CompletedTask;
    }

    private async Task FetchDataAsync()
    {
        // ...
    }
}

Here are my thoughts for improvement.

The code above is nice and easy mainly because there is only one property to persist. Once there are multiple calling x.TryTakeFromState for each is laborious. I would like to propose being able to write (something like) the following code instead.

public partial class Weather : ComponentBase
{
    public WeatherForecast[]? Forecasts; // Made public
    public int SelectedIndex;
    public string SomeOtherState = "This is serialized too.";

    protected override async Task OnInitializedAsync(bool stateWasDeserialized)
    {
        if (!stateWasDeserialized)
        {
            await FetchDataAsync();
        }
    }
    // ...
}
  1. All public fields and properties (with getter + setter) are automatically serialized and deserialized (presumably excluding parameters/cascading values as these will be set by outer components and passed in).
  2. Any of those can be excluded from serialization if decorated with [DoNotSerializeState].
  3. Lower visibility members can be included in serialized if decorated with [SerializeState].
  4. Introduce an overloaded OnInitializedAsync that has a bool parameter indicating whether or not the component state was deserialized, by default this just calls OnInitializedAsync (which can possibly be marked [Obsolete] in V10 and removed in V11?)
  5. All components are automatically opt-in unless the coder of the component explicitly marks their component or assembly with [DoNotSerializeState].

I think this gives the easiest experience for new users and also allows more experienced users to tweak more precisely if needed.

  1. Automatically deals with serializing state from the server pre-render down to the WASM client to avoid double-rendering.
  2. The the user must explicitly write code to not fetch data when deserializing state, so existing code bases will continue to work.
  3. The default implementation of the new overloaded OnInitializedAsync can call OnInitializedAsync, so no code breakage.
  4. In apps where 99% of pages are accessed only via WASM navigation and not server pre-rendering, it will have no impact at all.

Possible alternative names for this parameter hasPreRenderedState. It would be nice if the meaning could be negated in order to avoid the ! in the if, but I can't think of a good name. Chat-GPT (Cheat-Petey) suggests stateIsUninitialized - which I think works quite well.

if (stateIsUnitialized)
  await FetchDataAsync();

Another benefit of this approach is that it could be retrofitted to .net 8 using a Roslyn Analyzer, the only difference being there would be no override on OnInitializedAsync - the OnInitialized would have to be implemented by Roslyn, determine if there is state to deserialize, and then call a new partial OnInitializedAsync with the new parameter. But I don't know if that's a requirement or not.

Hona commented 2 months ago

@mrpmorris I love that design. I want to leave my thoughts that follow on from your ideas.

I believe for the success of Blazor there should be a lot more magic/convenience by default, and it be suggested by way of the templates.

The basis is using Auto render mode with prerendering on. My experience of using this is its incredibly clunky & beginner unfriendly (e.g. when does something render, what project do I put this in, is this called on the client, or the server, or both (twice?))

The suggested paradigm relies on server first, client side second.

I'd suggest the following structure:

Contoso

Contoso.Contracts

Contoso.Client

Now, what would this look like?

Contoso/Components/WeatherPage.razor

@page "/weatherforecast"
@inject IWeatherRepository WeatherRepository

<!-- Put a table here -->

@code {
    public WeatherForecast[] Forecasts;

    protected override async Task OnInitializedAsync(bool stateWasDeserialized)
    {
        if (!stateWasDeserialized)
        {
            Forecasts = await WeatherRepository.GetForecastsAsync();
        }
    }
}

Contoso.Contracts/Models/WeatherForecast.cs

public record WeatherForecast(...);

`Contoso.Contracts/Repositories/IWeatherRepository.cs

public interface IWeatherRepository
{
    Task<WeatherForecast[]> GetForecastsAsync();
}

Now the important stuff, showing which is server, which is client implementations.

Contoso/Repositories/WeatherRepository.cs

public class WeatherRepository(AppDbContext DbContext) : IWeatherRepository
{
    public Task<WeatherForecast[]> GetForecastsAsync()
    {
         // Simulating some sort of EF query
         return await DbContext.Forecasts
             .ToListAsync();
    } 
}

Contoso.Client/Repositories/WeatherRepository.cs

public class WeatherRepository(HttpClient Client) : IWeatherRepository
{
    public Task<WeatherForecast[]> GetForecastsAsync()
    {
         // Simulating some sort of EF query
         return await Client.GetFromJsonAsync<WeatherForecast[]>("/api/weatherforecasts");
    } 
}

Now the wireup code

Contoso.Client/Program.cs (I'd prefer something like DependencyInjection.cs or something that shows this is only for wiring up contract interfaces to implementations)

...
builder.Services.AddSingleton<IWeatherRepository, WeatherRepository>();
...

Contoso/Program.cs

...
builder.Services.AddSingleton<IWeatherRepository, WeatherRepository>();
...

I think in this way there could even be the possibility to automatically detect what is server only, client only, where there is only a server implementation of the interface, it can only be on the server.

There was some code omitted obviously, interactivity models I generally ignored, where prerenders/client and server switches are the priority for me.

Further Improvements

I'd even consider a separate method on ComponentBase, taking this snipped from the previous message:

protected override async Task OnInitializedAsync(bool stateWasDeserialized)
{
    if (!stateWasDeserialized)
    {
        await FetchDataAsync();
    }
}

I think this leads to two separate concerns being thrown into one method. A fresh load is inside an if, or past a guard clause, then deserialization cleanup/handling is next to it.

I feel there is enough to warrant something like the following (ignoring async overloads):

// Note that this method would be written the same with classic blazor paradigms
protected override async Task OnInitializedAsync()
{
        FetchDataAsync();
}

// Note that this is opt in, if the framework's magic is not correct for specific use cases
protected override void OnDeserialized()
{
    // Do something 
   // Maybe provide parameters mentioning what changed?
}

I think if:

Then, Blazor could be open to wider adoption.

Feedback from the 10 or so different people I've seen try the Blazor Web App paradigm, have all come to the same conclusion as me, where you should just stick with the classic project structures. The foundation is there for the new methods, but let's make it easy to use.

Keen to hear everyone's thoughts :)

MackinnonBuck commented 1 month ago

Update

After some internal discussion, our inclination is to not make any further changes to the Blazor Web App project template in .NET 9. The primary reasoning for this is:

I'll use this comment to summarize the internal discussion, including the tradeoffs and potential approaches we considered. We'd greatly appreciate feedback on what specific changes to the template you would like to see, so that the changes we do eventually make will be well-informed.

Original proposed changes

The original proposal was to make the following changes to variations of the Blazor Web template that use WebAssembly interactivity only:

The following sections describe some tradeoffs we considered when evaluating these changes.

Tradeoff 1: UX regression

One fairly significant tradeoff is that the user gets a blank screen on the weather page during WebAssembly initialization. This is an especially poor UX on slower connections, where the WebAssembly runtime can several seconds to download and initialize.

Potential solution: Add a loading indicator

A loading indicator (similar to the one in the WebAssembly standalone template) could provide some feedback to the user while the WebAssembly runtime is downloading and starting. We can do this by creating a WebAssemblyLoadingIndicator component:

WebAssemblyLoadingIndicator.razor

@rendermode InteractiveWebAssembly

@if (!Platform.IsInteractive)
{
  <svg class="loading-progress">
    <circle r="40%" cx="50%" cy="50%" />
    <circle r="40%" cx="50%" cy="50%" />
  </svg>
  <div class="loading-progress-text"></div>
}

This component works by:

  1. Prerendering the loading indicator markup onto the page. The loading indicator styling gets updated by CSS variables that reflect the WebAssembly loading progress.
  2. After interactivity starts, the component stops rendering the loading indicator to allow other interactive content to take its place.

When using global WebAssembly interactivity, we can simply plop the <LoadingIndicator> in App.razor. However, for the per-page interactivity case, things get a little more tricky; we only want to render the loading indicator on pages that utilize WebAssembly interactivity (with prerendering disabled).

One way to achieve this is to move MainLayout.razor and NavMenu.razor to the .Client project and introduce a WebAssemblyLoadingLayout component that renders a loading indicator alongside the body.

WebAssemblyLoadingIndicator.razor

@inherits LayoutComponentBase
@layout MainLayout

<LoadingIndicator />
@Body

Pages that want to display a loading indicator can do the following:

@page "/weather"
@layout WebAssemblyLoadingLayout
@rendermode @(new InteractiveWebAssemblyRenderMode(false))

This mostly results in a better UX, but:

There are other ways to add the loading indicator to the page that don't require an explicit gesture from the page component, but are a bit too "gross" to put in the template. For example, rather than introducing a new WebAssemblyLoadingLayout, we could update the existing MainLayout and keep it in the server project:

@inherits LayoutComponentBase

<div class="page">
...

  <main>
+   @if (!Platform.IsInteractive && PageRenderMode is InteractiveWebAssemblyRenderMode { Prerender: false })
+   {
+     <LoadingIndicator />
+   }

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

...

@code {
+ private IComponentRenderMode? PageRenderMode
+   => (Body?.Target as RouteView)?.RouteData.PageType.GetCustomAttribute<RenderModeAttribute>()?.Mode;
}

The general internal consensus was that to address the UX regression, we're introducing another tradeoff adding complexity to the template.

Potential solution: Enable prerendering

We also briefly considered whether there was a way to cleanly make the Weather page prerendered but still use HttpClient to fetch data after interactivity starts. We even briefly considered whether a new framework feature could help with this. For example:

@page "/weather"
@rendermode InteractiveWebAssembly

@inject? HttpClient Client @* Some kind of new optional inject *@

@code {
  protected override async Task OnInitializedAsync()
  {
    if (Client is not null)
    {
      forecasts = await Client.GetFromJsonAsync<WeatherForecast[]?>("/api/weather");
    }
  }
}

However, we quickly decided against this since it doesn't broadly solve the problem of being able to write components without having to worry about how they run on the server.

Tradeoff 2: Not a great demonstration of interactivity

The weather page currently demonstrates stream rendering, which was one of the major features included in the .NET 8 release. We'd be swapping out that demonstration in favor of a different one that's arguably less suitable for the scenario, given the page is completely static after loading weather data.

We've received feedback that demonstrating use of HttpClient would be helpful, but we don't have a clear sense of what the magnitude of the opposite feedback would be; how many developers would ask that we revert to using stream rendering in the template?

There's also the consideration that https://github.com/dotnet/aspnetcore/issues/51584 might get implemented in .NET 9. In many cases, that feature could serve as a better solution to the "double fetch" problem than disabling prerendering and using an HttpClient.

Requested feedback

To help us decide what changes to make to the template, we'd love to hear ideas from the community about what specific changes you'd like to see. Please bear in mind that one of our goals is to keep these templates as straightforward and approachable as possible. Thanks!

SteveSandersonMS commented 1 month ago

I'm 100% fine with the decision made to not change this in .NET 9. Template changes are something we don't want to churn on repeatedly, so doing it earlier in a cycle makes sense.

If we come back to this in .NET 10, what about the following ways to address the concerns above? Not sure if this approach has been considered but it seems simpler and more reliable to me:

I haven't tried it but would expect this to cause the loading indicator to not show up (even as a flicker) except when loading is actually happening, and would work the same whether it's global or per-page interactivity.

MackinnonBuck commented 1 month ago

@SteveSandersonMS, that's also an approach I tried out, although I ended up with a slightly different version of it:

/* Hide the loading indicator if we haven't started loading WebAssembly resources */
html:not([style*="--blazor-load-percentage"]) > * .loading-indicator {
    display: none;
}

/* Hide the loading indicator if the current page contains any content */
article > .loading-indicator:not(:only-child) {
    display: none;
}

I found that relying on a CSS variable that gets removed after loading completes wasn't totally ideal because:

My solution was to add a second CSS rule to hide WebAssembly loading indicator if there's any content on the page. Unfortunately, this was still problematic:

I can think of some other ways to overcome those problems, but this is where I stopped to try an approach that programmatically adds and removes the loading indicator. Maybe the CSS approach is worth revisiting again!

AlbertoPa commented 3 weeks ago

Seeing this postponed to Net 10, over a year and half from now, when the Net 9 roadmap is barely halfway, and the issues discussed here have been reported long before the Net 8 release is disappointing. I am not sure what additional feedback should be given about what we would like to see in templates: abundant feedback was given before the release of Net 8, and it was often ignored or dismissed by claiming the Web App already has equivalent functionality, even when implementing the feedback in question meant leaving the existing Net 7 templates available side-by-side to the new ones until the latter reached a higher level of maturity.

At any rate, my two (old) cents. Having a template where the developer can choose what render mode to use and generate an app that can be used as a starting point without major surgery just to achieve basic functionality, as needed now with the Web App, would be useful. For example:

1) App with local user accounts in the three render modes (already available), with a realistic example of an API call (currently missing: there is a database already using EF Core, why not make a trivial model/query/api call instead than hardcoding data etc.?).

2) App with Entra using the Microsoft Identity Web library (currently missing, some samples available using generic OIDC, which could also be an option for third-party OIDC providers) demonstrating the different render modes and API calls (already in the works - there is an issue open about it).

In these options, demonstrate persistence (it would be a start to have one page demoing pre-rendering persistence). It can be improved later for Net 10, if a better way is found. I think this would also reduce the effort to dig out the information from the documentation, which is sometimes spotty.

P.S. Templates have changed drastically in Net 8, significantly increasing the amount of work needed to get up to speed, and deeply changing the development pattern (yes, the previous functionality can be recovered, but also that takes time to figure out). They have also changed after the Net 8 release, to allow integration in Aspire. I don't see much concern with changing templates, so I would say pushing a partial fix to the issues discussed in this thread should have higher priority than not altering templates, which does not seem a concern in other circumstances.

DjeeBay commented 5 days ago

To be honest, from the beginning I've found that "Blazor Unified", or Auto, is impressive technically but adds too much overthinking and complexity. As a developer you need to decide which render mode you want and answering that question requires a deep knowledge on what it takes.

By default VS suggests Auto mode, but when you want to add an API project (which is very common and was implemented before .Net 8) you'll realise that some tradeoffs are needed. Even the full (global) WASM template isn't a ready to go as mentioned earlier in this issue.

So the default templates are irrelevant in most use cases and you need to find tricks or disable some things. Back in .Net 7 the templates were ready to go.

Currently Auto, or trying to run a component in both Server and WASM is not a good choice IMO. There are a too many differences wether you're running on Server or on Client. Before .Net 8 that choice was made once for all at the start of the project and it was OK because those 2 modes are and will remain different no matter how hard you try to unify them.

Finally to me Auto should be a third mode for the adventurers / experts that exactly know what they are doing. But default templates should stay stupid simple, full Server or full WASM, and with WASM comes an API call so you are ready to start developing your solution.

.Net 7 WASM template was a real world sample. Now we miss it. When we choose (global) WASM by default we don't want components that are able tu run on the Server. And if it's needed it's up to the developer to do the necessary tricks.

To summarise, three templates would be great :

  1. WASM : like .Net 7 with an API call and a Shared project that shows the real benefit of Blazor : sharing code between front-end and back-end
  2. Server : all is rendered backend with a WS connection
  3. Auto (Advanced) : whatever sample you want, but this mode adds a warning to notice developers that it needs advanced knowledge and demonstrates the powerful things you can do in an advanced mode.