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 0e41b36..6baa3be 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; @@ -482,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 c97e3db..d227f4d 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` @@ -259,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); + } +} diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 943b6fa..dfc6296 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. @@ -300,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, @@ -394,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.CamelCase), 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/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..5c0ab53 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -452,6 +452,71 @@ 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_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() + { + 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() {