diff --git a/docs/api-overwrites/responses.md b/docs/api-overwrites/responses.md index 5780f4e..6bb77e7 100644 --- a/docs/api-overwrites/responses.md +++ b/docs/api-overwrites/responses.md @@ -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: diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index 2d679ce..cdcb977 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -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`. 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. diff --git a/docs/articles/responses.md b/docs/articles/responses.md index c5544ec..8a4339c 100644 --- a/docs/articles/responses.md +++ b/docs/articles/responses.md @@ -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. @@ -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`: @@ -298,7 +298,7 @@ public sealed class ProductsController(IOptions 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 diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 91c547b..3c8bb97 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -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); + /// + /// The event detail type. + [RequiresDynamicCode("Event details are serialized using reflection. Use the TriggerEvent overload that accepts JsonTypeInfo for Native AOT applications.")] + [RequiresUnreferencedCode("Event details are serialized using reflection. Use the TriggerEvent overload that accepts JsonTypeInfo for trimmed applications.")] + public HtmxResponse TriggerEvent(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); + /// /// Adds a client-side event and its detail, serialized with the specified JSON options, /// to the response header selected by . diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 5c0ab53..55c45c2 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -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() { @@ -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; } +}