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
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,Ramstack.HtmxToolkit.HtmxTriggerTiming)
example:
- |-
[!code-csharp[](../snippets/responses/TriggerClientEvent.cs)]
---

---
uid: Ramstack.HtmxToolkit.HtmxResponse.TriggerEvent(System.String,System.Object,System.Text.Json.JsonSerializerOptions,Ramstack.HtmxToolkit.HtmxTriggerTiming)
example:
Expand Down
6 changes: 4 additions & 2 deletions docs/articles/migrate-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,10 @@ Other changes:
- The timing parameter of `TriggerEvent(string, HtmxTriggerTiming)` was renamed from `trigger` to `timing`.
- An event without a detail now receives an empty object; in 1.x, `event.detail.value` was an empty string.
- `TriggerEvent` throws `ArgumentException` for an empty or whitespace event name.
- `TriggerEvent(string, object, HtmxTriggerTiming)` is annotated as incompatible with trimming and Native AOT.
Trimmed and Native AOT projects get warnings IL2026 and IL3050 for it; use the overload that accepts
- A constant zero is now sent as the detail. In 1.x, `TriggerEvent("count", 0)` selected the overload with only a timing,
because zero converts to any enum, and the detail was lost.
- `TriggerEvent` overloads that serialize a detail through reflection are annotated as incompatible with trimming and Native AOT.
Trimmed and Native AOT projects get warnings IL2026 and IL3050 for them; use the overload that accepts
`JsonTypeInfo<T>`. See [Serialize details with source-generated metadata](responses.md#serialize-details-with-source-generated-metadata).
- `HtmxTriggerTiming` members are ordered as HTMX processes them: `Receive`, `AfterSwap`, `AfterSettle`.
The numeric values of `AfterSwap` and `AfterSettle` swapped; this matters only if the values are stored or cast.
Expand Down
6 changes: 3 additions & 3 deletions docs/articles/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ added with different timings.

### Serialize details with source-generated metadata

The object overload discovers the shape of each detail type through reflection the first time it serializes that type.
Without metadata, `TriggerEvent` discovers the shape of each detail type through reflection the first time it serializes that type.
Pass source-generated JSON metadata to do this work at build time instead: the first event of each type is serialized
without the reflection delay, the payload can be written by generated code, and the app needs less memory.
Because no reflection is involved, this overload also supports trimming and Native AOT.
Expand Down Expand Up @@ -277,7 +277,7 @@ otherwise, the server rejects the header.

### Serialize details with custom options

The object overload always uses the defaults described above, not the JSON options configured for the application.
Without options, `TriggerEvent` always uses the defaults described above, not the JSON options configured for the application.
To serialize a detail with other settings, for example with the application's options that write enums as strings,
pass `JsonSerializerOptions`:

Expand All @@ -298,7 +298,7 @@ public sealed class ProductsController(IOptions<JsonOptions> jsonOptions) : Cont
```

`JsonOptions` here is `Microsoft.AspNetCore.Mvc.JsonOptions`. In Minimal APIs, use `SerializerOptions`
of `Microsoft.AspNetCore.Http.Json.JsonOptions`. Like the object overload, this overload uses reflection;
of `Microsoft.AspNetCore.Http.Json.JsonOptions`. This overload also uses reflection;
for trimming and Native AOT, use [source-generated metadata](#serialize-details-with-source-generated-metadata).

The detail is always written as compact JSON, with non-ASCII characters in string values and dictionary keys
Expand Down
9 changes: 9 additions & 0 deletions src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,15 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = Ht
public HtmxResponse TriggerEvent(string eventName, object detail, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
TriggerEvent(eventName, detail, JsonOptions.Web, timing);

/// <inheritdoc cref="TriggerEvent(string, object, HtmxTriggerTiming)" />
/// <typeparam name="T">The event detail type.</typeparam>
[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<T>(string eventName, T detail, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
// This overload keeps a constant zero from binding to the overload without a detail, because zero converts to any enum.
// The detail is passed as object so that it is serialized by its runtime type rather than by T.
TriggerEvent(eventName, (object)detail!, timing);

/// <summary>
/// Adds a client-side event and its detail, serialized with the specified JSON options,
/// to the response header selected by <paramref name="timing" />.
Expand Down
51 changes: 51 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,47 @@ public void TriggerEvent_EmptyStringDetail_SerializesAsEmptyString()
Is.EqualTo("{\"e\":\"\"}"));
}

[Test]
public void TriggerEvent_ConstantZeroDetail_IsNotTreatedAsTiming()
{
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r
.TriggerEvent("int", 0)
.TriggerEvent("long", 0L)
.TriggerEvent("double", 0.0)
.TriggerEvent("decimal", 0m));

Assert.That(
context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(),
Is.EqualTo("{\"int\":0,\"long\":0,\"double\":0,\"decimal\":0}"));
}

[Test]
public void TriggerEvent_DetailWithTiming_SetsSelectedTrigger()
{
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.TriggerEvent("e", 0, HtmxTriggerTiming.AfterSwap));

Assert.That(
context.Response.Headers[HtmxResponseHeaderNames.TriggerAfterSwap].ToString(),
Is.EqualTo("{\"e\":0}"));
}

[Test]
public void TriggerEvent_DetailOfBaseType_SerializesRuntimeType()
{
TriggerEventBaseDetail detail = new TriggerEventDerivedDetail { Id = 1, Text = "a" };

var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.TriggerEvent("e", detail));

var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString();
var json = JsonHelper.ParseJson(header);

Assert.That(json["e"].GetProperty("id").GetInt32(), Is.EqualTo(1));
Assert.That(json["e"].GetProperty("text").GetString(), Is.EqualTo("a"));
}

[Test]
public void TriggerEvent_AfterResponseStarted_ThrowsInvalidOperationException()
{
Expand Down Expand Up @@ -694,3 +735,13 @@ internal sealed class TriggerEventDetail
{
public string Text { get; set; } = "";
}

internal class TriggerEventBaseDetail
{
public string Text { get; set; } = "";
}

internal sealed class TriggerEventDerivedDetail : TriggerEventBaseDetail
{
public int Id { get; set; }
}
Loading