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:
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
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;
+ }
}