egil / Htmxor

Supercharges Blazor static server side rendering (SSR) by seamlessly integrating the Htmx.org frontend library.
MIT License
109 stars 12 forks source link

"Template fragment" component in Htmxor #25

Closed egil closed 2 months ago

egil commented 2 months ago

[UPDATED: combined a bunch of comments below into a general description/issue].

Htmxor needs a good way to do "template fragments". The library currently have a very rough first attempt, <HtmxPartial>. It may need a better name and certainly more features.

Let me start by explaining how <HtmxPartial> works right now.

Lets use this as the example:

@page "/counter"
<PageTitle>Counter</PageTitle>
<div id="counter">
    <HtmxPartial>
        <p role="status">
            Current count: @CurrentCount
        </p>
        <button class="btn btn-primary"
                hx-put="/counter"
                hx-vals='{ "CurrentCount": @(CurrentCount) }'
                hx-target="#counter"
                @onput="IncrementCount">
            Click me
        </button>
    </HtmxPartial>
</div>

@code {
    [SupplyParameterFromForm]
    private int CurrentCount { get; set; } = 0;

    private void IncrementCount(HtmxContextEventArgs args)
    {
        CurrentCount++;
    }
}

Normal request or hx-boosted request

During a normal request or hx-boosted request, the full component tree is rendered, i.e. starting with App.razor, all the way down to the Counter.razor page.

Here the full component tree is rendered out, including <HtmxPartial> and its child content.

hx-request to /counter

When a hx-request is received that targets the component, that component's component-tree is searched, and since a component is found whose [Parameter] public bool Condition { get; set; } = true; returns true, only that <HtmxPartial> component and its children is rendered.

Normal request or htmx request

During rendering, if a component is reached, its Condition parameter is checked, and if it returns false, the component and its children is not rendered.

This happens during both normal and htmx requests.

A more detailed discussion of the proposed improvements is in the comment https://github.com/egil/Htmxor/issues/25#issuecomment-2088498304 below.

tanczosm commented 2 months ago

What exactly is the purpose of HxPartial?

egil commented 2 months ago

The purpose is to have parts of a component that only render conditionally, or more generally, they are an implementation of https://htmx.org/essays/template-fragments/.

For example:

https://github.com/egil/Htmxor/blob/4f19d7ed5bd349950144fba14194519a8c455fdb/samples/MinimalHtmxorApp/Components/Pages/Counter.razor#L1-L39

Here, on a full-page request, the entire markup is rendered. On htmx requests, only the child content of the HxPartial is rendered.

tanczosm commented 2 months ago

This section is really worth thinking about a bit regarding some of the various angles you could approach. The idea of "full page request" needs to be carefully considered, because I could see nested partials being used frequently.

This actually was the core idea I originally wanted with the HxSwappable component, because typically the areas that you would do this with align with either standard swaps or out-of-bound swaps. To make them work you always need a container with an id and then if the request is HTMX you can add the OOB hx-swap-oob="true" attribute to the response without any bad effects even if the swap isn't oob.

A typical use case on a page might have more than one "HxSwappable" section akin to your HtmxPartial, so the rendered result just needs to be:

  1. If htmx then render each of the HxSwappable containers with each having hx-swap-oob="true" added so they each neatly swap into the right places
  2. If not htmx, render the whole page inline

