From 00949d644122fc9b3f511733415c97d240c3cac2 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 28 Oct 2026 20:24:29 +0500 Subject: [PATCH 1/3] http: serialize event details with the ASP.NET Core web defaults The object overload of TriggerEvent still used the 1.x options: it camel-cased dictionary keys and dropped null properties. Keys are data, such as field names, so Errors["Email"] reached the browser as "email" and "Address.City" as "address.City". The JSON also differed from the JsonTypeInfo example in the docs, which says the payload stays the same. Use JsonSerializerDefaults.Web, which ASP.NET Core uses for JSON responses by default: property names are camel-cased, while dictionary keys and null values are written as is. --- docs/articles/migrate-to-v2.md | 8 ++++- docs/articles/responses.md | 5 ++-- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 8 +++-- .../Serialization/JsonOptions.cs | 15 +++------- .../HtmxResponseTests.cs | 30 +++++++++++++++++++ 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index 0e41b36..8bfe6f8 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -26,7 +26,7 @@ step; see [Version compatibility](version-compatibility.md) for that. | [Polling](#replace-stoppolling) | `StopPolling` removed | Build | | [History](#update-history-calls) | `PreventPushUrl` and `PreventReplaceUrl` become `PreventHistoryUpdate` | Build | | [HX-Location](#replace-ajaxcontext-with-htmxlocationoptions) | `AjaxContext` becomes `HtmxLocationOptions` | Build | -| [Client events](#update-client-events) | Event names are no longer camel-cased; repeated names are kept | Build, **silent** | +| [Client events](#update-client-events) | Event names and detail dictionary keys keep their case; repeated names are kept | Build, **silent** | | [Typed response headers](#update-typed-response-headers) | `HtmxResponseHeaders` is a struct; event properties are read-only | Build | | [`[HtmxResponse]`](#update-htmxresponse) | Restricted to classes and methods, one per target, inherited | Build, **silent** | @@ -420,6 +420,12 @@ See [Location or redirect](responses.md#location-or-redirect). > In 2.0 the browser receives `ItemSaved`. A listener for `itemSaved` or `hx-trigger="itemSaved from:body"` > silently stops firing. Use the same name on the server and in the client. +**Dictionary keys and `null` values in details are kept.** In 1.x, the detail object was serialized with +camel-cased dictionary keys and without `null` properties, so a detail with `Errors["Email"]` and `Note = null` +sent `{"errors":{"email":[...]}}`. In 2.0 it is serialized the way ASP.NET Core serializes JSON responses: +property names are still camel-cased, but the browser receives `{"errors":{"Email":[...]},"note":null}`. +Update listeners that read camel-cased dictionary keys. + **Repeated names are kept.** In 1.x, a second event with the same name in the same header was dropped. In 2.0, all events are sent in call order. HTMX cannot deliver a repeated name from one header by itself, so the [Toolkit script](antiforgery.md#what-the-toolkit-script-does) delivers such events; diff --git a/docs/articles/responses.md b/docs/articles/responses.md index c97e3db..3e28b86 100644 --- a/docs/articles/responses.md +++ b/docs/articles/responses.md @@ -187,7 +187,8 @@ Response.Htmx(htmx => htmx.TriggerEvent( HtmxTriggerTiming.AfterSwap)); ``` -The object is serialized with camelCase property names, so `Id` becomes `id` in `event.detail`: +The object is serialized the way ASP.NET Core serializes JSON responses by default: property names are camel-cased, +while dictionary keys and `null` values are written as is. `Id` becomes `id` in `event.detail`: ```javascript document.addEventListener("product-saved", event => { @@ -195,7 +196,7 @@ document.addEventListener("product-saved", event => { }); ``` -Only the detail uses camelCase. The event name is sent exactly as passed to `TriggerEvent`, +Only property names in the detail are camel-cased. The event name is sent exactly as passed to `TriggerEvent`, and browser event names are case-sensitive. Put the listener in a JavaScript file loaded once by the layout, after HTMX. Listening on `document` diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 943b6fa..ab9269e 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -272,7 +272,11 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = Ht /// . /// /// The event name to trigger. - /// The event detail. + /// + /// The event detail, serialized with the defaults, as ASP.NET Core + /// serializes JSON responses: property names are camel-cased, while dictionary keys and + /// values are written as is. + /// /// The event timing. Defaults to . /// /// The current instance. @@ -395,7 +399,7 @@ private static HtmxResponse SetHistoryHeader(HtmxResponse response, string key, [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) => - AddToPendingEvent(response, eventName, JsonSerializer.Serialize(detail, JsonOptions.CamelCase), timing); + AddToPendingEvent(response, eventName, JsonSerializer.Serialize(detail, JsonOptions.Web), timing); private static HtmxResponse TriggerEventCore(HtmxResponse response, string eventName, T detail, JsonTypeInfo jsonTypeInfo, HtmxTriggerTiming timing) => AddToPendingEvent(response, eventName, SerializeEventDetail(detail, jsonTypeInfo), timing); diff --git a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs index 07043bf..9bb0a3c 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs @@ -1,6 +1,5 @@ using System.Text.Encodings.Web; using System.Text.Json; -using System.Text.Json.Serialization; using System.Text.Unicode; namespace Ramstack.HtmxToolkit.Serialization; @@ -18,21 +17,15 @@ internal static class JsonOptions JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All)); /// - /// The shared JSON serializer options that use - /// for property names and dictionary keys and omit properties - /// whose values are . + /// The shared JSON serializer options with the defaults, + /// which ASP.NET Core uses for JSON responses: property names are camel-cased, while dictionary keys + /// and values are written as is. /// /// /// The options use the default encoder, which escapes non-ASCII characters, /// so that the resulting JSON can be written into a response header. /// - public static readonly JsonSerializerOptions CamelCase = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = false - }; + public static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); /// /// Configures serializer options to preserve Unicode characters while escaping diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 7453d2e..a1d719e 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -452,6 +452,36 @@ public void TriggerEvent_WithComplexDetail_SerializesJson() Assert.That(json["message"].GetProperty("text").GetString(), Is.EqualTo("hi")); } + [Test] + public void TriggerEvent_WithDictionaryDetail_KeepsKeyCase() + { + // Dictionary keys are data, such as field names, and must reach the browser unchanged. + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("invalid", new + { + Errors = new Dictionary + { + ["Email"] = ["Required"], + ["Address.City"] = ["Too long"] + } + })); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"invalid\":{\"errors\":{\"Email\":[\"Required\"],\"Address.City\":[\"Too long\"]}}}")); + } + + [Test] + public void TriggerEvent_WithNullProperty_WritesNull() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("saved", new { Id = 42, Note = (string?)null })); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"saved\":{\"id\":42,\"note\":null}}")); + } + [Test] public void TriggerEvent_WithIndentedJsonTypeInfo_SerializesImmediatelyAsCompactJson() { From a16fcf603bdea065854228b809229904994313b9 Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 28 Oct 2026 21:09:01 +0500 Subject: [PATCH 2/3] http: accept JsonSerializerOptions for event details Add TriggerEvent(string, object, JsonSerializerOptions, timing). All overloads now write the detail through the same writer, so the header stays compact and escapes non-ASCII characters in string values and dictionary keys whatever the options say. --- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 85 ++++++++++++++++--- .../HtmxResponseTests.cs | 35 ++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index ab9269e..dfc6296 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -304,13 +304,55 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = Ht /// [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, object detail, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) - { - return TriggerEventImpl(this, eventName, detail, timing); + public HtmxResponse TriggerEvent(string eventName, object detail, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => + TriggerEvent(eventName, detail, JsonOptions.Web, timing); - static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing) => - TriggerEventCore(response, eventName, detail, timing); - } + /// + /// Adds a client-side event and its detail, serialized with the specified JSON options, + /// to the response header selected by . + /// + /// The event name to trigger. + /// The event detail. + /// + /// The options used to serialize the event detail, for example, the JSON options of the application. + /// + /// The event timing. Defaults to . + /// + /// The current instance. + /// + /// + /// + /// HTMX 4.x has only the HX-Trigger header, so events with any value + /// are emitted through it. These events fire when the request completes, which is after the swap + /// and settle steps whenever a swap is performed. + /// See PR #3900. + /// + /// + /// Events in the same header are triggered in the order they were added. If the same event name + /// is added more than once for a header, all events in that header are sent in a form that only + /// the Toolkit script (htmx-toolkit.js) can replay; without the script, none of them + /// is triggered. In HTMX 4.x, this also applies to a name added with different timings. + /// + /// + /// Each call sets the header in , replacing its value with + /// the events added for it. If the header has been removed, for example by + /// , the events added before that are discarded. + /// Like any other response header, an event cannot be added after the response has started; + /// the method then throws . + /// + /// + /// The detail is always written as compact JSON. Response headers allow only ASCII characters: + /// other characters in string values and dictionary keys are escaped, but property names and enum names + /// are written as escaped by the of . + /// With a relaxed encoder, such as + /// , + /// these names must contain only ASCII characters. + /// + /// + [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, object detail, JsonSerializerOptions options, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => + TriggerEventCore(this, eventName, detail, options, timing); /// /// Adds a client-side event and its detail, serialized with the specified JSON metadata, @@ -398,26 +440,45 @@ private static HtmxResponse SetHistoryHeader(HtmxResponse response, string key, [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) => - AddToPendingEvent(response, eventName, JsonSerializer.Serialize(detail, JsonOptions.Web), timing); + private static HtmxResponse TriggerEventCore(HtmxResponse response, string eventName, object detail, JsonSerializerOptions options, HtmxTriggerTiming timing) => + AddToPendingEvent(response, eventName, SerializeEventDetail(detail, options), timing); private static HtmxResponse TriggerEventCore(HtmxResponse response, string eventName, T detail, JsonTypeInfo jsonTypeInfo, HtmxTriggerTiming timing) => AddToPendingEvent(response, eventName, SerializeEventDetail(detail, jsonTypeInfo), timing); + [RequiresDynamicCode("Event details are serialized using reflection.")] + [RequiresUnreferencedCode("Event details are serialized using reflection.")] + private static string SerializeEventDetail(object detail, JsonSerializerOptions options) + { + Argument.ThrowIfNull(options); + + var buffer = new ArrayBufferWriter(); + using (var writer = CreateHeaderWriter(buffer)) + JsonSerializer.Serialize(writer, detail, options); + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + private static string SerializeEventDetail(T detail, JsonTypeInfo jsonTypeInfo) { Argument.ThrowIfNull(jsonTypeInfo); - // The writer's default encoder escapes non-ASCII characters, which response headers do not allow, - // in string values and dictionary keys even if the jsonTypeInfo options use a relaxed encoder. - // Property names and enum names are written as escaped by the encoder of those options. var buffer = new ArrayBufferWriter(); - using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true })) + using (var writer = CreateHeaderWriter(buffer)) JsonSerializer.Serialize(writer, detail, jsonTypeInfo); return Encoding.UTF8.GetString(buffer.WrittenSpan); } + private static Utf8JsonWriter CreateHeaderWriter(ArrayBufferWriter buffer) + { + // The writer's settings take precedence over the serializer options, so the JSON is compact, + // and the default encoder escapes non-ASCII characters, which response headers do not allow, + // in string values and dictionary keys even if the options use a relaxed encoder. + // Property names and enum names are written as escaped by the encoder of those options. + return new Utf8JsonWriter(buffer, new JsonWriterOptions { SkipValidation = true }); + } + private static HtmxResponse AddToPendingEvent(HtmxResponse response, string eventName, string detailJson, HtmxTriggerTiming timing) { PendingEvents.GetOrCreate(response._response).AddEvent(timing, eventName, detailJson); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index a1d719e..5c0ab53 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -471,6 +471,41 @@ public void TriggerEvent_WithDictionaryDetail_KeepsKeyCase() Is.EqualTo("{\"invalid\":{\"errors\":{\"Email\":[\"Required\"],\"Address.City\":[\"Too long\"]}}}")); } + [Test] + public void TriggerEvent_WithOptions_SerializesWithOptions() + { + var context = TestHelper.CreateHtmxRequestContext(); + var options = new JsonSerializerOptions + { + Converters = { new JsonStringEnumConverter() } + }; + + context.Response.Htmx(r => r.TriggerEvent("scheduled", new { Day = DayOfWeek.Monday }, options)); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"scheduled\":{\"Day\":\"Monday\"}}")); + } + + [Test] + public void TriggerEvent_WithIndentedRelaxedEscapingOptions_WritesCompactAsciiJson() + { + // Minimal API JsonOptions use UnsafeRelaxedJsonEscaping, and response headers + // cannot contain line breaks or non-ASCII characters. + var context = TestHelper.CreateHtmxRequestContext(); + var options = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true + }; + + context.Response.Htmx(r => r.TriggerEvent("saved", new { Name = "Привет" }, options)); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"saved\":{\"Name\":\"\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\"}}")); + } + [Test] public void TriggerEvent_WithNullProperty_WritesNull() { From 6640cd258c463916f7a8de0c5fea21f0a0bd9b1c Mon Sep 17 00:00:00 2001 From: rameel Date: Wed, 28 Oct 2026 21:17:29 +0500 Subject: [PATCH 3/3] http: document custom JSON options for event details --- docs/api-overwrites/responses.md | 7 +++++ docs/articles/migrate-to-v2.md | 2 ++ docs/articles/responses.md | 30 +++++++++++++++++++ .../TriggerClientEventWithOptions.cs | 13 ++++++++ 4 files changed, 52 insertions(+) create mode 100644 docs/snippets/responses/TriggerClientEventWithOptions.cs diff --git a/docs/api-overwrites/responses.md b/docs/api-overwrites/responses.md index 1c92285..5780f4e 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(System.String,System.Object,System.Text.Json.JsonSerializerOptions,Ramstack.HtmxToolkit.HtmxTriggerTiming) +example: + - |- + [!code-csharp[](../snippets/responses/TriggerClientEventWithOptions.cs)] +--- + --- uid: Ramstack.HtmxToolkit.HtmxResponse.TriggerEvent``1(System.String,``0,System.Text.Json.Serialization.Metadata.JsonTypeInfo{``0},Ramstack.HtmxToolkit.HtmxTriggerTiming) example: diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index 8bfe6f8..6baa3be 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -488,3 +488,5 @@ After the migration, consider these additions: See [Check full or partial requests](requests.md#check-full-or-partial-requests). - Morph swaps for HTMX 1.9.x and 2.x through the `ramstack-swap` extension. See [Morph swaps](version-compatibility.md#morph-swaps). - Native AOT support for event details through `JsonTypeInfo`. +- Custom JSON options for event details through `JsonSerializerOptions`. + See [Serialize details with custom options](responses.md#serialize-details-with-custom-options). diff --git a/docs/articles/responses.md b/docs/articles/responses.md index 3e28b86..d227f4d 100644 --- a/docs/articles/responses.md +++ b/docs/articles/responses.md @@ -260,6 +260,36 @@ If you take the metadata from options with a relaxed encoder, such as Minimal AP which use `JavaScriptEncoder.UnsafeRelaxedJsonEscaping`, keep these names ASCII-only; 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. +To serialize a detail with other settings, for example with the application's options that write enums as strings, +pass `JsonSerializerOptions`: + +```csharp +public sealed class ProductsController(IOptions jsonOptions) : Controller +{ + [HttpPost] + public IActionResult Save(Product product) + { + Response.Htmx(htmx => htmx.TriggerEvent( + "product-saved", + new { product.Id, product.Status }, + jsonOptions.Value.JsonSerializerOptions)); + + return PartialView("_ProductRow", product); + } +} +``` + +`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; +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 +escaped as described above. Minimal API `JsonOptions` use a relaxed encoder, so with them, keep property names +and enum names ASCII-only. + ## Configure declaratively in MVC `HtmxResponseAttribute` is convenient when response behavior is constant: diff --git a/docs/snippets/responses/TriggerClientEventWithOptions.cs b/docs/snippets/responses/TriggerClientEventWithOptions.cs new file mode 100644 index 0000000..6866ff7 --- /dev/null +++ b/docs/snippets/responses/TriggerClientEventWithOptions.cs @@ -0,0 +1,13 @@ +public sealed class ProductsController(IOptions jsonOptions) : Controller +{ + [HttpPost] + public IActionResult Save(Product product) + { + Response.Htmx(htmx => htmx.TriggerEvent( + "product-saved", + new { product.Id, product.Status }, + jsonOptions.Value.JsonSerializerOptions)); + + return PartialView("_ProductRow", product); + } +}