slaveOftime
blazorfsharpblazorhtmx

Navigation and form enhancement in dotnet 8 blazor is not enough

2023-11-25

With dotnet 8, blazor got many new features, the most interesting features for me are the navigation and form enhancement. So after it's final release, I try to upgrade my blog site with those features.

My post is very simple, just two main page, one is for list all the posts, another is for the detail.

For main page

I have a search filter to allow user to search by some keyword. To make this work with form enhancement, the main thing to change is:

form {
    ...
    dataEnhance
    formName "post-search"
    html.blazor<AntiforgeryToken>()
    ...
}

type PostList() =
    ...
    [<SupplyParameterFromQuery>]
    member val query = "" with get, set
    ...

So when user enter to submit the search form, my PostList component can receive the query parameter and render the full main page and return to browser, blazor.web.js will intercept this form submit request and patch the current page with the returned full main page content. And the data-enhance for the form element will keep the scroll position.

So here, we got the first problem: it will always load the full page, the content will be large and the server also will need more time to fetch related data and spend more time to render.

For detail page

Detail page has more features:

  1. SSR for the post detail
  2. Delay 5 seconds to increase view count automatically
  3. Like survey
  4. Comments

Feature 2 was used implemented by blazor custom element which will open a new websocket connection. But now this cannot be achieved because blazor.web.js does not support that, even it support, there is also a problem which is: when navigate from main page to detail page, the navigation enhance is truned on, and js cannot be executed by default. To make it work we may introduce more logic and hook it in the data enahncement event from blazor. And I got no luck to success to implement it.

Feature 3 can be done with the enhanced form, but still the problem is the full page load from server.

Feature 4 is supposed to be loaded lazily when user scroll to the bottom of the detail page. But now, we may need to use streaming feature of blazor. Then this means, every time when navigate with query or to some hash tag, or sumbit the like survey, the server will need to streming the comments.

After all the changes, I found another problem:

When navigate from main page to detail page, the scroll position is always the same. Which means if I scroll to some position in detail page and navigate back to main page, I will not navigate back to the position where I click to start to navigate to detail page, user then will be lost and has to scroll to the old position manually.

This is not easy to fix, because I did not found a way to record the old page's scroll position when navigation enhancement happens. Currently, there is only one event which will be triggered after the navigation enhacement happened.

Rescure me by the improved htmx

Before, I already use htmx + blazor custom element to implement current blog site. Features like increase view count in 5s delay, post like survey, add new comments are all supported by custom elements which will have to create a websocket connection. But the good thing is, it is very simple to use custom element, it is just a normal blazor component in the server.

I can use htmx to implement all the features which is implemented by custom element, but it was quite hard to handle complex logic, especially I have to introduce a lot of endpoints to serve the content for htmx's need. For example, below was the endpoints and only serving for simple features:

let viewGroup = group.MapGroup("/view")
viewGroup.MapGet("/post-list", Func<_> PostList.Create)
viewGroup.MapGet("/post/{id}", Func<_, _>(fun (id: Guid) -> PostDetail.Create id))
viewGroup.MapGet("/post/feed/{id}", Func<_, _>(fun (id: Guid) -> PostDetail.CreateForFeed id))
viewGroup.MapGet("/post/{postId}/comment", Func<_, _>(fun (postId: Guid) -> PostComment.Create postId))
viewGroup
    .MapPost(
        "/post/{postId}/comment",
        Func<_, _, _>(fun (postId: Guid) (parentComment: Nullable<Guid>) -> PostComment.CreateNewComment(postId, parentComment))
    )
    .RequireAuthorization()

how to make it simple now

When I upgrade Fun.Blazor to dotnet 8, I found I can make the integration between blazor and htmx way easier (And all those concept can be also implemented for csharp too.)

I introduced APIs to automatically map all the needed components:

app.MapBlazorSSRComponents(Assembly.GetExecutingAssembly(), enableAntiforgery = true)
app.MapFunBlazorCustomElements(Assembly.GetExecutingAssembly(), enableAntiforgery = true)

And hxRequestBlazorSSR, hxGetComponent and hxRequestCustomElement etc. for dom attributes, so it can automatically generate related the query and send to the backend. So all the magic routing endpoints can be removed. Also some magic query name can be type safe now.

Take the post search as the example:

input {
    name "query"
    value searchQuery

    // Before it is 
    hxGet "/view/post-list"
    // Now it is 
    hxGetComponent typeof<PostList>
    
    ...
}

After the magic route url is removed, complex logic can be done and hook up very easily.

In short way, the core concept has two things need to be done:

  • Expose blazor components with an endpoint automatically (MapBlazorSSRComponents, MapFunBlazorCustomElements)
  • Build the url for hx-get, hx-post etc to use based on the blazor component type (hxGetComponent (QueryBuilder().Add((fun x -> x.query), searchQuery)))

For integrate htmx with blazor custom-element, you can check my old blog, even now it is way more simplier, but the core concept is the same.

Below is a simple counter which is displayed when you scroll and it is in viewport and then delay for 3 seconds. And when you increase to 4 you will get increased in delay.

How to use DemoHtmxCounter

section {
    class' "p-5 rounded shadow-lg bg-green-100"
    hxTrigger' (hxEvt.intersect, delayMs = 3000)
    hxGetComponent (QueryBuilder<DemoHtmxCounter>().Add((fun x -> x.Count), 1))
}

Below is a simple counter which is prerendered directly in first load

How to use DemoHtmxCounter with prerender

section {
    class' "p-5 rounded shadow-lg bg-blue-100"
    html.blazor (ComponentAttrBuilder<DemoHtmxCounter>().Add((fun x -> x.Count), 2))
}
Count = 2

Below is the counter's implementation

DemoHtmxCounter

type DemoHtmxCounter() as this =
    inherit FunComponent()

    [<Parameter>]
    member val Count = 0 with get, set

    override _.OnInitializedAsync() = task { if this.Count > 3 then do! Task.Delay(1000) }

    override _.Render() = form {
        hxSwap_outerHTML
        hxGetComponent typeof<DemoHtmxCounter>
        div {
            class' "font-bold text-fuchsia-600"
            $"Count = {this.Count}"
        }
        div {
            class' "join"
            button {
                name (nameof this.Count)
                value (this.Count + 1)
                class' "join-item btn btn-sm btn-info"
                "Increase"
            }
            button {
                name (nameof this.Count)
                value (this.Count - 1)
                class' "join-item btn btn-sm btn-info"
                "Decrease"
            }
        }
        if this.Count >= 3 then
            progress { class' "progress progress-primary h-1 mt-1 htmx-loading-indicator" }
    }

Summary

After some exploration, I found blazor's navigation and form enhancement features are very limited and inefficient to use. But with the new APIS from Fun.Blazor and htmx, things become way simplier to implement and maintain.

If we think in another way, with blazor.web.js:

  • Render a page in static (streaming supported)
  • A full page can be re-rendered caused by navigation or form enhancement with different parameters from query or form, and only patch the diff when fetched in browser
  • An interactive component can be loaded (websocket connected) at anytime when the full page is fetched from server with different parameters and patched in browser

But with htmx + blazor.server.js (❤️😍):

  • Render a page in static (streaming is not supported, but can use htmx to send another request to achieve, it can be more flexible, because we can only render it when it is needed, like in viewport etc.)
  • Any blazor component can be re-rendered with different parameters from query or form (no need to render the full page on server), and only patch the diff when fetched in browser
  • An interactive component can be loaded by blazor custom-element (websocket connected) at anytime in any place with any event

Do you like this post?