From 0d2b00236f2107684620904dbbc843776eff3d0a Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 8 Sep 2026 16:05:42 +0500 Subject: [PATCH 1/5] http: support AOT-safe trigger event serialization --- README.md | 10 +- .../Assets/htmx-toolkit.js | 2 +- .../Assets/htmx-toolkit.min.js | 2 +- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 58 +++++++--- .../HtmxResponseHeaders.cs | 41 ++----- src/Ramstack.HtmxToolkit/PendingEvents.cs | 84 ++++++++------ .../Properties/CollectionBuilderAttribute.cs | 3 +- .../RequiresDynamicCodeAttribute.cs | 32 +++++ .../Serialization/JsonOptions.cs | 8 +- .../HtmxResponseHeadersTests.cs | 19 +-- .../HtmxResponseTests.cs | 52 ++++++++- .../PendingEventsTests.cs | 109 ++++++++++-------- 12 files changed, 276 insertions(+), 144 deletions(-) create mode 100644 src/Ramstack.HtmxToolkit/Properties/RequiresDynamicCodeAttribute.cs diff --git a/README.md b/README.md index fd12ac5..6688cb4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ HtmxToolkit is designed to minimize HTMX integration overhead in the application In normal use, they incur no wrapper allocations while preserving a strongly typed API. - Version-specific HTMX configuration is serialized only when the configuration changes; the resulting JSON is cached and reused across requests. - Known JSON shapes use source-generated `System.Text.Json` metadata, avoiding reflection-based metadata discovery at runtime. - Event details passed to `TriggerEvent` are the deliberate exception because their types are defined by the application. + Applications can pass `JsonTypeInfo` to `TriggerEvent` to serialize their event details without reflection. - Work is skipped for non-HTMX requests, and overloads that accept state allow callers to use static callbacks and avoid closure allocations. ## Installation @@ -204,6 +204,14 @@ Response.Htmx( Request.Path.Value); ``` +For trimming and Native AOT, pass source-generated JSON metadata for the event detail: + +```csharp +Response.Htmx( + static (htmx, detail) => htmx.TriggerEvent("profile-updated", detail, AppJsonContext.Default.ProfileUpdated), + detail); +``` + Call `Response.GetHtmxHeaders()` for direct access to the strongly typed response headers, or use `HtmxResponseHeaderNames` with lower-level APIs. ### Declarative Responses diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js index 9225690..767245a 100644 --- a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js +++ b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.js @@ -122,7 +122,7 @@ document._r_htmx ||= ((document, htmx) => { add_antiforgery(request.method, request.headers, request.body); }); - listen("rs:events", e => { + listen("rs:event", e => { for (let kvp of e.detail.value || e.detail) { htmx.trigger(e.target, kvp.key, kvp.value); } diff --git a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js index a37d755..3125b6b 100644 --- a/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js +++ b/src/Ramstack.HtmxToolkit/Assets/htmx-toolkit.min.js @@ -1 +1 @@ -document._r_htmx||=((e,t)=>{const r=e=>console.warn(`ramstack.htmxtoolkit: ${e}`);t.defineExtension&&t.defineExtension("ramstack-morph",{isInlineSwap:e=>"outerMorph"===e||"outerSync"===e,handleSwap(e,t,n){if("textContent"===e)return t.textContent=n.textContent,[t];let o="innerMorph"===e?"innerHTML":"outerMorph"===e?"outerHTML":null;if(o){let a=globalThis.Idiomorph;if(a?.morph)return a.morph(t,n.children,{morphStyle:o});r(`Idiomorph is unavailable; falling back from ${e} to ${o}`);let i=[...n.childNodes];return"innerHTML"===o?t.replaceChildren(...i):t.replaceWith(...i),i}if("outerSync"===e){r("outerSync requires HTMX 4.x; falling back to attribute sync and innerHTML");let e=n.firstElementChild;if(e){for(let r of[...t.attributes])e.hasAttribute(r.name)||t.removeAttribute(r.name);for(let r of e.attributes)t.setAttribute(r.name,r.value);let r=e.childNodes;return t.replaceChildren(...r),r}}}});const n=(t,r)=>{e.addEventListener(t,r)},o=e=>{let t=e.querySelector("meta[name='htmx-config']")?.dataset||{};return{headerName:t.antiforgeryHeaderName,formFieldName:t.antiforgeryFormFieldName,requestToken:t.antiforgeryRequestToken}};let a=o(e);const i=(e,t,r)=>{if(!/^get$/i.test(e)){const{headerName:e,formFieldName:n,requestToken:o}=a;o&&(r.has?.(n)||r[n]||(e?t[e]=o:r.set?r.set(n,o):r[n]=o))}},l=e=>{let t=(new DOMParser).parseFromString(e||"","text/html"),r=o(t);r&&(a=r)};return n("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&l(t.xhr.responseText)}),n("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&l(t.text)}),n("htmx:configRequest",e=>{let t=e.detail;i(t.verb,t.headers,t.parameters)}),n("htmx:config:request",e=>{let t=e.detail.ctx.request;i(t.method,t.headers,t.body)}),n("rs:events",e=>{for(let r of e.detail.value||e.detail)t.trigger(e.target,r.key,r.value)}),!0})(document,htmx); \ No newline at end of file +document._r_htmx||=((e,t)=>{const r=e=>console.warn(`ramstack.htmxtoolkit: ${e}`);t.defineExtension&&t.defineExtension("ramstack-morph",{isInlineSwap:e=>"outerMorph"===e||"outerSync"===e,handleSwap(e,t,n){if("textContent"===e)return t.textContent=n.textContent,[t];let o="innerMorph"===e?"innerHTML":"outerMorph"===e?"outerHTML":null;if(o){let a=globalThis.Idiomorph;if(a?.morph)return a.morph(t,n.children,{morphStyle:o});r(`Idiomorph is unavailable; falling back from ${e} to ${o}`);let i=[...n.childNodes];return"innerHTML"===o?t.replaceChildren(...i):t.replaceWith(...i),i}if("outerSync"===e){r("outerSync requires HTMX 4.x; falling back to attribute sync and innerHTML");let e=n.firstElementChild;if(e){for(let r of[...t.attributes])e.hasAttribute(r.name)||t.removeAttribute(r.name);for(let r of e.attributes)t.setAttribute(r.name,r.value);let r=e.childNodes;return t.replaceChildren(...r),r}}}});const n=(t,r)=>{e.addEventListener(t,r)},o=e=>{let t=e.querySelector("meta[name='htmx-config']")?.dataset||{};return{headerName:t.antiforgeryHeaderName,formFieldName:t.antiforgeryFormFieldName,requestToken:t.antiforgeryRequestToken}};let a=o(e);const i=(e,t,r)=>{if(!/^get$/i.test(e)){const{headerName:e,formFieldName:n,requestToken:o}=a;o&&(r.has?.(n)||r[n]||(e?t[e]=o:r.set?r.set(n,o):r[n]=o))}},l=e=>{let t=(new DOMParser).parseFromString(e||"","text/html"),r=o(t);r&&(a=r)};return n("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&l(t.xhr.responseText)}),n("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&l(t.text)}),n("htmx:configRequest",e=>{let t=e.detail;i(t.verb,t.headers,t.parameters)}),n("htmx:config:request",e=>{let t=e.detail.ctx.request;i(t.method,t.headers,t.body)}),n("rs:event",e=>{for(let r of e.detail.value||e.detail)t.trigger(e.target,r.key,r.value)}),!0})(document,htmx); \ No newline at end of file diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 657ecaf..e6740b6 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -1,5 +1,9 @@ +using System.Buffers; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.AspNetCore.Http; @@ -206,7 +210,7 @@ public HtmxResponse Reselect(string value) => /// See PR #3900. /// public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = HtmxTriggerTiming.Receive) => - TriggerEvent(eventName, "", trigger); + QueueEvent(this, eventName, "{}", trigger); /// /// Adds a client-side event and its detail to the response header selected by @@ -223,14 +227,35 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = H /// /// The current instance. /// + [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); static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing) => - AddEvent(response, eventName, detail, timing); + TriggerEventCore(response, eventName, detail, timing); } + /// + /// Adds a client-side event and serializes its detail using the specified JSON metadata. + /// + /// The event detail type. + /// The event name to trigger. + /// The event detail. + /// The source-generated JSON metadata for the event detail. + /// The event timing. Defaults to . + /// + /// The current instance. + /// + /// + /// In HTMX 4.x, every value is emitted through HX-Trigger + /// and runs when the request completes (after the swap whenever one is performed). + /// See PR #3900. + /// + public HtmxResponse TriggerEvent(string eventName, T detail, JsonTypeInfo jsonTypeInfo, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => + TriggerEventCore(this, eventName, detail, jsonTypeInfo, timing); + /// /// Sets a response header and returns the response wrapper for fluent chaining. /// @@ -246,17 +271,24 @@ private static HtmxResponse SetHeader(HtmxResponse response, string key, string return response; } - /// - /// Adds a pending client-side event and returns the response wrapper for fluent chaining. - /// - /// The response wrapper to update. - /// The event name. - /// The event detail. - /// The time at which to trigger the events. - /// - /// The updated response wrapper. - /// - private static HtmxResponse AddEvent(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing) + [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) => + QueueEvent(response, eventName, JsonSerializer.Serialize(detail, JsonOptions.CamelCase), timing); + + private static HtmxResponse TriggerEventCore(HtmxResponse response, string eventName, T detail, JsonTypeInfo jsonTypeInfo, HtmxTriggerTiming timing) => + QueueEvent(response, eventName, SerializeEventDetail(detail, jsonTypeInfo), timing); + + private static string SerializeEventDetail(T detail, JsonTypeInfo jsonTypeInfo) + { + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true })) + JsonSerializer.Serialize(writer, detail, jsonTypeInfo); + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static HtmxResponse QueueEvent(HtmxResponse response, string eventName, string detail, HtmxTriggerTiming timing) { PendingEvents.GetOrCreate(response._response).AddEvent(timing, eventName, detail); return response; diff --git a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs index 07baeb0..0dcbf77 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs @@ -117,12 +117,12 @@ public string Reselect } /// - /// Gets or sets the client-side events to trigger through the HX-Trigger header. + /// Gets the client-side events to trigger through the HX-Trigger header. /// /// /// - /// Event values are accumulated for the current response and serialized - /// into the header immediately before the response starts. + /// Event values are serialized JSON fragments accumulated for the current response + /// and written into the header immediately before the response starts. /// /// /// HTMX 1.x and 2.x trigger these events when the response is received, @@ -130,21 +130,15 @@ public string Reselect /// (after the swap whenever one is performed). /// /// - [MaybeNull] - public IReadOnlyDictionary Trigger - { - get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.Receive); - set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.Receive, value); - } + public IReadOnlyDictionary? Trigger => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.Receive); /// - /// Gets or sets the client-side events to trigger through - /// the HX-Trigger-After-Swap header after the swap step. + /// Gets the client-side events to trigger through the HX-Trigger-After-Swap header after the swap step. /// /// /// - /// Event values are accumulated for the current response and serialized - /// into the header immediately before the response starts. + /// Event values are serialized JSON fragments accumulated for the current response + /// and written into the header immediately before the response starts. /// /// /// In HTMX 4.x, assigned events are accumulated in @@ -152,21 +146,15 @@ public IReadOnlyDictionary Trigger /// (after the swap whenever one is performed). /// /// - [MaybeNull] - public IReadOnlyDictionary TriggerAfterSwap - { - get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSwap); - set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.AfterSwap, value); - } + public IReadOnlyDictionary? TriggerAfterSwap => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSwap); /// - /// Gets or sets the client-side events to trigger through - /// the HX-Trigger-After-Settle header after the settle step. + /// Gets the client-side events to trigger through the HX-Trigger-After-Settle header after the settle step. /// /// /// - /// Event values are accumulated for the current response and serialized - /// into the header immediately before the response starts. + /// Event values are serialized JSON fragments accumulated for the current response + /// and written into the header immediately before the response starts. /// /// /// In HTMX 4.x, assigned events are accumulated in @@ -175,12 +163,7 @@ public IReadOnlyDictionary TriggerAfterSwap /// cannot be preserved. /// /// - [MaybeNull] - public IReadOnlyDictionary TriggerAfterSettle - { - get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSettle); - set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.AfterSettle, value); - } + public IReadOnlyDictionary? TriggerAfterSettle => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSettle); /// /// Gets the value of the specified header. diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs index 68a8bb5..ab7778d 100644 --- a/src/Ramstack.HtmxToolkit/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Http; @@ -15,10 +17,11 @@ namespace Ramstack.HtmxToolkit; /// internal sealed class PendingEvents { - private const string ProxyEventName = "rs:events"; + private const string ProxyEventName = "rs:event"; private readonly HttpResponse _response; private readonly HtmxTargetVersion _version; + private readonly ArrayBufferWriter _buffer = new(); private SmallDictionary? _receive; private SmallDictionary? _afterSwap; @@ -34,13 +37,16 @@ private PendingEvents(HttpResponse response) => /// /// Adds the specified event to the pending set for . /// When an event name already exists, the duplicate is stored under the - /// rs:events key for client-side replay. + /// rs:event key for client-side replay. /// /// The time at which to trigger the events. /// The event name. /// The event detail. - public void AddEvent(HtmxTriggerTiming timing, string eventName, object detail) + public void AddEvent(HtmxTriggerTiming timing, string eventName, string detail) { + if (eventName == ProxyEventName) + throw new ArgumentException($"The event name '{ProxyEventName}' is reserved.", nameof(eventName)); + timing = NormalizeTiming(timing); var current = timing switch @@ -52,13 +58,14 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, object detail) if (!current.TryAdd(eventName, detail)) { - if (!current.TryGetValue(ProxyEventName, out var value) || value is not List> collection) + if (!current.TryGetValue(ProxyEventName, out var value) + || value is not List> collection) { collection = []; current[ProxyEventName] = collection; } - collection.Add(new KeyValuePair(eventName, detail)); + collection.Add(new KeyValuePair(eventName, detail)); } } @@ -81,30 +88,6 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, object detail) }; } - /// - /// Replaces the pending events for the specified . - /// - /// The time at which to trigger the events. - /// The replacement event names and their associated details. - public void SetEvents(HtmxTriggerTiming timing, IReadOnlyDictionary events) - { - timing = NormalizeTiming(timing); - - var replacement = new SmallDictionary(events, StringComparer.Ordinal); - switch (timing) - { - case HtmxTriggerTiming.Receive: - _receive = replacement; - break; - case HtmxTriggerTiming.AfterSwap: - _afterSwap = replacement; - break; - default: - _afterSettle = replacement; - break; - } - } - /// /// Serializes the accumulated events, if any, into the corresponding /// HX-Trigger response headers. @@ -161,8 +144,44 @@ public static PendingEvents GetOrCreate(HttpResponse response) /// The events to serialize. private void SetHeader(string name, SmallDictionary? events) { - if (events is not null) - _response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase); + if (events is null) + return; + + _buffer.Clear(); + + using (var writer = new Utf8JsonWriter(_buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true })) + { + writer.WriteStartObject(); + + foreach (var (key, value) in events) + { + writer.WritePropertyName(JsonNamingPolicy.CamelCase.ConvertName(key)); + + if (key == ProxyEventName) + { + writer.WriteStartArray(); + + foreach (var (k, v) in (List>)value) + { + writer.WriteStartObject(); + writer.WriteString("key", k); + writer.WritePropertyName("value"); + writer.WriteRawValue(v); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + else + { + writer.WriteRawValue((string)value); + } + } + + writer.WriteEndObject(); + } + + _response.Headers[name] = Encoding.UTF8.GetString(_buffer.WrittenSpan); } /// @@ -178,8 +197,7 @@ private HtmxTriggerTiming NormalizeTiming(HtmxTriggerTiming timing) => : timing; /// - /// Returns the configured HTMX target version, defaulting to HTMX 2.x - /// when toolkit services are unavailable. + /// Returns the configured HTMX target version, defaulting to HTMX 2.x when toolkit services are unavailable. /// /// The response whose request services are inspected. /// diff --git a/src/Ramstack.HtmxToolkit/Properties/CollectionBuilderAttribute.cs b/src/Ramstack.HtmxToolkit/Properties/CollectionBuilderAttribute.cs index ff1aa00..9ce29c1 100644 --- a/src/Ramstack.HtmxToolkit/Properties/CollectionBuilderAttribute.cs +++ b/src/Ramstack.HtmxToolkit/Properties/CollectionBuilderAttribute.cs @@ -1,6 +1,7 @@ #if !NET8_0_OR_GREATER -// ReSharper disable once CheckNamespace +// ReSharper disable CheckNamespace +// ReSharper disable UnusedAutoPropertyAccessor.Global namespace System.Runtime.CompilerServices; /// diff --git a/src/Ramstack.HtmxToolkit/Properties/RequiresDynamicCodeAttribute.cs b/src/Ramstack.HtmxToolkit/Properties/RequiresDynamicCodeAttribute.cs new file mode 100644 index 0000000..9f2f6ea --- /dev/null +++ b/src/Ramstack.HtmxToolkit/Properties/RequiresDynamicCodeAttribute.cs @@ -0,0 +1,32 @@ +#if !NET7_0_OR_GREATER + +// ReSharper disable CheckNamespace +// ReSharper disable UnusedAutoPropertyAccessor.Global +// ReSharper disable UnusedMember.Global +namespace System.Diagnostics.CodeAnalysis; + +/// +/// Indicates that a member requires the ability to generate code at runtime. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class, Inherited = false)] +internal sealed class RequiresDynamicCodeAttribute : Attribute +{ + /// + /// Gets a message describing why the member requires dynamic code. + /// + public string Message { get; } + + /// + /// Gets or sets an optional URL containing more information. + /// + public string? Url { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// A message describing why the member requires dynamic code. + public RequiresDynamicCodeAttribute(string message) => + Message = message; +} + +#endif diff --git a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs index 00a0b72..d8fb1ad 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/JsonOptions.cs @@ -6,7 +6,7 @@ namespace Ramstack.HtmxToolkit.Serialization; /// -/// Provides preconfigured for JSON serialization. +/// Provides shared options and encoding settings for JSON serialization. /// internal static class JsonOptions { @@ -17,6 +17,12 @@ internal static class JsonOptions private static readonly JavaScriptEncoder s_encoder = JavaScriptEncoder.Create(new TextEncoderSettings(UnicodeRanges.All)); + /// + /// Gets the shared encoder that preserves characters from all Unicode ranges while escaping + /// JavaScript- and HTML-sensitive characters. + /// + public static JavaScriptEncoder Encoder => s_encoder; + /// /// Configures serializer options to preserve Unicode characters while escaping /// JavaScript- and HTML-sensitive characters. diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs index 4564e1f..9948c1d 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs @@ -18,9 +18,6 @@ public void Properties_RoundTrips() headers.Retarget = "#target"; headers.Reselect = "#select"; headers.Reswap = HtmxSwap.OuterHtml; - headers.Trigger = new Dictionary { ["message"] = "hello" }; - headers.TriggerAfterSwap = new Dictionary { ["swapped"] = true }; - headers.TriggerAfterSettle = new Dictionary { ["settled"] = 1 }; Assert.That(headers.Location, Is.EqualTo("/foo")); Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Location], Is.EqualTo("/foo")); @@ -31,12 +28,6 @@ public void Properties_RoundTrips() Assert.That(headers.Reselect, Is.EqualTo("#select")); Assert.That(headers.Reswap, Is.EqualTo(HtmxSwap.OuterHtml)); Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Reswap], Is.EqualTo("outerHTML")); - Assert.That(headers.Trigger.Count, Is.EqualTo(1)); - Assert.That(headers.Trigger["message"], Is.EqualTo("hello")); - Assert.That(headers.TriggerAfterSwap.Count, Is.EqualTo(1)); - Assert.That(headers.TriggerAfterSwap["swapped"], Is.True); - Assert.That(headers.TriggerAfterSettle.Count, Is.EqualTo(1)); - Assert.That(headers.TriggerAfterSettle["settled"], Is.EqualTo(1)); } [Test] @@ -119,15 +110,15 @@ public void TriggerTimingProperties_Htmx4_AliasReceiveTrigger() { var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4); var headers = context.Response.GetHtmxHeaders(); - var events = new Dictionary { ["swapped"] = true }; - headers.TriggerAfterSwap = events; + context.Response.Htmx(htmx => htmx.TriggerEvent("swapped", true, HtmxTriggerTiming.AfterSwap)); Assert.Multiple(() => { - Assert.That(headers.Trigger, Is.EqualTo(events)); - Assert.That(headers.TriggerAfterSwap, Is.EqualTo(events)); - Assert.That(headers.TriggerAfterSettle, Is.EqualTo(events)); + Assert.That(headers.Trigger, Is.Not.Null); + Assert.That(headers.Trigger, Is.SameAs(headers.Trigger)); + Assert.That(headers.TriggerAfterSwap, Is.SameAs(headers.Trigger)); + Assert.That(headers.TriggerAfterSettle, Is.SameAs(headers.Trigger)); }); PendingEvents.GetOrCreate(context.Response).Flush(); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index c158de3..3e9f179 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + using Ramstack.HtmxToolkit.Configuration; namespace Ramstack.HtmxToolkit.Tests; @@ -192,7 +194,7 @@ public void TriggerEvent_SetsReceiveTrigger() var events = context.Response.GetHtmxHeaders().Trigger!; Assert.That(events.Count, Is.EqualTo(1)); - Assert.That(events["notify"], Is.EqualTo("hello")); + Assert.That(events["notify"], Is.EqualTo("\"hello\"")); } [Test] @@ -204,7 +206,7 @@ public void TriggerEvent_WithTiming_SetsAfterSettleTrigger() var events = context.Response.GetHtmxHeaders().TriggerAfterSettle!; Assert.That(events.Count, Is.EqualTo(1)); - Assert.That(events["done"], Is.EqualTo("")); + Assert.That(events["done"], Is.EqualTo("{}")); } [Test] @@ -222,6 +224,43 @@ public void TriggerEvent_WithComplexDetail_SerializesJson() Assert.That(json["message"].GetProperty("text").GetString(), Is.EqualTo("hi")); } + [Test] + public void TriggerEvent_WithIndentedJsonTypeInfo_SerializesImmediatelyAsCompactJson() + { + var context = TestHelper.CreateHtmxRequestContext(); + var detail = new TriggerEventDetail { Text = "before" }; + + context.Response.Htmx(static (htmx, state) => + { + var info = TriggerEventJsonSerializerContext.Default.TriggerEventDetail; + htmx.TriggerEvent("ModelChanged", state, info); + }, detail); + + detail.Text = "after"; + + var events = context.Response.GetHtmxHeaders().Trigger!; + PendingEvents.GetOrCreate(context.Response).Flush(); + + Assert.Multiple(() => + { + Assert.That(events["ModelChanged"], Is.EqualTo("{\"text\":\"before\"}")); + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"modelChanged\":{\"text\":\"before\"}}")); + }); + } + + [Test] + public void TriggerEvent_ReservedProxyEventName_ThrowsArgumentException() + { + var context = TestHelper.CreateHtmxRequestContext(); + + var exception = Assert.Throws(() => + context.Response.Htmx(static htmx => htmx.TriggerEvent("rs:event"))); + + Assert.That(exception?.ParamName, Is.EqualTo("eventName")); + } + [Test] public void TriggerEvent_Htmx4_AddsEveryTimingToReceiveTrigger() { @@ -243,3 +282,12 @@ public void TriggerEvent_Htmx4_AddsEveryTimingToReceiveTrigger() }); } } + +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TriggerEventDetail))] +internal partial class TriggerEventJsonSerializerContext : JsonSerializerContext; + +internal sealed class TriggerEventDetail +{ + public string Text { get; set; } = ""; +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs index 1f9006b..11220c8 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + using Ramstack.HtmxToolkit.Configuration; namespace Ramstack.HtmxToolkit.Tests; @@ -11,14 +13,14 @@ public void AddEvent_StoresDistinctKeys() var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "a", 1); - pending.AddEvent(HtmxTriggerTiming.Receive, "b", 2); + pending.AddEvent(HtmxTriggerTiming.Receive, "a", "1"); + pending.AddEvent(HtmxTriggerTiming.Receive, "b", "2"); var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; Assert.That(events.Count, Is.EqualTo(2)); - Assert.That(events["a"], Is.EqualTo(1)); - Assert.That(events["b"], Is.EqualTo(2)); + Assert.That(events["a"], Is.EqualTo("1")); + Assert.That(events["b"], Is.EqualTo("2")); } [Test] @@ -27,14 +29,20 @@ public void AddEvent_AccumulatesDuplicateKeys_UnderProxy() var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "message", "first"); - pending.AddEvent(HtmxTriggerTiming.Receive, "message", "second"); + pending.AddEvent(HtmxTriggerTiming.Receive, "message", "\"first\""); + pending.AddEvent(HtmxTriggerTiming.Receive, "message", "\"second\""); var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; Assert.That(events.Count, Is.EqualTo(2)); - Assert.That(events["message"], Is.EqualTo("first")); - Assert.That(events["rs:events"], Is.EqualTo(new[] { KeyValuePair.Create("message", "second") })); + Assert.That(events["message"], Is.EqualTo("\"first\"")); + Assert.That(events["rs:event"], Is.EqualTo([KeyValuePair.Create("message", "\"second\"")])); + + pending.Flush(); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"message\":\"first\",\"rs:event\":[{\"key\":\"message\",\"value\":\"second\"}]}")); } [Test] @@ -43,13 +51,13 @@ public void AddEvent_TracksTimingsIndependently() var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "r", 1); - pending.AddEvent(HtmxTriggerTiming.AfterSettle, "t", 3); - pending.AddEvent(HtmxTriggerTiming.AfterSwap, "s", 2); + pending.AddEvent(HtmxTriggerTiming.Receive, "r", "1"); + pending.AddEvent(HtmxTriggerTiming.AfterSettle, "t", "3"); + pending.AddEvent(HtmxTriggerTiming.AfterSwap, "s", "2"); - Assert.That(pending.GetEvents(HtmxTriggerTiming.Receive), Is.EqualTo(CreateDictionary(("r", 1)))); - Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSettle), Is.EqualTo(CreateDictionary(("t", 3)))); - Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSwap), Is.EqualTo(CreateDictionary(("s", 2)))); + Assert.That(pending.GetEvents(HtmxTriggerTiming.Receive), Is.EqualTo(CreateDictionary(("r", "1")))); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSettle), Is.EqualTo(CreateDictionary(("t", "3")))); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSwap), Is.EqualTo(CreateDictionary(("s", "2")))); } [TestCase(HtmxTargetVersion.V1)] @@ -59,9 +67,9 @@ public void AddEvent_PriorVersions_WriteEachTimingToItsOwnHeader(HtmxTargetVersi var context = TestHelper.CreateHtmxRequestContext(targetVersion); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "received", 1); - pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", 2); - pending.AddEvent(HtmxTriggerTiming.AfterSettle, "settled", 3); + pending.AddEvent(HtmxTriggerTiming.Receive, "received", "1"); + pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", "2"); + pending.AddEvent(HtmxTriggerTiming.AfterSettle, "settled", "3"); pending.Flush(); Assert.Multiple(() => @@ -86,9 +94,9 @@ public void AddEvent_Htmx4_NormalizesAllTimingsToReceive() var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "received", 1); - pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", 2); - pending.AddEvent(HtmxTriggerTiming.AfterSettle, "settled", 3); + pending.AddEvent(HtmxTriggerTiming.Receive, "received", "1"); + pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", "2"); + pending.AddEvent(HtmxTriggerTiming.AfterSettle, "settled", "3"); var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; pending.Flush(); @@ -114,21 +122,29 @@ public void AddEvent_Htmx4_PreservesDuplicatesAcrossRequestedTimings() var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "message", "first"); - pending.AddEvent(HtmxTriggerTiming.AfterSwap, "message", "second"); - pending.AddEvent(HtmxTriggerTiming.AfterSettle, "message", "third"); + pending.AddEvent(HtmxTriggerTiming.Receive, "message", "\"first\""); + pending.AddEvent(HtmxTriggerTiming.AfterSwap, "message", "\"second\""); + pending.AddEvent(HtmxTriggerTiming.AfterSettle, "message", "\"third\""); var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; Assert.Multiple(() => { - Assert.That(events["message"], Is.EqualTo("first")); - Assert.That(events["rs:events"], Is.EqualTo(new[] - { - KeyValuePair.Create("message", "second"), - KeyValuePair.Create("message", "third") - })); + Assert.That(events["message"], Is.EqualTo("\"first\"")); + Assert.That( + events["rs:event"], + Is.EqualTo( + [ + KeyValuePair.Create("message", "\"second\""), + KeyValuePair.Create("message", "\"third\"") + ])); }); + + pending.Flush(); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"message\":\"first\",\"rs:event\":[{\"key\":\"message\",\"value\":\"second\"},{\"key\":\"message\",\"value\":\"third\"}]}")); } [Test] @@ -142,27 +158,13 @@ public void GetEvents_ReturnsNull_WhenNothingRegistered() Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSwap), Is.Null); } - [Test] - public void SetEvents_ReplacesExisting() - { - var context = TestHelper.CreateHttpContext(); - var pending = PendingEvents.GetOrCreate(context.Response); - - pending.AddEvent(HtmxTriggerTiming.Receive, "old", 1); - pending.SetEvents(HtmxTriggerTiming.Receive, CreateDictionary(("new", 2))); - - Assert.That( - pending.GetEvents(HtmxTriggerTiming.Receive), - Is.EqualTo(CreateDictionary(("new", 2)))); - } - [Test] public void Flush_WritesCamelCaseJsonToHeaders() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "message", "hello"); + pending.AddEvent(HtmxTriggerTiming.Receive, "message", "\"hello\""); pending.Flush(); @@ -177,7 +179,7 @@ public void Flush_WritesOnlyRegisteredTimings() var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", true); + pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", "true"); pending.Flush(); @@ -192,13 +194,24 @@ public void Flush_SerializesNullEventDetail_AsNull() var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "e", null!); + pending.AddEvent(HtmxTriggerTiming.Receive, "e", "null"); pending.Flush(); var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(); Assert.That(header, Is.EqualTo("{\"e\":null}")); } + [Test] + public void Flush_RejectsInvalidJsonDetail() + { + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvent(HtmxTriggerTiming.Receive, "e", "not-json"); + + Assert.Catch(pending.Flush); + } + [Test] public void GetOrCreate_ReturnsSameInstance() { @@ -218,9 +231,9 @@ public void GetOrCreate_RegistersInHttpContextItems() Assert.That(context.Items[typeof(PendingEvents)], Is.SameAs(pending)); } - private static Dictionary CreateDictionary(params (string, object)[] parameters) + private static Dictionary CreateDictionary(params (string, string)[] parameters) { - var dictionary = new Dictionary(); + var dictionary = new Dictionary(); foreach (var (k, v) in parameters) dictionary[k] = v; From 9a6faee615813da8f15138cadd9664e850b59974 Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 8 Sep 2026 17:22:19 +0500 Subject: [PATCH 2/5] build: validate AOT compatibility --- .github/workflows/publish.yml | 11 ++++++++--- src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj | 3 ++- .../Serialization/HtmxConfigJsonSerializerContext.cs | 5 +++++ .../HtmxDictionaryJsonSerializerContext.cs | 5 +++++ .../HtmxLocationOptionsJsonSerializerContext.cs | 5 +++++ .../Serialization/HtmxRequestJsonSerializerContext.cs | 5 +++++ 6 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a6fee6f..279f8d5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,9 @@ jobs: - name: Install .NET uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.x + dotnet-version: | + 8.x + 10.x - name: Checkout uses: actions/checkout@v7 @@ -28,10 +30,13 @@ jobs: fetch-depth: 1 - name: Build - run: dotnet build -c Release + run: dotnet build -c Release --warnaserror + + - name: Restore NuGet Package + run: dotnet restore src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -p:TargetFrameworks=net6.0 - name: Create NuGet Packages - run: dotnet pack -c Release -o ./nuget --no-build + run: dotnet pack src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -c Release -o ./nuget --no-build --no-restore -p:TargetFrameworks=net6.0 - name: NuGet login (OIDC) id: login diff --git a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj index f4d69ca..b0141e4 100644 --- a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj +++ b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj @@ -1,6 +1,7 @@ - net6.0 + net6.0;net8.0 + true Enables seamless integration of HTMX with ASP.NET Core (https://htmx.org). enable enable diff --git a/src/Ramstack.HtmxToolkit/Serialization/HtmxConfigJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/Serialization/HtmxConfigJsonSerializerContext.cs index 9b3a430..202578d 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/HtmxConfigJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/HtmxConfigJsonSerializerContext.cs @@ -23,6 +23,11 @@ internal partial class HtmxConfigJsonSerializerContext : JsonSerializerContext static HtmxConfigJsonSerializerContext() { JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); + + #if NET8_0_OR_GREATER + Default = new HtmxConfigJsonSerializerContext(s_defaultOptions); + #else s_defaultContext = new HtmxConfigJsonSerializerContext(s_defaultOptions); + #endif } } diff --git a/src/Ramstack.HtmxToolkit/Serialization/HtmxDictionaryJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/Serialization/HtmxDictionaryJsonSerializerContext.cs index 1ed83c4..77b44c3 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/HtmxDictionaryJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/HtmxDictionaryJsonSerializerContext.cs @@ -15,6 +15,11 @@ internal partial class HtmxDictionaryJsonSerializerContext : JsonSerializerConte static HtmxDictionaryJsonSerializerContext() { JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); + + #if NET8_0_OR_GREATER + Default = new HtmxDictionaryJsonSerializerContext(s_defaultOptions); + #else s_defaultContext = new HtmxDictionaryJsonSerializerContext(s_defaultOptions); + #endif } } diff --git a/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs index 63e57f8..b09218e 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonSerializerContext.cs @@ -19,6 +19,11 @@ internal partial class HtmxLocationOptionsJsonSerializerContext : JsonSerializer static HtmxLocationOptionsJsonSerializerContext() { JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); + + #if NET8_0_OR_GREATER + Default = new HtmxLocationOptionsJsonSerializerContext(s_defaultOptions); + #else s_defaultContext = new HtmxLocationOptionsJsonSerializerContext(s_defaultOptions); + #endif } } diff --git a/src/Ramstack.HtmxToolkit/Serialization/HtmxRequestJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/Serialization/HtmxRequestJsonSerializerContext.cs index 3d215a6..30d2f6d 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/HtmxRequestJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/HtmxRequestJsonSerializerContext.cs @@ -22,6 +22,11 @@ internal partial class HtmxRequestJsonSerializerContext : JsonSerializerContext static HtmxRequestJsonSerializerContext() { JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); + + #if NET8_0_OR_GREATER + Default = new HtmxRequestJsonSerializerContext(s_defaultOptions); + #else s_defaultContext = new HtmxRequestJsonSerializerContext(s_defaultOptions); + #endif } } From c844211bbe1b23df7aa56a470de4a7c9127aaf33 Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 8 Sep 2026 17:44:44 +0500 Subject: [PATCH 3/5] build: detect Native AOT regressions in CI --- .github/workflows/publish.yml | 5 ++++- .github/workflows/test.yml | 7 ++++++- src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 279f8d5..4ac7362 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,7 +30,10 @@ jobs: fetch-depth: 1 - name: Build - run: dotnet build -c Release --warnaserror + run: dotnet build -c Release + + - name: Build (AOT Analyze) + run: dotnet build src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -c Release -f net8.0 --no-incremental --no-restore --warnaserror -p:EnableAotAnalysis=true - name: Restore NuGet Package run: dotnet restore src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -p:TargetFrameworks=net6.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 67dc13a..fe62c92 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,9 @@ jobs: - name: Install .NET uses: actions/setup-dotnet@v6 with: - dotnet-version: 10.x + dotnet-version: | + 8.x + 10.x - name: Checkout uses: actions/checkout@v7 @@ -26,6 +28,9 @@ jobs: - name: Build (Release) run: dotnet build -c Release + - name: Build (AOT Analyze) + run: dotnet build src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj -c Release -f net8.0 --no-incremental --no-restore --warnaserror -p:EnableAotAnalysis=true + - name: Test (Debug) run: dotnet test -c Debug --no-build diff --git a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj index b0141e4..1d54cee 100644 --- a/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj +++ b/src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj @@ -1,7 +1,7 @@ net6.0;net8.0 - true + true Enables seamless integration of HTMX with ASP.NET Core (https://htmx.org). enable enable From 890c4a1285d5cbcdea754d8d22ea8452d77bbb1e Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 8 Sep 2026 17:59:03 +0500 Subject: [PATCH 4/5] http: document AOT-safe trigger event serialization --- docs/api-overwrites/responses.md | 7 +++++++ docs/articles/responses.md | 21 +++++++++++++++++++ .../responses/TriggerClientEventAot.cs | 15 +++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 docs/snippets/responses/TriggerClientEventAot.cs diff --git a/docs/api-overwrites/responses.md b/docs/api-overwrites/responses.md index 03d8cbd..8e64cc9 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,System.Text.Json.Serialization.Metadata.JsonTypeInfo{``0},Ramstack.HtmxToolkit.HtmxTriggerTiming) +example: + - |- + [!code-csharp[](../snippets/responses/TriggerClientEventAot.cs)] +--- + --- uid: Ramstack.HtmxToolkit.HtmxResponseAttribute example: diff --git a/docs/articles/responses.md b/docs/articles/responses.md index 90ac330..5f7808a 100644 --- a/docs/articles/responses.md +++ b/docs/articles/responses.md @@ -124,6 +124,27 @@ Response.Htmx(htmx => htmx.TriggerEvent( HtmxTriggerTiming.AfterSwap)); ``` +The object overload uses reflection to serialize event details. For trimming and Native AOT, +pass source-generated JSON metadata instead: + +```csharp +var detail = new ProductSaved(product.Id); + +Response.Htmx( + static (htmx, detail) => htmx.TriggerEvent( + "product-saved", + detail, + AppJsonContext.Default.ProductSaved, + HtmxTriggerTiming.AfterSwap), + detail); + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(ProductSaved))] +internal partial class AppJsonContext : JsonSerializerContext; + +internal sealed record ProductSaved(int Id); +``` + ```html public static readonly JsonSerializerOptions CamelCase = new() { + Encoder = s_encoder, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,