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(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:
Expand Down
10 changes: 9 additions & 1 deletion docs/articles/migrate-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** |

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>`.
- Custom JSON options for event details through `JsonSerializerOptions`.
See [Serialize details with custom options](responses.md#serialize-details-with-custom-options).
35 changes: 33 additions & 2 deletions docs/articles/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,15 +187,16 @@ 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 => {
console.log("Saved product:", event.detail.id);
});
```

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`
Expand Down Expand Up @@ -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> 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:
Expand Down
13 changes: 13 additions & 0 deletions docs/snippets/responses/TriggerClientEventWithOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
public sealed class ProductsController(IOptions<JsonOptions> 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);
}
}
91 changes: 78 additions & 13 deletions src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,11 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = Ht
/// <paramref name="timing" />.
/// </summary>
/// <param name="eventName">The event name to trigger.</param>
/// <param name="detail">The event detail.</param>
/// <param name="detail">
/// The event detail, serialized with the <see cref="JsonSerializerDefaults.Web" /> defaults, as ASP.NET Core
/// serializes JSON responses: property names are camel-cased, while dictionary keys and
/// <see langword="null" /> values are written as is.
/// </param>
/// <param name="timing">The event timing. Defaults to <see cref="HtmxTriggerTiming.Receive" />.</param>
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
Expand Down Expand Up @@ -300,13 +304,55 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = Ht
/// </remarks>
[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);
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);
}
/// <summary>
/// Adds a client-side event and its detail, serialized with the specified JSON options,
/// to the response header selected by <paramref name="timing" />.
/// </summary>
/// <param name="eventName">The event name to trigger.</param>
/// <param name="detail">The event detail.</param>
/// <param name="options">
/// The options used to serialize the event detail, for example, the JSON options of the application.
/// </param>
/// <param name="timing">The event timing. Defaults to <see cref="HtmxTriggerTiming.Receive" />.</param>
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
/// <remarks>
/// <para>
/// HTMX 4.x has only the <c>HX-Trigger</c> header, so events with any <see cref="HtmxTriggerTiming" /> 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 <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </para>
/// <para>
/// 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 (<c>htmx-toolkit.js</c>) can replay; without the script, none of them
/// is triggered. In HTMX 4.x, this also applies to a name added with different timings.
/// </para>
/// <para>
/// Each call sets the header in <see cref="HttpResponse.Headers" />, replacing its value with
/// the events added for it. If the header has been removed, for example by
/// <see cref="ResponseExtensions.Clear(HttpResponse)" />, 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 <see cref="InvalidOperationException" />.
/// </para>
/// <para>
/// 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 <see cref="JsonSerializerOptions.Encoder" /> of <paramref name="options" />.
/// With a relaxed encoder, such as
/// <see cref="System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping" />,
/// these names must contain only ASCII characters.
/// </para>
/// </remarks>
[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, JsonSerializerOptions options, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
TriggerEventCore(this, eventName, detail, options, timing);

/// <summary>
/// Adds a client-side event and its detail, serialized with the specified JSON metadata,
Expand Down Expand Up @@ -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<T>(HtmxResponse response, string eventName, T detail, JsonTypeInfo<T> 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<byte>();
using (var writer = CreateHeaderWriter(buffer))
JsonSerializer.Serialize(writer, detail, options);

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

private static string SerializeEventDetail<T>(T detail, JsonTypeInfo<T> 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<byte>();
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<byte> 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);
Expand Down
15 changes: 4 additions & 11 deletions src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,21 +17,15 @@ internal static class JsonOptions
JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All));

/// <summary>
/// The shared JSON serializer options that use <see cref="JsonNamingPolicy.CamelCase" />
/// for property names and dictionary keys and omit properties
/// whose values are <see langword="null" />.
/// The shared JSON serializer options with the <see cref="JsonSerializerDefaults.Web" /> defaults,
/// which ASP.NET Core uses for JSON responses: property names are camel-cased, while dictionary keys
/// and <see langword="null" /> values are written as is.
/// </summary>
/// <remarks>
/// The options use the default encoder, which escapes non-ASCII characters,
/// so that the resulting JSON can be written into a response header.
/// </remarks>
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);

/// <summary>
/// Configures serializer options to preserve Unicode characters while escaping
Expand Down
65 changes: 65 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>
{
["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()
{
Expand Down
Loading