Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ jobs:
- name: Install .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.x
dotnet-version: |
8.x
10.x

- name: Checkout
uses: actions/checkout@v7
Expand All @@ -30,8 +32,16 @@ jobs:
- name: Build
run: dotnet build -c Release

- name: Build (AOT Analyze)
run: dotnet build src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -c Release -f net8.0 --no-incremental --no-restore --warnaserror -p:EnableAotAnalysis=true

# NOTE: net8.0 is CI-only (AOT analysis above); the package ships net6.0 assets only.
# Do not "fix" this to pack all TargetFrameworks without also updating the csproj comment.
- name: Restore NuGet Package
run: dotnet restore src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -p:TargetFrameworks=net6.0

- name: Create NuGet Packages
run: dotnet pack -c Release -o ./nuget --no-build
run: dotnet pack src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -c Release -o ./nuget --no-build --no-restore -p:TargetFrameworks=net6.0

- name: NuGet login (OIDC)
id: login
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ jobs:
- name: Install .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.x
dotnet-version: |
8.x
10.x

- name: Checkout
uses: actions/checkout@v7
Expand All @@ -26,6 +28,9 @@ jobs:
- name: Build (Release)
run: dotnet build -c Release

- name: Build (AOT Analyze)
run: dotnet build src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -c Release -f net8.0 --no-incremental --no-restore --warnaserror -p:EnableAotAnalysis=true

- name: Test (Debug)
run: dotnet test -c Debug --no-build

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ HtmxToolkit is designed to minimize HTMX integration overhead in the application
In normal use, they incur no wrapper allocations while preserving a strongly typed API.
- Version-specific HTMX configuration is serialized only when the configuration changes; the resulting JSON is cached and reused across requests.
- Known JSON shapes use source-generated `System.Text.Json` metadata, avoiding reflection-based metadata discovery at runtime.
Event details passed to `TriggerEvent` are the deliberate exception because their types are defined by the application.
Applications can pass `JsonTypeInfo<T>` to `TriggerEvent` to serialize their event details without reflection.
- Work is skipped for non-HTMX requests, and overloads that accept state allow callers to use static callbacks and avoid closure allocations.

## Installation
Expand Down Expand Up @@ -204,6 +204,14 @@ Response.Htmx(
Request.Path.Value);
```

For trimming and Native AOT, pass source-generated JSON metadata for the event detail:

```csharp
Response.Htmx(
static (htmx, detail) => htmx.TriggerEvent("profile-updated", detail, AppJsonContext.Default.ProfileUpdated),
detail);
```

Call `Response.GetHtmxHeaders()` for direct access to the strongly typed response headers, or use `HtmxResponseHeaderNames` with lower-level APIs.

### Declarative Responses
Expand Down
7 changes: 7 additions & 0 deletions docs/api-overwrites/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ example:
[!code-csharp[](../snippets/responses/TriggerClientEvent.cs)]
---

---
uid: Ramstack.HtmxToolkit.HtmxResponse.TriggerEvent``1(System.String,``0,System.Text.Json.Serialization.Metadata.JsonTypeInfo{``0},Ramstack.HtmxToolkit.HtmxTriggerTiming)
example:
- |-
[!code-csharp[](../snippets/responses/TriggerClientEventAot.cs)]
---

---
uid: Ramstack.HtmxToolkit.HtmxResponseAttribute
example:
Expand Down
21 changes: 21 additions & 0 deletions docs/articles/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,27 @@ Response.Htmx(htmx => htmx.TriggerEvent(
HtmxTriggerTiming.AfterSwap));
```

The object overload uses reflection to serialize event details. For trimming and Native AOT,
pass source-generated JSON metadata instead:

```csharp
var detail = new ProductSaved(product.Id);

Response.Htmx(
static (htmx, detail) => htmx.TriggerEvent(
"product-saved",
detail,
AppJsonContext.Default.ProductSaved,
HtmxTriggerTiming.AfterSwap),
detail);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(ProductSaved))]
internal partial class AppJsonContext : JsonSerializerContext;

internal sealed record ProductSaved(int Id);
```

