From 5135ef7b7a20118e50fc35ff077a47f0985e5c8a Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 31 Aug 2026 03:23:53 +0500 Subject: [PATCH 1/2] Handle trigger event timing compatibility in HTMX 4.x --- README.md | 9 +++ src/Ramstack.HtmxToolkit/HtmxResponse.cs | 15 ++++ .../HtmxResponseHeaders.cs | 21 ++++- src/Ramstack.HtmxToolkit/HtmxTriggerTiming.cs | 22 ++++- src/Ramstack.HtmxToolkit/PendingEvents.cs | 40 ++++++++- .../HtmxResponseHeadersTests.cs | 28 +++++++ .../HtmxResponseTests.cs | 23 ++++++ .../PendingEventsTests.cs | 81 +++++++++++++++++++ .../Ramstack.HtmxToolkit.Tests/TestHelper.cs | 41 ++++++++++ 9 files changed, 272 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f0dd73c..a3cb588 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,15 @@ Response.Htmx(h => h .StopPolling(ShouldStopPolling)); ``` +`TriggerEvent` and `TriggerEvents` accept an optional `HtmxTriggerTiming` value. +In HTMX 1.x and 2.x, the value selects `HX-Trigger`, `HX-Trigger-After-Swap`, or +`HX-Trigger-After-Settle`. HTMX 4.x supports only `HX-Trigger`, so the toolkit +emits events requested for any timing through that header rather than dropping +them. These events run after the swap; in particular, the 1.x/2.x `Receive` and +`AfterSettle` timings cannot be preserved. See +[htmx pull request #3900](https://github.com/bigskysoftware/htmx/pull/3900) for +the upstream timing change. + :bulb: The generic overload accepts an additional state parameter to avoid closure allocations: ```csharp diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 8ea5c5d..fe965d3 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -205,6 +205,11 @@ public HtmxResponse Reselect(string value) => /// /// The current instance. /// + /// + /// In HTMX 4.x, every value is emitted through + /// HX-Trigger and runs after the swap. + /// See PR #3900. + /// public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = HtmxTriggerTiming.Receive) => TriggerEvent(eventName, "", trigger); @@ -218,6 +223,11 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = H /// /// The current instance. /// + /// + /// In HTMX 4.x, every value is emitted through + /// HX-Trigger and runs after the swap. + /// See PR #3900. + /// public HtmxResponse TriggerEvent(string eventName, object detail, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) { return TriggerEventImpl(this, eventName, detail, timing); @@ -234,6 +244,11 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob /// /// The current instance. /// + /// + /// In HTMX 4.x, every value is emitted through + /// HX-Trigger and runs after the swap. + /// See PR #3900. + /// public HtmxResponse TriggerEvents(IReadOnlyDictionary events, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => AddEvents(this, events, timing); diff --git a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs index 7ba7e24..f2ca589 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs @@ -120,8 +120,14 @@ public string Reselect /// Gets or sets the client-side events to trigger through the HX-Trigger header. /// /// - /// Event values are accumulated for the current response and serialized - /// into the header immediately before the response starts. + /// + /// Event values are accumulated for the current response and serialized + /// into the header immediately before the response starts. + /// + /// + /// HTMX 1.x and 2.x trigger these events when the response is received, + /// whereas HTMX 4.x triggers them after the swap. + /// /// [MaybeNull] public IReadOnlyDictionary Trigger @@ -139,7 +145,10 @@ public IReadOnlyDictionary Trigger /// Event values are accumulated for the current response and serialized /// into the header immediately before the response starts. /// - /// This header is supported only in HTMX 1.x and 2.x. + /// + /// In HTMX 4.x, assigned events are accumulated in + /// and emitted through HX-Trigger after the swap. + /// /// [MaybeNull] public IReadOnlyDictionary TriggerAfterSwap @@ -157,7 +166,11 @@ public IReadOnlyDictionary TriggerAfterSwap /// Event values are accumulated for the current response and serialized /// into the header immediately before the response starts. /// - /// This header is supported only in HTMX 1.x and 2.x. + /// + /// In HTMX 4.x, assigned events are accumulated in + /// and emitted through HX-Trigger after the swap; the requested + /// after-settle timing cannot be preserved. + /// /// [MaybeNull] public IReadOnlyDictionary TriggerAfterSettle diff --git a/src/Ramstack.HtmxToolkit/HtmxTriggerTiming.cs b/src/Ramstack.HtmxToolkit/HtmxTriggerTiming.cs index d603282..df63f21 100644 --- a/src/Ramstack.HtmxToolkit/HtmxTriggerTiming.cs +++ b/src/Ramstack.HtmxToolkit/HtmxTriggerTiming.cs @@ -10,20 +10,36 @@ public enum HtmxTriggerTiming /// /// /// HTMX 1.x and 2.x trigger these events when the response is received. - /// HTMX 4.x triggers them after the swap completes. + /// + /// HTMX 4.x triggers them after the swap completes. + /// See PR #3900. + /// /// Receive, /// /// Maps to the HX-Trigger-After-Swap header, which triggers events after the swap step. /// - /// Supported only in HTMX 1.x and 2.x. + /// + /// HTMX 1.x and 2.x emit these events through HX-Trigger-After-Swap. + /// + /// HTMX 4.x emits them through HX-Trigger, which also runs after the swap. + /// See PR #3900. + /// + /// AfterSwap, /// /// Maps to the HX-Trigger-After-Settle header, which triggers events /// after the settle step. /// - /// Supported only in HTMX 1.x and 2.x. + /// + /// HTMX 1.x and 2.x emit these events through HX-Trigger-After-Settle. + /// + /// HTMX 4.x emits them through HX-Trigger after the swap; the requested + /// after-settle timing cannot be preserved. + /// See PR #3900. + /// + /// AfterSettle } diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs index a513e65..5ea5c02 100644 --- a/src/Ramstack.HtmxToolkit/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs @@ -1,8 +1,10 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; using Ramstack.HtmxToolkit.Collections; +using Ramstack.HtmxToolkit.Configuration; using Ramstack.HtmxToolkit.Serialization; namespace Ramstack.HtmxToolkit; @@ -16,6 +18,8 @@ internal sealed class PendingEvents private const string ProxyEventName = "rs:events"; private readonly HttpResponse _response; + private readonly HtmxTargetVersion _version; + private SmallDictionary? _receive; private SmallDictionary? _afterSwap; private SmallDictionary? _afterSettle; @@ -25,7 +29,7 @@ internal sealed class PendingEvents /// /// The HTTP response to which the events belong. private PendingEvents(HttpResponse response) => - _response = response; + (_response, _version) = (response, GetTargetVersion(response)); /// /// Adds the specified events to the pending set for . @@ -36,6 +40,8 @@ private PendingEvents(HttpResponse response) => /// The event names and their associated details. public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary events) { + timing = NormalizeTiming(timing); + var current = timing switch { HtmxTriggerTiming.Receive => _receive ??= new SmallDictionary(StringComparer.Ordinal), @@ -67,6 +73,8 @@ public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary public IReadOnlyDictionary? GetEvents(HtmxTriggerTiming timing) { + timing = NormalizeTiming(timing); + return timing switch { HtmxTriggerTiming.Receive => _receive, @@ -82,6 +90,8 @@ public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionaryThe replacement event names and their associated details. public void SetEvents(HtmxTriggerTiming timing, IReadOnlyDictionary events) { + timing = NormalizeTiming(timing); + var replacement = new SmallDictionary(events, StringComparer.Ordinal); switch (timing) { @@ -156,4 +166,32 @@ private void SetHeader(string name, SmallDictionary? events) if (events is not null) _response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase); } + + /// + /// Normalizes unsupported HTMX 4.x trigger timings to the primary trigger header. + /// + /// The requested event timing. + /// + /// The timing supported by the configured HTMX version. + /// + private HtmxTriggerTiming NormalizeTiming(HtmxTriggerTiming timing) => + _version == HtmxTargetVersion.V4 + ? HtmxTriggerTiming.Receive + : timing; + + /// + /// Returns the configured HTMX target version, defaulting to HTMX 2.x + /// when toolkit services are unavailable. + /// + /// The response whose request services are inspected. + /// + /// The configured HTMX target version. + /// + private static HtmxTargetVersion GetTargetVersion(HttpResponse response) + { + var p = response.HttpContext.RequestServices; + var options = p?.GetService(typeof(IOptions)) as IOptions; + + return options?.Value.TargetVersion ?? HtmxTargetVersion.V2; + } } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs index 6695a65..bf8cb45 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Http; +using Ramstack.HtmxToolkit.Configuration; + namespace Ramstack.HtmxToolkit.Tests; [TestFixture] @@ -113,4 +115,30 @@ public void Trigger_IsNull_WhenNotSet() Assert.That(headers.TriggerAfterSwap, Is.Null); Assert.That(headers.TriggerAfterSettle, Is.Null); } + + [Test] + public void TriggerTimingProperties_Htmx4_AliasReceiveTrigger() + { + var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4); + var headers = context.Response.GetHtmxHeaders(); + var events = new Dictionary { ["swapped"] = true }; + + headers.TriggerAfterSwap = events; + + Assert.Multiple(() => + { + Assert.That(headers.Trigger, Is.EqualTo(events)); + Assert.That(headers.TriggerAfterSwap, Is.EqualTo(events)); + Assert.That(headers.TriggerAfterSettle, Is.EqualTo(events)); + }); + + PendingEvents.GetOrCreate(context.Response).Flush(); + + Assert.Multiple(() => + { + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Trigger), Is.True); + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSwap), Is.False); + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSettle), Is.False); + }); + } } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 40860b6..8148ff6 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -1,3 +1,5 @@ +using Ramstack.HtmxToolkit.Configuration; + namespace Ramstack.HtmxToolkit.Tests; [TestFixture] @@ -260,4 +262,25 @@ public void TriggerEvents_SetsMultipleEvents() Assert.That(events["a"], Is.EqualTo(1)); Assert.That(events["b"], Is.EqualTo(2)); } + + [Test] + public void TriggerEvent_Htmx4_AddsEveryTimingToReceiveTrigger() + { + var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4); + + context.Response.Htmx(r => r + .TriggerEvent("received") + .TriggerEvent("swapped", HtmxTriggerTiming.AfterSwap) + .TriggerEvent("settled", HtmxTriggerTiming.AfterSettle)); + + var headers = context.Response.GetHtmxHeaders(); + var events = headers.Trigger!; + + Assert.Multiple(() => + { + Assert.That(events.Keys, Is.EqualTo(["received", "swapped", "settled"])); + Assert.That(headers.TriggerAfterSwap, Is.SameAs(events)); + Assert.That(headers.TriggerAfterSettle, Is.SameAs(events)); + }); + } } diff --git a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs index 46e1dae..3b0ef3a 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Http; +using Ramstack.HtmxToolkit.Configuration; + namespace Ramstack.HtmxToolkit.Tests; [TestFixture] @@ -58,6 +60,85 @@ public void AddEvents_TracksTimingsIndependently() Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSwap), Is.EqualTo(CreateDictionary(("s", 2)))); } + [TestCase(HtmxTargetVersion.V1)] + [TestCase(HtmxTargetVersion.V2)] + public void AddEvents_LegacyVersions_WriteEachTimingToItsOwnHeader(HtmxTargetVersion targetVersion) + { + var context = TestHelper.CreateHtmxRequestContext(targetVersion); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("received", 1))); + pending.AddEvents(HtmxTriggerTiming.AfterSwap, CreateDictionary(("swapped", 2))); + pending.AddEvents(HtmxTriggerTiming.AfterSettle, CreateDictionary(("settled", 3))); + pending.Flush(); + + Assert.Multiple(() => + { + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"received\":1}")); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.TriggerAfterSwap].ToString(), + Is.EqualTo("{\"swapped\":2}")); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.TriggerAfterSettle].ToString(), + Is.EqualTo("{\"settled\":3}")); + }); + } + + [Test] + public void AddEvents_Htmx4_NormalizesAllTimingsToReceive() + { + var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("received", 1))); + pending.AddEvents(HtmxTriggerTiming.AfterSwap, CreateDictionary(("swapped", 2))); + pending.AddEvents(HtmxTriggerTiming.AfterSettle, CreateDictionary(("settled", 3))); + + var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; + pending.Flush(); + + Assert.Multiple(() => + { + Assert.That(events.Keys, Is.EqualTo(["received", "swapped", "settled"])); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSwap), Is.SameAs(events)); + Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSettle), Is.SameAs(events)); + + Assert.That( + context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(), + Is.EqualTo("{\"received\":1,\"swapped\":2,\"settled\":3}")); + + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSwap), Is.False); + Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSettle), Is.False); + }); + } + + [Test] + public void AddEvents_Htmx4_PreservesDuplicatesAcrossRequestedTimings() + { + var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4); + var pending = PendingEvents.GetOrCreate(context.Response); + + pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("message", "first"))); + pending.AddEvents(HtmxTriggerTiming.AfterSwap, CreateDictionary(("message", "second"))); + pending.AddEvents(HtmxTriggerTiming.AfterSettle, CreateDictionary(("message", "third"))); + + var events = pending.GetEvents(HtmxTriggerTiming.Receive)!; + + Assert.Multiple(() => + { + Assert.That(events["message"], Is.EqualTo("first")); + Assert.That(events["rs:events"], Is.EqualTo(new[] + { + KeyValuePair.Create("message", "second"), + KeyValuePair.Create("message", "third") + })); + }); + } + [Test] public void GetEvents_ReturnsNull_WhenNothingRegistered() { diff --git a/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs b/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs index ada0cee..02cdb65 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs @@ -1,5 +1,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using Ramstack.HtmxToolkit.Configuration; namespace Ramstack.HtmxToolkit.Tests; @@ -44,6 +48,43 @@ public static HttpContext CreateHtmxRequestContext(bool boosted = false) return context; } + /// + /// Creates a whose request is an htmx request + /// and whose services target the specified HTMX major version. + /// + /// The HTMX major version to configure. + /// to mark the request as boosted; + /// otherwise, . + /// + /// The configured . + /// + public static HttpContext CreateHtmxRequestContext(HtmxTargetVersion version, bool boosted = false) + { + var context = CreateHtmxRequestContext(boosted); + var options = new HtmxToolkitOptions(); + + switch (version) + { + case HtmxTargetVersion.V1: + options.UseHtmxV1(); + break; + case HtmxTargetVersion.V2: + options.UseHtmxV2(); + break; + case HtmxTargetVersion.V4: + options.UseHtmxV4(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(version)); + } + + context.RequestServices = new ServiceCollection() + .AddSingleton(Options.Create(options)) + .BuildServiceProvider(); + + return context; + } + /// /// Creates a with the given tag name, attributes and items. /// From d0db4ef5e20622ce319d002f445b45ae3d001027 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 31 Aug 2026 03:35:02 +0500 Subject: [PATCH 2/2] Configure request services in HTMX response tests --- src/Ramstack.HtmxToolkit/PendingEvents.cs | 2 +- .../HtmxResponseHeadersTests.cs | 18 +++-- .../PendingEventsTests.cs | 22 +++---- .../Ramstack.HtmxToolkit.Tests/TestHelper.cs | 66 ++++++++++++------- 4 files changed, 62 insertions(+), 46 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs index 5ea5c02..210c064 100644 --- a/src/Ramstack.HtmxToolkit/PendingEvents.cs +++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs @@ -190,7 +190,7 @@ private HtmxTriggerTiming NormalizeTiming(HtmxTriggerTiming timing) => private static HtmxTargetVersion GetTargetVersion(HttpResponse response) { var p = response.HttpContext.RequestServices; - var options = p?.GetService(typeof(IOptions)) as IOptions; + var options = p.GetService(typeof(IOptions)) as IOptions; return options?.Value.TargetVersion ?? HtmxTargetVersion.V2; } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs index bf8cb45..4564e1f 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs @@ -1,5 +1,3 @@ -using Microsoft.AspNetCore.Http; - using Ramstack.HtmxToolkit.Configuration; namespace Ramstack.HtmxToolkit.Tests; @@ -10,7 +8,7 @@ public class HtmxResponseHeadersTests [Test] public void Properties_RoundTrips() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var headers = context.Response.GetHtmxHeaders(); headers.Location = "/foo"; @@ -44,7 +42,7 @@ public void Properties_RoundTrips() [Test] public void Refresh_WhenTrue_SetsTrue() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var headers = context.Response.GetHtmxHeaders(); headers.Refresh = true; @@ -56,7 +54,7 @@ public void Refresh_WhenTrue_SetsTrue() [Test] public void Refresh_IsFalse_ByDefault() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var headers = context.Response.GetHtmxHeaders(); Assert.That(headers.Refresh, Is.False); @@ -65,7 +63,7 @@ public void Refresh_IsFalse_ByDefault() [Test] public void Reswap_IsNull_WhenHeaderAbsent() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var headers = context.Response.GetHtmxHeaders(); Assert.That(headers.Reswap, Is.Null); @@ -74,7 +72,7 @@ public void Reswap_IsNull_WhenHeaderAbsent() [Test] public void Reswap_IsNull_WhenHeaderUnknown() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var headers = context.Response.GetHtmxHeaders(); context.Response.Headers[HtmxResponseHeaderNames.Reswap] = "bogus"; @@ -85,7 +83,7 @@ public void Reswap_IsNull_WhenHeaderUnknown() [Test] public void ReswapExpression_KeepsFullExpression_WhileReswapParsesOnlyStyle() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var headers = context.Response.GetHtmxHeaders(); headers.ReswapExpression = "outerHTML show:top"; @@ -97,7 +95,7 @@ public void ReswapExpression_KeepsFullExpression_WhileReswapParsesOnlyStyle() [Test] public void SettingNull_DoesNotAddHeader() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var headers = context.Response.GetHtmxHeaders(); headers.Location = null!; @@ -108,7 +106,7 @@ public void SettingNull_DoesNotAddHeader() [Test] public void Trigger_IsNull_WhenNotSet() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var headers = context.Response.GetHtmxHeaders(); Assert.That(headers.Trigger, Is.Null); diff --git a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs index 3b0ef3a..8c5f069 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs @@ -1,5 +1,3 @@ -using Microsoft.AspNetCore.Http; - using Ramstack.HtmxToolkit.Configuration; namespace Ramstack.HtmxToolkit.Tests; @@ -10,7 +8,7 @@ public class PendingEventsTests [Test] public void AddEvents_StoresDistinctKeys() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvents( @@ -27,7 +25,7 @@ public void AddEvents_StoresDistinctKeys() [Test] public void AddEvents_AccumulatesDuplicateKeys_UnderProxy() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvents( @@ -48,7 +46,7 @@ public void AddEvents_AccumulatesDuplicateKeys_UnderProxy() [Test] public void AddEvents_TracksTimingsIndependently() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("r", 1))); @@ -142,7 +140,7 @@ public void AddEvents_Htmx4_PreservesDuplicatesAcrossRequestedTimings() [Test] public void GetEvents_ReturnsNull_WhenNothingRegistered() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); Assert.That(pending.GetEvents(HtmxTriggerTiming.Receive), Is.Null); @@ -153,7 +151,7 @@ public void GetEvents_ReturnsNull_WhenNothingRegistered() [Test] public void SetEvents_ReplacesExisting() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("old", 1))); @@ -167,7 +165,7 @@ public void SetEvents_ReplacesExisting() [Test] public void Flush_WritesCamelCaseJsonToHeaders() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvents( @@ -184,7 +182,7 @@ public void Flush_WritesCamelCaseJsonToHeaders() [Test] public void Flush_WritesOnlyRegisteredTimings() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvents( @@ -201,7 +199,7 @@ public void Flush_WritesOnlyRegisteredTimings() [Test] public void Flush_SerializesNullEventDetail_AsNull() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("e", null!))); @@ -214,7 +212,7 @@ public void Flush_SerializesNullEventDetail_AsNull() [Test] public void GetOrCreate_ReturnsSameInstance() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); Assert.That(pending, Is.SameAs(PendingEvents.GetOrCreate(context.Response))); @@ -224,7 +222,7 @@ public void GetOrCreate_ReturnsSameInstance() [Test] public void GetOrCreate_RegistersInHttpContextItems() { - var context = new DefaultHttpContext(); + var context = TestHelper.CreateHttpContext(); var pending = PendingEvents.GetOrCreate(context.Response); Assert.That(context.Items[typeof(PendingEvents)], Is.SameAs(pending)); diff --git a/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs b/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs index 02cdb65..5f3fe0c 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TestHelper.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Ramstack.HtmxToolkit.Configuration; +using Ramstack.HtmxToolkit.Hosting; namespace Ramstack.HtmxToolkit.Tests; @@ -21,7 +21,7 @@ internal static class TestHelper /// public static HttpContext CreateHttpContext(params (string Name, string Value)[] headers) { - var context = new DefaultHttpContext(); + var context = CreateContext(HtmxTargetVersion.V2); foreach (var (name, value) in headers) context.Request.Headers[name] = value; @@ -39,7 +39,7 @@ public static HttpContext CreateHttpContext(params (string Name, string Value)[] /// public static HttpContext CreateHtmxRequestContext(bool boosted = false) { - var context = new DefaultHttpContext(); + var context = CreateContext(HtmxTargetVersion.V2); context.Request.Headers[HtmxRequestHeaderNames.Request] = "true"; if (boosted) @@ -60,31 +60,51 @@ public static HttpContext CreateHtmxRequestContext(bool boosted = false) /// public static HttpContext CreateHtmxRequestContext(HtmxTargetVersion version, bool boosted = false) { - var context = CreateHtmxRequestContext(boosted); - var options = new HtmxToolkitOptions(); + var context = CreateContext(version); + context.Request.Headers[HtmxRequestHeaderNames.Request] = "true"; - switch (version) - { - case HtmxTargetVersion.V1: - options.UseHtmxV1(); - break; - case HtmxTargetVersion.V2: - options.UseHtmxV2(); - break; - case HtmxTargetVersion.V4: - options.UseHtmxV4(); - break; - default: - throw new ArgumentOutOfRangeException(nameof(version)); - } - - context.RequestServices = new ServiceCollection() - .AddSingleton(Options.Create(options)) - .BuildServiceProvider(); + if (boosted) + context.Request.Headers[HtmxRequestHeaderNames.Boosted] = "true"; return context; } + /// + /// Creates an HTTP context whose request services contain the toolkit + /// configuration for the specified HTMX major version. + /// + /// The HTMX major version to configure. + /// + /// The configured HTTP context. + /// + private static HttpContext CreateContext(HtmxTargetVersion version) + { + var services = new ServiceCollection(); + + services.AddHtmxToolkit(options => + { + switch (version) + { + case HtmxTargetVersion.V1: + options.UseHtmxV1(); + break; + case HtmxTargetVersion.V2: + options.UseHtmxV2(); + break; + case HtmxTargetVersion.V4: + options.UseHtmxV4(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(version)); + } + }); + + return new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider() + }; + } + /// /// Creates a with the given tag name, attributes and items. ///