The other thing I would have done would be to make sure that each HxSwappable/HtmxPartial can be directly routable somehow (by it's container id?).

If the page is: /news/ then this routes to /news/weather or /news/?hxs=weather or something. I'm not sure about the easiest solution to this problem.

<HxSwappable Id="weather"></HxSwappable>

renders as

<div id="weather" hx-target="#weather">
</div>

Perhaps hx-target be used to directly route HxSwappable/HxPartial components if this is the HxSwappable output? Then if you spot the current request is htmx and the hx-target matches the Id of a swappable component then you ONLY render that component.

The other major factor to consider is the flow of data to these sections. Typically you need to compose these swappable sections because they enclose components that will experience state changes. I've most typically made use of them when I have a section of page that may take longer to render than the rest of the page because there is some higher level of latency when loading data and you want to present something to the user (like a skeleton).

React has a component called Suspense that has similar effects to what I'd love out of a Swappable section.

Perhaps something like this:

<HxSwappable Id="videos" Mode=@RenderModes.Lazy>
   <Fallback>Video skeleton page here</Fallback>
   <ChildContent>Long loading content</ChildContent>
</HxSwappable>

This is spitballing here but when rendering HxSwappable components if the render mode is Lazy then render Fallback only (ignoring ChildContent rendering completely so as to not consume resources trying to run the long-running code) and use the htmx lazy loading pattern to load in content. When rendering that swappable from htmx then render the content directly, ignoring fallback.

If using hx-target to limit the loading page to just one HxSwappable by Id, then the output could look like this:


<!-- regular or htmx page request is made to /videos which returns -->

<container>
    <div id="videos" hx-target="#videos" hx-get="/videos" hx-trigger="load">
        Video skeleton page here
    </div>
</container>

<!-- htmx request is then made to /videos with hx-target of #hx_videos which returns -->

<div id="hx_videos" hx-target="#videos" hx-swap="innerHTML" hx-swap-oob="true">
    Long loading content
</div>

The other idea which is a more htmx friendly alternative to streaming, would be:

When the render mode is RenderModes.Lazy (or whatever) and the current route isn't directly to this component, then you immediately render fallback and begin rendering ChildContent out-of-band, making the result available on a cached basis via some unique url. That way the user gets the response quickly.

e.g. On first load the above HxSwappable component renders as:

<div id="hx_videos" hx-get="/special/deferred/render/url/8c691763-21b2-4c78-b0e4-5f7236fab91d" hx-trigger="load">
    Video skeleton page here
</div>

Then the page loads, a request is made by htmx to /special/deferred/render/url/8c691763-21b2-4c78-b0e4-5f7236fab91d and retrieves the result of rendering the ChildContent.

If this isn't feasible then at least having the Swappable component directly routable would work.

egil commented 2 months ago

Let me start by explaining how <HtmxPartial> works right now.

Lets use this as the example:

@page "/counter"
<PageTitle>Counter</PageTitle>
<div id="counter">
    <HtmxPartial>
        <p role="status">
            Current count: @CurrentCount
        </p>
        <button class="btn btn-primary"
                hx-put="/counter"
                hx-vals='{ "CurrentCount": @(CurrentCount) }'
                hx-target="#counter"
                @onput="IncrementCount">
            Click me
        </button>
    </HtmxPartial>
</div>

@code {
    [SupplyParameterFromForm]
    private int CurrentCount { get; set; } = 0;

    private void IncrementCount(HtmxContextEventArgs args)
    {
        CurrentCount++;
    }
}

Normal request or hx-boosted request

During a normal request or hx-boosted request, the full component tree is rendered, i.e. starting with App.razor, all the way down to the Counter.razor page.

Here the full component tree is rendered out, including <HtmxPartial> and its child content.

hx-request to /counter

When a hx-request is received that targets the component, that component's component-tree is searched, and since a component is found whose [Parameter] public bool Condition { get; set; } = true; returns true, only that <HtmxPartial> component and its children is rendered.

Normal request or htmx request

During rendering, if a component is reached, its Condition parameter is checked, and if it returns false, the component and its children is not rendered.

This happens during both normal and htmx requests.

egil commented 2 months ago

I like your ideas suggested in https://github.com/egil/Htmxor/issues/25#issuecomment-2088376473. Good stuff!

But, I want to see if we can figure out a "base" component type, PartialBase, that e.g. a <HxSwappable> component can inherit from, or another 3rd party custom component can inherit from, that enables all your good ideas.

Because it would be great if PartialBase is the only component type the HtmxRenderer needs to know about.

So, let's establish some rules for PartialBase. Here are my current suggestions:

During full page requests:

  1. <HtmxPartial Condition=true> is the default, e.g. the same as <HtmxPartial>

  2. <HtmxPartial> is only rendered if its Condition returns true.

  3. Multiple <HtmxPartial> component can render during the same request. For example:

    <HtmxPartial Condition=true>Foo</HtmxPartial>
    <HtmxPartial Condition=false>Bar</HtmxPartial>
    <HtmxPartial Condition=true>Baz</HtmxPartial>

    This will result in Foo Baz being returned.

  4. A <HtmxPartial> does not render if it is a child of inside another <HtmxPartial Condition=false>, independent of what its Condition parameter returns. For example:

    <HtmxPartial Condition=false>Foo
      <HtmxPartial Condition=true>Bar</HtmxPartial>
    </HtmxPartial>    
    <HtmxPartial Condition=true>Baz</HtmxPartial>

    This will result in Baz being returned. Equally:

    <HtmxPartial Condition=true>Foo
      <HtmxPartial Condition=false>Bar</HtmxPartial>
    </HtmxPartial>    
    <HtmxPartial Condition=true>Baz</HtmxPartial>

    This will result in Foo Baz being returned.

During HX requests:

  1. If the target component's component tree does NOT contain any <HtmxPartial> components, the full component tree is rendered out, e.g. a request to /component-without-partials:

    @page "/component-without-partials"
    <h1>hello world</h1>
    <ChildComponent Message="hi" />

    Will result in both <ComponentWithoutPartials> and all its children being rendered.

  2. If the target component's component tree contains one or more <HtmxPartial> whose returns on return true, then ONLY these <HtmxPartial> components are rendered. E.g. a request to /component-partials:

    @page "/component-partials"
    <h1>hello world</h1>
    <HtmxPartial>
      <ChildComponent Message="hi" />
    </HtmxPartial>

    This will result in both <ChildComponent > being rendered.

  3. If the target component defines a HxLayout, then that layout is always rendered, independent of whether there exists <HtmxPartial> components in the render tree or not. For example:

    @page "/component-without-partials"
    @attribute [HxLayout(typeof(HtmxorLayout)]
    <h1>hello world</h1>
    <ChildComponent Message="hi" />
    @page "/component-partials"
    @attribute [HxLayout(typeof(HtmxorLayout)]
    <h1>hello world</h1>
    <HtmxPartial>
      <ChildComponent Message="hi" />
    </HtmxPartial>

    will both render any target component as a child of the HtmxorLayout component.

This should enable all your ideas through custom components that inherit from <HtmxPartial>.

Currently, the Htmxor does these things:

tanczosm commented 2 months ago

What is the mechanism for targeting that HtmxPartial component?

Normal request or hx-boosted request

During a normal request or hx-boosted request, the full component tree is rendered, i.e. starting with App.razor, all the way down to the Counter.razor page.

This is potentially incorrect. On an hx-boosted request the rendered output can still target a particular selector. The documentation doesn't give you much to indicate it but hx-target is still adhered to along with hx-swap as well. Even if hx-target is body then hx-boosted requests only replace the body of the current page without changing the url (used for non-GET requests). If hx-target is "window" or empty (?) then it's a full page request.

For example:

<navbar>
    <a href="/home" hx-swap="innerHTML transition:true scroll:main:top show:none" hx-boost="true" hx-target="#main_content">Home</a>
</navbar>

<main>
    <div id="main_content"></div>
</main>

This would load the content at url /home as boosted, swapping it into #main_content with a transition, immediately setting the scrollbar to the top of main without any animation, and overriding any default show scroll animations. This would allow you to load content into a particular div just as you would with hx-get.

As such, a full page request to /home should render normally and a boosted page request to /home is the same as any other hx-get.

I see you added something more so I'm going to read that before adding anything further.

egil commented 2 months ago

Let's keep how to handle hx-boost out of this discussion. Moved to issue #26.

egil commented 2 months ago

With the rules above in mind, lets try to implement HtmxSwappable (completely untested):

@using Htmxor.Http
@inherits PartialBase
@inject HtmxContext Context
@code {
    [Parameter, EditorRequired]
    public required RenderFragment Loading { get; set; }

    [Parameter, EditorRequired]
    public required RenderFragment ChildContent { get; set; }

    [Parameter, EditorRequired]
    public required string Id { get; set; }

    [Parameter]
    public bool Lazy { get; set; }

    // Render both during normal and htmx requests
    protected internal override bool ShouldRender() => true;
}
@if (Lazy)
{
    if (Context.Request.IsHtmxRequest)
    {
        <div id="@Id" hx-target="#@Id" hx-swap="innerHTML" hx-swap-oob="true">
            @ChildContent
        </div>
    }
    else
    {
        <div id="@Id" hx-target="#@Id" hx-get="@Context.Request.CurrentURL.PathAndQuery" hx-trigger="load">
            @Loading
        </div>
    }
}
else
{
    <div id="@Id">
        @ChildContent
    </div>
}
tanczosm commented 2 months ago

The base component idea works. I'm going to parse through your ideas and see if anything jumps out that is missing.

Quick question, if I nest a component inside of a partial with Condition=false, no lifecycle methods for that component are ever called correct?

egil commented 2 months ago

Quick question, if I nest a component inside of a partial with Condition=false, no lifecycle methods for that component are ever called correct?

This is an interesting discussion.

We could go with no, meaning that SetParameterAsync (which kicks off all other life cycle methods, if any) is never called on child components, meaning no render tree is created, thus, the child components and markup/elements are never instantiated and never exist.

However, it could be that the element that was the trigger for the hx-request, e.g. <button hx-get="/url" @onget=(() => some logic here)>BTN</button>, is the child of a partial with Condition=false. In this case, the event handler will never be recreated and Htmxor will not be able to call it.

An alternative is to go with an approach similar to Blazor's ComponentBase.ShouldRender(). It will allow a single render and any number of invocations of life cycle methods. This could be useful if "condition" is determined dynamically based on async operations.

Ultimately, when it comes time to produce markup that will be sent to the client, the renderer will not generate markup from components inside partials with condition false, but any child components and their render trees will be created.

tanczosm commented 2 months ago

We could go with no, meaning that SetParameterAsync (which kicks off all other life cycle methods, if any) is never called on child components, meaning no render tree is created, thus, the child components and markup/elements are never instantiated and never exist.

Is the Condition value cascaded?

I think my chief concern is if I use template fragments to embed long-running asynchronously loaded components that I can adequately know whether to fire off any data loading events that might be used to build the render tree after SetParametersAsync is called.

If I know a component isn't going to be rendered anyway because it isn't within the hierarchy of rendered components then I can at least avoid doing expensive work that might not be needed and/or delay the page request while waiting to finish building the render tree.

egil commented 2 months ago

The condition value is cascading in a sense that if a parent component is true, then all children will inherit that render condition unless they override.

tanczosm commented 2 months ago

In example #4 then, you have something similar to this in which I took out the explict override of Condition to true for the Bar element:

<HtmxPartial Condition=false>Foo
  <HtmxPartial>Bar</HtmxPartial>  <-- Condition will be false (?)
</HtmxPartial>    
<HtmxPartial Condition=true>Baz</HtmxPartial>

As I currently understand it, the Condition for the Bar partial will be false because the render condition is false based on the parent.

Is there a way for non-HtmxPartial child components to know the render condition context they exist within as well? Would it be possible to cascade the Condition to all child components?

<HtmxPartial Condition=false>Foo
  <HtmxPartial>
      <HugeVideoPage/>  <-- HugeVideoPage without a parameter can't know it won't be rendered
  </HtmxPartial>
</HtmxPartial>    
<HtmxPartial Condition=true>Baz</HtmxPartial>
egil commented 2 months ago

Those questions are something we can leave up to developers themselves. Let me explain my current implementation idea:

Htmxor comes with an interface:

public interface IConditionalOutput
{
    /// <summary>
    /// Determine whether this component should produce any markup during a request.
    /// </summary>
    /// <param name="hasConditionalChildren">Whether this component has any children that implements <paramref name="hasConditionalChildren"/>.</param>
    /// <returns><see langword="true"/> if the component should produce markup, <see langword="false"/> otherwise.</returns>
    bool ShouldOutput(bool hasConditionalChildren);
}

When writing output to an HTTP request, the renderer walks down through the render tree, and when it gets to a component that implements IConditionalOutput, the renderer will decide based on the result from ShouldOutput() method if it's markup will be written out or not. Any child component that implements IConditionalOutput gets to decide for itself.

If components want to create a cascading value that child components (e.g. HugeVideoPage) can capture and see what its parent decided to do wrt. to producing output, that's just builtin functionality in Blazor and nothing specific to Htmxor.

Any component that chose to never create their child components (not build their child render tree) which would mean that child components never exist and don't get a say in whether or not they are rendered. Thats also just default Blazor behavior.

Does that answer your question?

It does make sense for Htmxor to come with built-in components that implement IConditionalOutput and decide whether or not to output based on e.g. the type of active request currently in flight.

For example, the default htmx "root component" could decide to NOT render any markup from itself, if it has one or more IConditionalOutput components.

That would mean that the following page component would be possible:

@page "/counter"
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<div id="counter">
    <HtmxPartial>
        <p role="status">
            Current count: @CurrentCount
        </p>
        <button class="btn btn-primary"
                hx-put="/counter"
                hx-vals='{ "CurrentCount": @(CurrentCount) }'
                hx-target="#counter"
                @onput=@(() => CurrentCount++)>
            Click me
        </button>
    </HtmxPartial>
</div>
@code {
    [SupplyParameterFromForm]
    private int CurrentCount { get; set; } = 0;
}

Here only the child content of <HtmxPartial> would be returned during a htmx request, and all the content (h1, div) would be returned during a full page request. This assumes that htmxor renders the Counter component inside a root component that does implement IConditionalOutput and that it returns false from ShouldOutput() during htmx requests.

tanczosm commented 2 months ago

That makes sense. I like that approach.

egil commented 2 months ago

Ok, the core functionality has been merged in. @tanczosm, I would love to get your take on useful components like HtmxSwappable mentioned earlier. But lets create an individual issue for those.