```html
<aside hx-get="/products/summary"
hx-trigger="product-saved from:body">
Expand Down
15 changes: 15 additions & 0 deletions docs/snippets/responses/TriggerClientEventAot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
var detail = new ProductSaved(product.Id);

Response.Htmx(
static (htmx, detail) => htmx.TriggerEvent(
"product-saved",
detail,
AppJsonContext.Default.ProductSaved,
HtmxTriggerTiming.AfterSwap),
detail);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(ProductSaved))]
internal partial class AppJsonContext : JsonSerializerContext;

internal sealed record ProductSaved(int Id);
2 changes: 1 addition & 1 deletion src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ document._r_htmx ||= ((document, htmx) => {
add_antiforgery(request.method, request.headers, request.body);
});

listen("rs:events", e => {
listen("rs:event", e => {
for (let kvp of e.detail.value || e.detail) {
htmx.trigger(e.target, kvp.key, kvp.value);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 45 additions & 13 deletions src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using System.Buffers;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;

using Microsoft.AspNetCore.Http;

Expand Down Expand Up @@ -206,7 +210,7 @@ public HtmxResponse Reselect(string value) =>
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </remarks>
public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = HtmxTriggerTiming.Receive) =>
TriggerEvent(eventName, "", trigger);
QueueEvent(this, eventName, "{}", trigger);

/// <summary>
/// Adds a client-side event and its detail to the response header selected by
Expand All @@ -223,14 +227,35 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = H
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
[RequiresDynamicCode("Event details are serialized using reflection. Use the TriggerEvent overload that accepts JsonTypeInfo<T> for Native AOT applications.")]
[RequiresUnreferencedCode("Event details are serialized using reflection. Use the TriggerEvent overload that accepts JsonTypeInfo<T> for trimmed applications.")]
public HtmxResponse TriggerEvent(string eventName, object detail, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive)
{
return TriggerEventImpl(this, eventName, detail, timing);

static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing) =>
AddEvent(response, eventName, detail, timing);
TriggerEventCore(response, eventName, detail, timing);
}

/// <summary>
/// Adds a client-side event and serializes its detail using the specified JSON metadata.
/// </summary>
/// <typeparam name="T">The event detail type.</typeparam>
/// <param name="eventName">The event name to trigger.</param>
/// <param name="detail">The event detail.</param>
/// <param name="jsonTypeInfo">The source-generated JSON metadata for the event detail.</param>
/// <param name="timing">The event timing. Defaults to <see cref="HtmxTriggerTiming.Receive" />.</param>
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
/// <remarks>
/// In HTMX 4.x, every <see cref="HtmxTriggerTiming" /> value is emitted through <c>HX-Trigger</c>
/// and runs when the request completes (after the swap whenever one is performed).
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </remarks>
public HtmxResponse TriggerEvent<T>(string eventName, T detail, JsonTypeInfo<T> jsonTypeInfo, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
TriggerEventCore(this, eventName, detail, jsonTypeInfo, timing);

/// <summary>
/// Sets a response header and returns the response wrapper for fluent chaining.
/// </summary>
Expand All @@ -246,17 +271,24 @@ private static HtmxResponse SetHeader(HtmxResponse response, string key, string
return response;
}

/// <summary>
/// Adds a pending client-side event and returns the response wrapper for fluent chaining.
/// </summary>
/// <param name="response">The response wrapper to update.</param>
/// <param name="eventName">The event name.</param>
/// <param name="detail">The event detail.</param>
/// <param name="timing">The time at which to trigger the events.</param>
/// <returns>
/// The updated response wrapper.
/// </returns>
private static HtmxResponse AddEvent(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing)
[RequiresDynamicCode("Event details are serialized using reflection.")]
[RequiresUnreferencedCode("Event details are serialized using reflection.")]
private static HtmxResponse TriggerEventCore(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing) =>
QueueEvent(response, eventName, JsonSerializer.Serialize(detail, JsonOptions.CamelCase), timing);

private static HtmxResponse TriggerEventCore<T>(HtmxResponse response, string eventName, T detail, JsonTypeInfo<T> jsonTypeInfo, HtmxTriggerTiming timing) =>
QueueEvent(response, eventName, SerializeEventDetail(detail, jsonTypeInfo), timing);

private static string SerializeEventDetail<T>(T detail, JsonTypeInfo<T> jsonTypeInfo)
{
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true }))
JsonSerializer.Serialize(writer, detail, jsonTypeInfo);

return Encoding.UTF8.GetString(buffer.WrittenSpan);
}

private static HtmxResponse QueueEvent(HtmxResponse response, string eventName, string detail, HtmxTriggerTiming timing)
{
PendingEvents.GetOrCreate(response._response).AddEvent(timing, eventName, detail);
return response;
Expand Down
41 changes: 12 additions & 29 deletions src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,56 +117,44 @@ public string Reselect
}

/// <summary>
/// Gets or sets the client-side events to trigger through the <c>HX-Trigger</c> header.
/// Gets the client-side events to trigger through the <c>HX-Trigger</c> header.
/// </summary>
/// <remarks>
/// <para>
/// Event values are accumulated for the current response and serialized
/// into the header immediately before the response starts.
/// Event values are serialized JSON fragments accumulated for the current response
/// and written into the header immediately before the response starts.
/// </para>
/// <para>
/// HTMX 1.x and 2.x trigger these events when the response is received,
/// whereas HTMX 4.x triggers them when the request completes
/// (after the swap whenever one is performed).
/// </para>
/// </remarks>
[MaybeNull]
public IReadOnlyDictionary<string, object> Trigger
{
get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.Receive);
set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.Receive, value);
}
public IReadOnlyDictionary<string, object>? Trigger => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.Receive);

/// <summary>
/// Gets or sets the client-side events to trigger through
/// the <c>HX-Trigger-After-Swap</c> header after the swap step.
/// Gets the client-side events to trigger through the <c>HX-Trigger-After-Swap</c> header after the swap step.
/// </summary>
/// <remarks>
/// <para>
/// Event values are accumulated for the current response and serialized
/// into the header immediately before the response starts.
/// Event values are serialized JSON fragments accumulated for the current response
/// and written into the header immediately before the response starts.
/// </para>
/// <para>
/// In HTMX 4.x, assigned events are accumulated in <see cref="Trigger" />
/// and emitted through <c>HX-Trigger</c> when the request completes
/// (after the swap whenever one is performed).
/// </para>
/// </remarks>
[MaybeNull]
public IReadOnlyDictionary<string, object> TriggerAfterSwap
{
get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSwap);
set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.AfterSwap, value);
}
public IReadOnlyDictionary<string, object>? TriggerAfterSwap => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSwap);

/// <summary>
/// Gets or sets the client-side events to trigger through
/// the <c>HX-Trigger-After-Settle</c> header after the settle step.
/// Gets the client-side events to trigger through the <c>HX-Trigger-After-Settle</c> header after the settle step.
/// </summary>
/// <remarks>
/// <para>
/// Event values are accumulated for the current response and serialized
/// into the header immediately before the response starts.
/// Event values are serialized JSON fragments accumulated for the current response
/// and written into the header immediately before the response starts.
/// </para>
/// <para>
/// In HTMX 4.x, assigned events are accumulated in <see cref="Trigger" />
Expand All @@ -175,12 +163,7 @@ public IReadOnlyDictionary<string, object> TriggerAfterSwap
/// cannot be preserved.
/// </para>
/// </remarks>
[MaybeNull]
public IReadOnlyDictionary<string, object> TriggerAfterSettle
{
get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSettle);
set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.AfterSettle, value);
}
public IReadOnlyDictionary<string, object>? TriggerAfterSettle => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSettle);

/// <summary>
/// Gets the value of the specified header.
Expand Down
Loading