From 277e042be52e691b2b72fbd4e0701aeb764c3512 Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 15 Oct 2026 17:35:15 +0500 Subject: [PATCH 1/3] http: write trigger event headers as events are added TriggerEvent() collected events and wrote their headers in an OnStarting callback, which broke in two cases: - An event added after the response had started was silently dropped if an earlier event had already registered the callback. - Response.Clear() did not remove the collected events, so an exception handler sent them with the error page, and HTMX 2.x triggered them for the failed request. Each TriggerEvent() call now sets the header in Response.Headers, replacing its value with the collected events. After the response has started, it throws InvalidOperationException like any other header change. If the header has been removed, the events collected before are discarded. --- .../Internal/PendingEvents.cs | 118 +++++++++++------- .../HtmxResponseHeadersTests.cs | 2 - .../HtmxResponseTests.cs | 47 +++++-- .../Internal/PendingEventsTests.cs | 98 +++++++++------ .../Ramstack.HtmxToolkit.Tests/TestHelper.cs | 25 ++++ 5 files changed, 198 insertions(+), 92 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs b/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs index e82522b..8066698 100644 --- a/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs @@ -12,8 +12,8 @@ namespace Ramstack.HtmxToolkit.Internal; /// -/// Accumulates HTMX events by for a single request, -/// deferring header serialization until the response is about to start. +/// Accumulates HTMX events by for a single request +/// and sets the corresponding response headers as the events are added. /// internal sealed class PendingEvents { @@ -43,11 +43,17 @@ private PendingEvents(HttpResponse response) => (_response, _version) = (response, GetTargetVersion(response)); /// - /// Adds the specified event to the end of the pending events for . + /// Adds the specified event to the end of the events for + /// and rewrites the corresponding response header. /// /// The time at which to trigger the event. /// The event name. /// The event detail as JSON. + /// + /// The header is the source of truth: if it has been removed, for example by + /// in an exception handler, + /// the events written into it are discarded as well. + /// public void AddEvent(HtmxTriggerTiming timing, string eventName, string detailJson) { Argument.ThrowIfNullOrWhiteSpace(eventName); @@ -58,25 +64,35 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, string detailJs $"The event name '{ProxyEventName}' is reserved.", nameof(eventName)); + if (_response.HasStarted) + throw new InvalidOperationException( + $"Cannot trigger the event '{eventName}' because the response has already started."); + timing = NormalizeTiming(timing); - var events = timing switch - { - HtmxTriggerTiming.Receive => _receive ??= new EventList(), - HtmxTriggerTiming.AfterSwap => _afterSwap ??= new EventList(), - _ => _afterSettle ??= new EventList() - }; + var name = GetHeaderName(timing); + ref var events = ref GetEventList(timing); + // The header is missing if it has not been set yet or has been removed, for example + // by Response.Clear() in an exception handler. Start a new list so that the removed + // events are not written back into the header. + if (!_response.Headers.ContainsKey(name)) + events = null; + + events ??= new EventList(); events.Add(eventName, detailJson); + + SetHeader(name, events); } /// - /// Returns the pending events for the specified + /// Returns the events for the specified /// in the order they were added. /// /// The time at which to trigger the events. /// - /// The pending events, or if none were registered. + /// The events, or if none were registered + /// or their header has been removed. /// /// /// Returns a read-only live view of the internal accumulator. @@ -85,25 +101,9 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, string detailJs { timing = NormalizeTiming(timing); - return timing switch - { - HtmxTriggerTiming.Receive => _receive, - HtmxTriggerTiming.AfterSwap => _afterSwap, - _ => _afterSettle - }; - } - - /// - /// Serializes the accumulated events, if any, into the corresponding - /// HX-Trigger response headers. - /// - public void Flush() - { - var buffer = new ArrayBufferWriter(); - - SetHeader(HtmxResponseHeaderNames.Trigger, _receive, buffer); - SetHeader(HtmxResponseHeaderNames.TriggerAfterSwap, _afterSwap, buffer); - SetHeader(HtmxResponseHeaderNames.TriggerAfterSettle, _afterSettle, buffer); + return _response.Headers.ContainsKey(GetHeaderName(timing)) + ? GetEventList(timing) + : null; } /// @@ -133,35 +133,23 @@ public static PendingEvents GetOrCreate(HttpResponse response) pending = new PendingEvents(response); context.Items[typeof(PendingEvents)] = pending; - response.OnStarting(static o => - { - var state = (PendingEvents)o; - state.Flush(); - return Task.CompletedTask; - }, pending); - return pending; } /// - /// Serializes the specified events into a response header - /// when the collection is not . + /// Serializes the specified events into a response header. /// /// The response header name. /// The events to serialize. - /// The buffer that receives the serialized header value. /// /// HTMX parses the header with JSON.parse, which keeps only the last of repeated names. /// If an event name repeats, all events are written in order under the key, /// and the Toolkit script replays them. /// Writing only the repeats there would change the order in which the events are triggered. /// - private void SetHeader(string name, EventList? events, ArrayBufferWriter buffer) + private void SetHeader(string name, EventList events) { - if (events is null) - return; - - buffer.Clear(); + var buffer = new ArrayBufferWriter(); using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true })) { @@ -199,6 +187,41 @@ private void SetHeader(string name, EventList? events, ArrayBufferWriter b _response.Headers[name] = Encoding.UTF8.GetString(buffer.WrittenSpan); } + /// + /// Returns a reference to the field that stores the events for the specified timing. + /// + /// The normalized event timing. + /// + /// A reference to the field that stores the events. + /// + private ref EventList? GetEventList(HtmxTriggerTiming timing) + { + if (timing == HtmxTriggerTiming.Receive) + return ref _receive; + + if (timing == HtmxTriggerTiming.AfterSwap) + return ref _afterSwap; + + return ref _afterSettle; + } + + /// + /// Returns the name of the response header that carries the events for the specified timing. + /// + /// The normalized event timing. + /// + /// The response header name. + /// + private static string GetHeaderName(HtmxTriggerTiming timing) + { + return timing switch + { + HtmxTriggerTiming.Receive => HtmxResponseHeaderNames.Trigger, + HtmxTriggerTiming.AfterSwap => HtmxResponseHeaderNames.TriggerAfterSwap, + _ => HtmxResponseHeaderNames.TriggerAfterSettle + }; + } + /// /// Determines whether any event name occurs more than once. /// @@ -209,8 +232,7 @@ private void SetHeader(string name, EventList? events, ArrayBufferWriter b private static bool HasRepeatedNames(EventList events) { // A response usually carries only a few events, and for them a quadratic scan - // is cheaper than allocating a set. Events can be added in a loop, though, - // so a set protects against a large number of them. + // is cheaper than allocating a set. var storage = events.Storage; if (storage.Count <= LinearScanThreshold) @@ -248,7 +270,7 @@ private static void WriteDetail(Utf8JsonWriter writer, string detailJson) } else { - writer.WriteRawValue(detailJson); + writer.WriteRawValue(detailJson, skipInputValidation: true); } } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs index d0f89a1..b524949 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs @@ -123,8 +123,6 @@ public void TriggerTimingProperties_Htmx4_AliasReceiveTrigger() Assert.That(headers.TriggerAfterSettle, Is.SameAs(headers.Trigger)); }); - PendingEvents.GetOrCreate(context.Response).Flush(); - Assert.Multiple(() => { Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Trigger), Is.True); diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 3a4be18..c679cdc 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; + using Ramstack.HtmxToolkit.Configuration; namespace Ramstack.HtmxToolkit.Tests; @@ -421,8 +423,6 @@ public void TriggerEvent_WithComplexDetail_SerializesJson() var context = TestHelper.CreateHtmxRequestContext(); context.Response.Htmx(r => r.TriggerEvent("message", new { Id = 42, Text = "hi" })); - PendingEvents.GetOrCreate(context.Response).Flush(); - var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(); var json = JsonHelper.ParseJson(header); @@ -445,7 +445,6 @@ public void TriggerEvent_WithIndentedJsonTypeInfo_SerializesImmediatelyAsCompact detail.Text = "after"; var events = context.Response.GetHtmxHeaders().Trigger!; - PendingEvents.GetOrCreate(context.Response).Flush(); Assert.Multiple(() => { @@ -462,8 +461,6 @@ public void TriggerEvent_NullObjectDetail_SerializesAsNullValue() var context = TestHelper.CreateHtmxRequestContext(); context.Response.Htmx(r => r.TriggerEvent("e", null!)); - PendingEvents.GetOrCreate(context.Response).Flush(); - Assert.That( context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), Is.EqualTo("{\"e\":{\"value\":null}}")); @@ -475,13 +472,49 @@ 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_AfterResponseStarted_ThrowsInvalidOperationException() + { + var context = TestHelper.CreateHtmxRequestContext(); + + context.Response.Htmx(r => r.TriggerEvent("before")); + TestHelper.StartResponse(context); + + Assert.Throws(() => + context.Response.Htmx(r => r.TriggerEvent("after"))); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"before\":{}}")); + } + + [Test] + public void TriggerEvent_AfterResponseCleared_DropsPreviousEvents() + { + // An exception handler clears the response before rendering the error page, + // so the events of the failed request must not reach the client. + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.TriggerEvent("saved")); + context.Response.Clear(); + + Assert.Multiple(() => + { + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Trigger), Is.False); + Assert.That(context.Response.GetHtmxHeaders().Trigger, Is.Null); + }); + + context.Response.Htmx(r => r.TriggerEvent("failed")); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"failed\":{}}")); + } + [Test] public void TriggerEvent_NullJsonTypeInfo_ThrowsArgumentNullException() { diff --git a/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs index b6dc210..23dc6f8 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/Internal/PendingEventsTests.cs @@ -1,5 +1,3 @@ -using System.Text.Json; - using Ramstack.HtmxToolkit.Configuration; namespace Ramstack.HtmxToolkit.Tests.Internal; @@ -40,7 +38,7 @@ public void AddEvent_KeepsRepeatedNames() } [Test] - public void Flush_WithRepeatedName_WritesAllEventsInOrderUnderProxy() + public void AddEvent_WithRepeatedName_WritesAllEventsInOrderUnderProxy() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); @@ -49,7 +47,6 @@ public void Flush_WithRepeatedName_WritesAllEventsInOrderUnderProxy() pending.AddEvent(HtmxTriggerTiming.Receive, "a", "2"); pending.AddEvent(HtmxTriggerTiming.Receive, "b", "3"); pending.AddEvent(HtmxTriggerTiming.Receive, "a", "4"); - pending.Flush(); Assert.That( context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), @@ -62,7 +59,7 @@ public void Flush_WithRepeatedName_WritesAllEventsInOrderUnderProxy() } [Test] - public void Flush_WithoutRepeatedNames_WritesEventsInOrder() + public void AddEvent_WithoutRepeatedNames_WritesEventsInOrder() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); @@ -70,8 +67,6 @@ public void Flush_WithoutRepeatedNames_WritesEventsInOrder() foreach (var name in new[] { "g", "f", "e", "d", "c", "b", "a" }) pending.AddEvent(HtmxTriggerTiming.Receive, name, "1"); - pending.Flush(); - Assert.That( context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), Is.EqualTo("{\"g\":1,\"f\":1,\"e\":1,\"d\":1,\"c\":1,\"b\":1,\"a\":1}")); @@ -80,7 +75,7 @@ public void Flush_WithoutRepeatedNames_WritesEventsInOrder() [TestCase(8)] [TestCase(9)] [TestCase(20)] - public void Flush_WithManyEvents_WritesRepeatedNameUnderProxy(int count) + public void AddEvent_WithManyEvents_WritesRepeatedNameUnderProxy(int count) { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); @@ -89,7 +84,6 @@ public void Flush_WithManyEvents_WritesRepeatedNameUnderProxy(int count) pending.AddEvent(HtmxTriggerTiming.Receive, "e" + i, "1"); pending.AddEvent(HtmxTriggerTiming.Receive, "e1", "2"); - pending.Flush(); var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(); @@ -100,7 +94,7 @@ public void Flush_WithManyEvents_WritesRepeatedNameUnderProxy(int count) [TestCase(8)] [TestCase(9)] [TestCase(20)] - public void Flush_WithManyDistinctEvents_WritesProperties(int count) + public void AddEvent_WithManyDistinctEvents_WritesProperties(int count) { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); @@ -108,8 +102,6 @@ public void Flush_WithManyDistinctEvents_WritesProperties(int count) for (var i = 1; i <= count; i++) pending.AddEvent(HtmxTriggerTiming.Receive, "e" + i, "1"); - pending.Flush(); - var expected = "{" + string.Join(",", Enumerable.Range(1, count).Select(i => $"\"e{i}\":1")) + "}"; Assert.That( @@ -142,7 +134,6 @@ public void AddEvent_PriorVersions_WriteEachTimingToItsOwnHeader(HtmxTargetVersi pending.AddEvent(HtmxTriggerTiming.Receive, "received", "1"); pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", "2"); pending.AddEvent(HtmxTriggerTiming.AfterSettle, "settled", "3"); - pending.Flush(); Assert.Multiple(() => { @@ -171,7 +162,6 @@ public void AddEvent_Htmx4_NormalizesAllTimingsToReceive() pending.AddEvent(HtmxTriggerTiming.AfterSettle, "settled", "3"); var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; - pending.Flush(); Assert.Multiple(() => { @@ -213,8 +203,6 @@ public void AddEvent_Htmx4_PreservesDuplicatesAcrossRequestedTimings() KeyValuePair.Create("message", "\"second\""), KeyValuePair.Create("message", "\"third\"")])); - pending.Flush(); - Assert.That( context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), Is.EqualTo( @@ -236,28 +224,25 @@ public void GetEvents_ReturnsNull_WhenNothingRegistered() } [Test] - public void Flush_WritesEventDetailToHeader() + public void AddEvent_WritesEventDetailToHeader() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvent(HtmxTriggerTiming.Receive, "message", "\"hello\""); - pending.Flush(); - var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger]; Assert.That(header.ToString(), Is.EqualTo("{\"message\":\"hello\"}")); } [Test] - public void Flush_PreservesEventNameCase() + public void AddEvent_PreservesEventNameCase() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvent(HtmxTriggerTiming.Receive, "ShowMessage", "1"); - pending.Flush(); Assert.That( context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), @@ -265,14 +250,13 @@ public void Flush_PreservesEventNameCase() } [Test] - public void Flush_PreservesEventNameCase_InReplayedEvents() + public void AddEvent_PreservesEventNameCase_InReplayedEvents() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvent(HtmxTriggerTiming.Receive, "ShowMessage", "1"); pending.AddEvent(HtmxTriggerTiming.Receive, "ShowMessage", "2"); - pending.Flush(); Assert.That( context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), @@ -280,14 +264,13 @@ public void Flush_PreservesEventNameCase_InReplayedEvents() } [Test] - public void Flush_WritesEventNamesThatDifferOnlyInCaseSeparately() + public void AddEvent_WritesEventNamesThatDifferOnlyInCaseSeparately() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvent(HtmxTriggerTiming.Receive, "Message", "1"); pending.AddEvent(HtmxTriggerTiming.Receive, "message", "2"); - pending.Flush(); Assert.That( context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), @@ -295,42 +278,38 @@ public void Flush_WritesEventNamesThatDifferOnlyInCaseSeparately() } [Test] - public void Flush_WritesOnlyRegisteredTimings() + public void AddEvent_WritesOnlyRegisteredTimings() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", "true"); - pending.Flush(); - Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Trigger), Is.False); Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSwap), Is.True); Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSettle), Is.False); } [Test] - public void Flush_WrapsNullEventDetail() + public void AddEvent_WrapsNullEventDetail() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvent(HtmxTriggerTiming.Receive, "e", "null"); - pending.Flush(); var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(); Assert.That(header, Is.EqualTo("{\"e\":{\"value\":null}}")); } [Test] - public void Flush_WrapsNullEventDetail_InReplayedEvents() + public void AddEvent_WrapsNullEventDetail_InReplayedEvents() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvent(HtmxTriggerTiming.Receive, "e", "null"); pending.AddEvent(HtmxTriggerTiming.Receive, "e", "1"); - pending.Flush(); var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(); Assert.That(header, Is.EqualTo("{\"rs:event\":[{\"key\":\"e\",\"value\":{\"value\":null}},{\"key\":\"e\",\"value\":1}]}")); @@ -410,14 +389,63 @@ public void GetEvents_ReturnsLiveView() } [Test] - public void Flush_RejectsInvalidJsonDetail() + public void AddEvent_ResponseStarted_ThrowsInvalidOperationException() { var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); - pending.AddEvent(HtmxTriggerTiming.Receive, "e", "not-json"); + pending.AddEvent(HtmxTriggerTiming.Receive, "before", "1"); + TestHelper.StartResponse(context); + + Assert.Throws(() => + pending.AddEvent(HtmxTriggerTiming.Receive, "after", "2")); + + Assert.Multiple(() => + { + Assert.That( + pending.GetEvents(HtmxTriggerTiming.Receive), + Is.EqualTo([KeyValuePair.Create("before", "1")])); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"before\":1}")); + }); + } + + [Test] + public void AddEvent_AfterHeaderRemoved_DiscardsPreviousEvents() + { + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); - Assert.Catch(pending.Flush); + pending.AddEvent(HtmxTriggerTiming.Receive, "a", "1"); + context.Response.Headers.Remove(HtmxResponseHeaderNames.Trigger); + pending.AddEvent(HtmxTriggerTiming.Receive, "b", "2"); + + Assert.Multiple(() => + { + Assert.That( + pending.GetEvents(HtmxTriggerTiming.Receive), + Is.EqualTo([KeyValuePair.Create("b", "2")])); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"b\":2}")); + }); + } + + [Test] + public void GetEvents_ReturnsNull_WhenHeaderRemoved() + { + var context = TestHelper.CreateHttpContext(); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvent(HtmxTriggerTiming.Receive, "a", "1"); + pending.AddEvent(HtmxTriggerTiming.AfterSwap, "b", "2"); + context.Response.Headers.Remove(HtmxResponseHeaderNames.Trigger); + + Assert.That(pending.GetEvents(HtmxTriggerTiming.Receive), Is.Null); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSwap), Is.EqualTo([KeyValuePair.Create("b", "2")])); } [Test] diff --git a/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs b/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs index 88628a1..d453b98 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.DependencyInjection; @@ -69,6 +70,21 @@ public static HttpContext CreateHtmxRequestContext(HtmxTargetVersion version, bo return context; } + /// + /// Marks the response of the specified as started, + /// keeping its current headers. + /// + /// The HTTP context whose response is marked as started. + public static void StartResponse(HttpContext context) + { + var feature = new StartedResponseFeature + { + Headers = context.Response.Headers + }; + + context.Features.Set(feature); + } + /// /// Creates an HTTP context whose request services contain the toolkit /// configuration for the specified HTMX major version. @@ -116,4 +132,13 @@ public static TagHelperContext CreateTagHelperContext(string tagName = "div", Ta /// public static TagHelperOutput CreateTagHelperOutput(string tagName = "div", TagHelperAttributeList? attributes = null) => new(tagName, attributes ?? [], (_, _) => Task.FromResult(new DefaultTagHelperContent())); + + /// + /// Represents a response feature whose response has already started. + /// + private sealed class StartedResponseFeature : HttpResponseFeature + { + /// + public override bool HasStarted => true; + } } From b507964b7865cbae8ac1b29471dcf0ccc9875051 Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 15 Oct 2026 17:47:51 +0500 Subject: [PATCH 2/3] http: document when trigger event headers are set --- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 21 +++++++++++++++++++ .../HtmxResponseHeaders.cs | 18 ++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index ba0adbd..c4afe62 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -234,6 +234,13 @@ public HtmxResponse Reselect(string value) => /// is triggered. In HTMX 4.x, this also applies to a name added with different timings. /// The event name rs:event is reserved for this purpose and cannot be used. /// + /// + /// 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 . + /// /// public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => AddToPendingEvent(this, eventName, "{}", timing); @@ -262,6 +269,13 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = Ht /// is triggered. In HTMX 4.x, this also applies to a name added with different timings. /// The event name rs:event is reserved for this purpose and cannot be used. /// + /// + /// 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 . + /// /// [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.")] @@ -301,6 +315,13 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob /// is triggered. In HTMX 4.x, this also applies to a name added with different timings. /// The event name rs:event is reserved for this purpose and cannot be used. /// + /// + /// 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 . + /// /// public HtmxResponse TriggerEvent(string eventName, T detail, JsonTypeInfo jsonTypeInfo, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => TriggerEventCore(this, eventName, detail, jsonTypeInfo, timing); diff --git a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs index f556a5b..7ecfb49 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs @@ -160,9 +160,9 @@ public string? Reselect /// /// Each entry pairs an event name with its detail serialized as JSON. Events are listed /// in the order they were added, including repeated names. The list is a read-only live view - /// of the events accumulated for the current response, which are written into the header - /// immediately before the response starts. If no events have been added, - /// the property returns . + /// of the events added for the current response. The header is updated each time an event + /// is added. If no events have been added, or the header has been removed, for example by + /// , the property returns . /// /// /// HTMX 1.9.x and 2.x trigger these events when the response is received, @@ -181,9 +181,9 @@ public string? Reselect /// /// Each entry pairs an event name with its detail serialized as JSON. Events are listed /// in the order they were added, including repeated names. The list is a read-only live view - /// of the events accumulated for the current response, which are written into the header - /// immediately before the response starts. If no events have been added, - /// the property returns . + /// of the events added for the current response. The header is updated each time an event + /// is added. If no events have been added, or the header has been removed, for example by + /// , the property returns . /// /// /// HTMX 4.x has no HX-Trigger-After-Swap header, so these events are emitted through @@ -201,9 +201,9 @@ public string? Reselect /// /// Each entry pairs an event name with its detail serialized as JSON. Events are listed /// in the order they were added, including repeated names. The list is a read-only live view - /// of the events accumulated for the current response, which are written into the header - /// immediately before the response starts. If no events have been added, - /// the property returns . + /// of the events added for the current response. The header is updated each time an event + /// is added. If no events have been added, or the header has been removed, for example by + /// , the property returns . /// /// /// HTMX 4.x has no HX-Trigger-After-Settle header, so these events are emitted through From ebce368bd157eec0e88fb1664f1000f00c4d5772 Mon Sep 17 00:00:00 2001 From: rameel Date: Fri, 16 Oct 2026 14:18:26 +0500 Subject: [PATCH 3/3] http: troubleshoot HX headers set after the response has started --- docs/articles/troubleshooting.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/articles/troubleshooting.md b/docs/articles/troubleshooting.md index 23c882a..24d9fb3 100644 --- a/docs/articles/troubleshooting.md +++ b/docs/articles/troubleshooting.md @@ -185,6 +185,13 @@ Confirm the request is actually sent by HTMX and is not a history restoration re For infrastructure that must write a header unconditionally, use `Response.GetHtmxHeaders()` and make the condition explicit. +## Setting an HX header throws InvalidOperationException + +Response headers, including the events added by `TriggerEvent()`, can be set only before the response starts. +The response starts when the first part of the body is sent, for example when a view calls `FlushAsync()`. +By the time `OnResultExecuted` of a filter runs, the result has usually written the body. Set the headers +before that point, for example in `OnResultExecuting`. + ## HX-Redirect does not navigate the browser Check the response status. HTMX cannot process `HX-Redirect` or other `HX-*` headers from an intermediate 3xx response @@ -251,8 +258,8 @@ See [Response events](version-compatibility.md#response-events). ## A response event does not refresh another component First check the actual response in the Network panel for `HX-Trigger`, `HX-Trigger-After-Swap`, or -`HX-Trigger-After-Settle`. Events added by `TriggerEvent()` are written when the response starts; inspecting -the headers immediately after the method call in server code can be too early. +`HX-Trigger-After-Settle`. `TriggerEvent()` sets the header in `Response.Headers` right away, so server code +can also inspect it after the call. If the header is present: