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