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

feat: Add trigger specification cache to Htmxor #44

Closed tanczosm closed 2 months ago

tanczosm commented 2 months ago

This update introduces a trigger specification cache to the Htmxor library. The new feature improves parsing performance by storing evaluated trigger specifications, albeit at the cost of increased memory usage on client side (htmx's words)

I haven't added all comments or tests yet.

The changes include:

The original rationale behind adding a trigger cache to htmx was because if you have a large number of rows with a common trigger there is a sizeable performance impact when htmx has to parse each of the trigger definitions. Htmx added a key/value store to quickly look up the parsed trigger definition from the raw trigger value.

The end result looks like this within the configuration. Note that because triggers can be complex it gets a bit tricky for developers to get the cached definitions parsed correctly by hand:

{
  "useTemplateFragments": true,
  "selfRequestsOnly": true,
  "triggerSpecsCache": {
    "revealed": [
      {
        "trigger": "revealed"
      }
    ],
    "newContact from:body": [
      {
        "trigger": "newContact",
        "from": "body"
      }
    ],
    "keyup changed delay:500ms, mouseenter once": [
      {
        "trigger": "keyup",
        "changed": true,
        "delay": 500
      },
      {
        "trigger": "mouseenter",
        "once": true
      }
    ],
    "every 30s, click": [
      {
        "trigger": "every",
        "pollInterval": 30000
      },
      {
        "trigger": "click"
      }
    ]
  }
}

This is generated by this code:

builder.Services.AddRazorComponents().AddHtmx(options =>
{
    // Enabled to support out of band updates
    options.UseTemplateFragments = true;

    // Enabled to show use of trigger specification cache
    options.TriggerSpecsCache = new TriggerSpecificationCache (
        Trigger.Revealed(), // Used in InfiniteScroll demo
        Trigger.OnEvent("newContact").From("body"), // Used in TriggeringEvents demo
        Trigger.OnEvent("keyup").Changed().Delay(TimeSpan.FromMilliseconds(500))
            .Or()
            .OnEvent("mouseenter").Once(),  //  Unused, demonstrates complex trigger
        Trigger.Every(TimeSpan.FromSeconds(30)) // Unused, demonstrates use of Every
            .Or()
            .OnEvent("click")
    );
});

The trigger builder builds the complete trigger value as well as the specification used for caching. It can be added easily to the TriggerSpecificationCache as a comma-separated list of fluent trigger definitions.

Second, the builder can be used directly in code to define triggers. This is important because it makes sure the exact trigger definition is used in both the cache definition as well as in the markup:

        <HtmxFragment Match=@(req => req.Target == "contacts-table")>
            <tbody id="contacts-table" hx-get hx-trigger=@Trigger.OnEvent("newContact").From("body") hx-swap=@SwapStyles.OuterHTML>
                @foreach (var contact in Contacts.Data.Values.OrderBy(x => x.Modified).Take(20))
                {
                    <tr>
                        <td>@contact.FirstName</td>
                        <td>@contact.LastName</td>
                        <td>@contact.Email</td>
                    </tr>
                }
            </tbody>
        </HtmxFragment>
egil commented 2 months ago

This looks promising. A few quick notes:

tanczosm commented 2 months ago

This looks promising. A few quick notes:

  • do we need both interfaces and public types?
  • if all cached triggers can be shared on the server, not just in the client, it would save allocations on each request.

The trigger builder consists of the base trigger builder and another class to build the modifier chains so the next fluent option is valid for the step you are on. Because you can end the trigger when in the TriggerBuilder or TriggerModifierBuilder I added ITriggerBuilder to define the build method so either could be used to add cache options.

One of the TriggerSpecificationCache constructors takes a params list of ITriggerBuilder objects.

    public TriggerSpecificationCache(params ITriggerBuilder[] builders)
    {
        builders ??= Array.Empty<ITriggerBuilder>();

        foreach (var triggerBuilder in builders)
        {
            var trigger = triggerBuilder.Build();
            Add(trigger.Key, trigger.Value);
        }
    }

That allows for:

new TriggerSpecificationCache (
        Trigger.Revealed(), // <-- this is a TriggerBuilder
        Trigger.OnEvent("newContact").From("body") // this is a TriggerModifierBuilder
    );

The base TriggerBuilder holds the whole trigger definition list so TriggerModifierBuilder's build method calls the TriggerBuilder build() method.

if all cached triggers can be shared on the server, not just in the client, it would save allocations on each request.

I'm not sure what you are asking. You can create a proxy object to perhaps download the cache list from the server. We'd have to set up an endpoint to output the cache and write some code to configure the setting. I'd put this in the future consideration list of features for now to fully implement

tanczosm commented 2 months ago

Let me know if you need any additional changes.

egil commented 2 months ago

Let me know if you need any additional changes.

Taking a look tonight and will push some changes.

egil commented 2 months ago

I'm not sure what you are asking. You can create a proxy object to perhaps download the cache list from the server. We'd have to set up an endpoint to output the cache and write some code to configure the setting. I'd put this in the future consideration list of features for now to fully implement

I was thinking of a micro-optimization, where we avoid allocating new builder instances when its not needed. Some of the options combinations are static. We can look at this later in the project life cycle.