From 57be89c2323737a403bd9a3cea6a2ca3cafe1bd2 Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 8 Sep 2026 21:45:53 +0500 Subject: [PATCH 1/4] http: reject empty trigger event names --- src/Ramstack.HtmxToolkit/PendingEvents.cs | 9 +++++++- .../PendingEventsTests.cs | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs index ab7778d..0da6ffb 100644 --- a/src/Ramstack.HtmxToolkit/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs @@ -44,8 +44,15 @@ private PendingEvents(HttpResponse response) => /// The event detail. public void AddEvent(HtmxTriggerTiming timing, string eventName, string detail) { + ArgumentNullException.ThrowIfNull(eventName); + + if (string.IsNullOrWhiteSpace(eventName)) + throw new ArgumentException("Event name cannot be empty.", nameof(eventName)); + if (eventName == ProxyEventName) - throw new ArgumentException($"The event name '{ProxyEventName}' is reserved.", nameof(eventName)); + throw new ArgumentException( + $"The event name '{ProxyEventName}' is reserved.", + nameof(eventName)); timing = NormalizeTiming(timing); diff --git a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs index 11220c8..99cfb21 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs @@ -201,6 +201,29 @@ public void Flush_SerializesNullEventDetail_AsNull() Assert.That(header, Is.EqualTo("{\"e\":null}")); } + [Test] + public void AddEvent_NullEventName_ThrowsArgumentNullException() + { + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + Assert.Throws(() => + pending.AddEvent(HtmxTriggerTiming.Receive, null!, "1")); + } + + [TestCase("")] + [TestCase(" ")] + public void AddEvent_EmptyOrWhitespaceEventName_ThrowsArgumentException(string eventName) + { + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + var exception = Assert.Throws(() => + pending.AddEvent(HtmxTriggerTiming.Receive, eventName, "1")); + + Assert.That(exception?.ParamName, Is.EqualTo("eventName")); + } + [Test] public void Flush_RejectsInvalidJsonDetail() { From 1e655e561b6b68844858fd91f9abda1d2ac0d712 Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 8 Sep 2026 22:29:42 +0500 Subject: [PATCH 2/4] http: normalize empty trigger event details --- src/Ramstack.HtmxToolkit/PendingEvents.cs | 5 +++- .../HtmxResponseTests.cs | 26 +++++++++++++++++++ .../PendingEventsTests.cs | 15 +++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs index 0da6ffb..229948d 100644 --- a/src/Ramstack.HtmxToolkit/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs @@ -42,7 +42,7 @@ private PendingEvents(HttpResponse response) => /// The time at which to trigger the events. /// The event name. /// The event detail. - public void AddEvent(HtmxTriggerTiming timing, string eventName, string detail) + public void AddEvent(HtmxTriggerTiming timing, string eventName, string? detail) { ArgumentNullException.ThrowIfNull(eventName); @@ -54,6 +54,9 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, string detail) $"The event name '{ProxyEventName}' is reserved.", nameof(eventName)); + if (string.IsNullOrWhiteSpace(detail)) + detail = "{}"; + timing = NormalizeTiming(timing); var current = timing switch diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 3e9f179..e385d72 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -250,6 +250,32 @@ public void TriggerEvent_WithIndentedJsonTypeInfo_SerializesImmediatelyAsCompact }); } + [Test] + public void TriggerEvent_NullObjectDetail_SerializesAsNull() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("e", (object?)null!)); + + PendingEvents.GetOrCreate(context.Response).Flush(); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"e\":null}")); + } + + [Test] + public void TriggerEvent_EmptyStringDetail_SerializesAsEmptyString() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("e", "")); + + PendingEvents.GetOrCreate(context.Response).Flush(); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"e\":\"\"}")); + } + [Test] public void TriggerEvent_ReservedProxyEventName_ThrowsArgumentException() { diff --git a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs index 99cfb21..918c9a6 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs @@ -201,6 +201,21 @@ public void Flush_SerializesNullEventDetail_AsNull() Assert.That(header, Is.EqualTo("{\"e\":null}")); } + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void AddEvent_NullOrWhitespaceDetail_NormalizesToEmptyObject(string? detail) + { + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvent(HtmxTriggerTiming.Receive, "e", detail); + pending.Flush(); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(); + Assert.That(header, Is.EqualTo("{\"e\":{}}")); + } + [Test] public void AddEvent_NullEventName_ThrowsArgumentNullException() { From c044b66d10493970babd3ba5d123c3a8570a64cd Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 8 Sep 2026 22:30:16 +0500 Subject: [PATCH 3/4] http: require JSON metadata for trigger event details --- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 2 ++ tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index e6740b6..a71726d 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -281,6 +281,8 @@ private static HtmxResponse TriggerEventCore(HtmxResponse response, string ev private static string SerializeEventDetail(T detail, JsonTypeInfo jsonTypeInfo) { + ArgumentNullException.ThrowIfNull(jsonTypeInfo); + var buffer = new ArrayBufferWriter(); using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true })) JsonSerializer.Serialize(writer, detail, jsonTypeInfo); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index e385d72..2b90523 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -276,6 +276,17 @@ public void TriggerEvent_EmptyStringDetail_SerializesAsEmptyString() Is.EqualTo("{\"e\":\"\"}")); } + [Test] + public void TriggerEvent_NullJsonTypeInfo_ThrowsArgumentNullException() + { + var context = TestHelper.CreateHtmxRequestContext(); + + var exception = Assert.Throws(() => + context.Response.Htmx(static htmx => htmx.TriggerEvent("e", new TriggerEventDetail(), null!))); + + Assert.That(exception?.ParamName, Is.EqualTo("jsonTypeInfo")); + } + [Test] public void TriggerEvent_ReservedProxyEventName_ThrowsArgumentException() { From 94b3128ebe49d29932090ea4e15f3ce91282000a Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 8 Sep 2026 22:57:30 +0500 Subject: [PATCH 4/4] http: document trigger event dictionaries as inspection-only --- src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs | 15 +++++++++++++++ src/Ramstack.HtmxToolkit/PendingEvents.cs | 4 ++++ .../PendingEventsTests.cs | 16 ++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs index 0dcbf77..b0cc14a 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs @@ -125,6 +125,11 @@ public string Reselect /// and written into the header immediately before the response starts. /// /// + /// The returned dictionary is a live view of the accumulated events, provided for + /// inspection only. Do not cast it to a mutable interface or modify it: doing so + /// corrupts the pending events and breaks header serialization. + /// + /// /// HTMX 1.x and 2.x trigger these events when the response is received, /// whereas HTMX 4.x triggers them when the request completes /// (after the swap whenever one is performed). @@ -141,6 +146,11 @@ public string Reselect /// and written into the header immediately before the response starts. /// /// + /// The returned dictionary is a live view of the accumulated events, provided for + /// inspection only. Do not cast it to a mutable interface or modify it: doing so + /// corrupts the pending events and breaks header serialization. + /// + /// /// In HTMX 4.x, assigned events are accumulated in /// and emitted through HX-Trigger when the request completes /// (after the swap whenever one is performed). @@ -157,6 +167,11 @@ public string Reselect /// and written into the header immediately before the response starts. /// /// + /// The returned dictionary is a live view of the accumulated events, provided for + /// inspection only. Do not cast it to a mutable interface or modify it: doing so + /// corrupts the pending events and breaks header serialization. + /// + /// /// In HTMX 4.x, assigned events are accumulated in /// and emitted through HX-Trigger when the request completes /// (after the swap whenever one is performed); the requested after-settle timing diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs index 229948d..8020a5b 100644 --- a/src/Ramstack.HtmxToolkit/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs @@ -86,6 +86,10 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, string? detail) /// /// The pending events, or if none were registered. /// + /// + /// Returns a live view of the internal accumulator for inspection only; + /// callers must not mutate it. + /// public IReadOnlyDictionary? GetEvents(HtmxTriggerTiming timing) { timing = NormalizeTiming(timing); diff --git a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs index 918c9a6..aa49ab5 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs @@ -239,6 +239,22 @@ public void AddEvent_EmptyOrWhitespaceEventName_ThrowsArgumentException(string e Assert.That(exception?.ParamName, Is.EqualTo("eventName")); } + [Test] + public void GetEvents_MutatingReturnedView_BreaksFlush() + { + // The returned dictionary is a live inspection-only view: writing a value + // that is not a serialized JSON fragment corrupts the pending events. + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvent(HtmxTriggerTiming.Receive, "a", "1"); + + var mutable = (IDictionary)pending.GetEvents(HtmxTriggerTiming.Receive)!; + mutable["a"] = 2; + + Assert.Throws(pending.Flush); + } + [Test] public void Flush_RejectsInvalidJsonDetail() {