From 03558ac8944454d66d20795d970a77e454217b61 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 10 Oct 2026 18:12:21 +0500 Subject: [PATCH 1/7] tag-helpers: type the request cache mode Change HtmxRequestTagHelper.Cache from a string to the new HtmxRequestCache enum and map each value to the Fetch RequestCache string for HTMX 4. The Fetch API rejects unknown cache modes, so a typo in hx-request-cache surfaced only in the browser. The enum moves the check to compile time and matches the existing Credentials option. --- .../Pages/Examples/RequestOptions.cshtml | 1 + src/Ramstack.HtmxToolkit/HtmxRequestCache.cs | 42 +++++++++++++++++++ .../TagHelpers/HtmxRequestTagHelper.cs | 15 +++++-- .../TagHelpers/HtmxRequestTagHelperTests.cs | 20 ++++++++- 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 src/Ramstack.HtmxToolkit/HtmxRequestCache.cs diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml index 8cbd6a4..c2cad48 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml @@ -22,6 +22,7 @@ hx-page="/Examples/RequestOptions" hx-page-handler="Preview" hx-request-timeout="2000" + hx-request-cache="@HtmxRequestCache.NoCache" hx-target="#preview-result"> Generate report preview diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestCache.cs b/src/Ramstack.HtmxToolkit/HtmxRequestCache.cs new file mode 100644 index 0000000..7ed96bf --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxRequestCache.cs @@ -0,0 +1,42 @@ +namespace Ramstack.HtmxToolkit; + +/// +/// Specifies the Fetch cache mode for an HTMX request. +/// +/// Supported only in HTMX 4.x. +public enum HtmxRequestCache +{ + /// + /// Uses the browser's normal HTTP cache behavior. + /// + Default, + + /// + /// Bypasses the cache and does not store the response in it. + /// + NoStore, + + /// + /// Bypasses the cache lookup and updates the cache with the response. + /// + Reload, + + /// + /// Revalidates a cached response before using it. + /// + NoCache, + + /// + /// Uses a cached response even if stale, or fetches and caches a response on a cache miss. + /// + ForceCache, + + /// + /// Uses a cached response even if stale, and fails on a cache miss. + /// + /// + /// Requires the same-origin Fetch mode, which is the HTMX default. + /// The request fails if specifies another mode. + /// + OnlyIfCached +} diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs index e4f2692..f295686 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -121,7 +121,7 @@ public bool? NoHeaders /// /// Supported only in HTMX 4.x. [HtmlAttributeName(RequestCacheAttributeName)] - public string? Cache + public HtmxRequestCache? Cache { get => _request.Cache; set => _request.Cache = value; @@ -217,7 +217,7 @@ internal sealed class HtmxRequestData public bool? NoHeaders { get; set; } /// - public string? Cache { get; set; } + public HtmxRequestCache? Cache { get; set; } /// public string? Redirect { get; set; } @@ -286,7 +286,16 @@ internal readonly struct HtmxRequestDataV4(HtmxRequestData data) }; /// - public string? Cache => data.Cache; + public string? Cache => data.Cache switch + { + HtmxRequestCache.Default => "default", + HtmxRequestCache.NoStore => "no-store", + HtmxRequestCache.Reload => "reload", + HtmxRequestCache.NoCache => "no-cache", + HtmxRequestCache.ForceCache => "force-cache", + HtmxRequestCache.OnlyIfCached => "only-if-cached", + _ => null + }; /// public string? Redirect => data.Redirect; diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs index 8ce52d5..394f521 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs @@ -93,7 +93,7 @@ public async Task ProcessAsync_SerializesHtmx4RequestConfiguration() helper.Timeout = 500; helper.Credentials = HtmxRequestCredentials.Omit; helper.NoHeaders = true; - helper.Cache = "no-cache"; + helper.Cache = HtmxRequestCache.NoCache; helper.Redirect = "manual"; helper.Referrer = "no-referrer"; helper.Integrity = "sha384-example"; @@ -136,6 +136,24 @@ public async Task ProcessAsync_SerializesHtmx4CredentialsMode(HtmxRequestCredent Assert.That(json["credentials"].GetString(), Is.EqualTo(expected)); } + [TestCase(HtmxRequestCache.Default, "default")] + [TestCase(HtmxRequestCache.NoStore, "no-store")] + [TestCase(HtmxRequestCache.Reload, "reload")] + [TestCase(HtmxRequestCache.NoCache, "no-cache")] + [TestCase(HtmxRequestCache.ForceCache, "force-cache")] + [TestCase(HtmxRequestCache.OnlyIfCached, "only-if-cached")] + public async Task ProcessAsync_SerializesHtmx4CacheMode(HtmxRequestCache cache, string expected) + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + helper.Cache = cache; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes["hx-config"]!.Value.ToString(), Is.EqualTo("{\"cache\":\"" + expected + "\"}")); + Assert.That(output.Attributes["hx-request"], Is.Null); + } + [Test] public async Task ProcessAsync_OmitsHtmx4OnlyCredentialsModeForPriorVersions() { From 7f8238c5595dcc9699ab99d33deeb21d6e331372 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 10 Oct 2026 18:17:43 +0500 Subject: [PATCH 2/7] tag-helpers: type the request redirect mode Change HtmxRequestTagHelper.Redirect from a string to the new HtmxRequestRedirect enum and map each value to the Fetch RequestRedirect string for HTMX 4. --- .../Pages/Examples/RequestOptions.cshtml | 5 +- .../HtmxRequestRedirect.cs | 31 ++++++++++ .../TagHelpers/HtmxRequestTagHelper.cs | 12 +++- .../TagHelpers/HtmxRequestTagHelperTests.cs | 60 ++++++++++++++++++- 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/Ramstack.HtmxToolkit/HtmxRequestRedirect.cs diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml index c2cad48..273593b 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/RequestOptions.cshtml @@ -13,8 +13,8 @@
-

hx-request-timeout

-

The toolkit emits an HTMX 4 hx-config timeout for this report preview.

+

hx-request-*

+

The toolkit emits an HTMX 4 hx-config with a timeout, cache mode, and redirect mode for this report preview.

@@ -23,6 +23,7 @@ hx-page-handler="Preview" hx-request-timeout="2000" hx-request-cache="@HtmxRequestCache.NoCache" + hx-request-redirect="@HtmxRequestRedirect.Error" hx-target="#preview-result"> Generate report preview diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestRedirect.cs b/src/Ramstack.HtmxToolkit/HtmxRequestRedirect.cs new file mode 100644 index 0000000..a705a40 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxRequestRedirect.cs @@ -0,0 +1,31 @@ +namespace Ramstack.HtmxToolkit; + +/// +/// Specifies the Fetch redirect mode for an HTMX request. +/// +/// Supported only in HTMX 4.x. +public enum HtmxRequestRedirect +{ + /// + /// Follows redirects automatically. + /// + Follow, + + /// + /// Fails the request when a redirect is encountered. + /// + /// + /// HTMX raises the htmx:error event and does not swap the response. + /// + Error, + + /// + /// Returns an opaque redirect response instead of following the redirect. + /// + /// + /// The response has status 0, and its headers and body are not accessible. + /// HTMX cannot read HX-* response headers, does not treat the response as an error, + /// and swaps the empty body into the target. + /// + Manual +} diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs index f295686..21e5b2c 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -132,7 +132,7 @@ public HtmxRequestCache? Cache /// /// Supported only in HTMX 4.x. [HtmlAttributeName(RequestRedirectAttributeName)] - public string? Redirect + public HtmxRequestRedirect? Redirect { get => _request.Redirect; set => _request.Redirect = value; @@ -220,7 +220,7 @@ internal sealed class HtmxRequestData public HtmxRequestCache? Cache { get; set; } /// - public string? Redirect { get; set; } + public HtmxRequestRedirect? Redirect { get; set; } /// public string? Referrer { get; set; } @@ -298,7 +298,13 @@ internal readonly struct HtmxRequestDataV4(HtmxRequestData data) }; /// - public string? Redirect => data.Redirect; + public string? Redirect => data.Redirect switch + { + HtmxRequestRedirect.Follow => "follow", + HtmxRequestRedirect.Error => "error", + HtmxRequestRedirect.Manual => "manual", + _ => null + }; /// public string? Referrer => data.Referrer; diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs index 394f521..7a309dd 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs @@ -94,7 +94,7 @@ public async Task ProcessAsync_SerializesHtmx4RequestConfiguration() helper.Credentials = HtmxRequestCredentials.Omit; helper.NoHeaders = true; helper.Cache = HtmxRequestCache.NoCache; - helper.Redirect = "manual"; + helper.Redirect = HtmxRequestRedirect.Manual; helper.Referrer = "no-referrer"; helper.Integrity = "sha384-example"; helper.Validate = true; @@ -154,6 +154,64 @@ public async Task ProcessAsync_SerializesHtmx4CacheMode(HtmxRequestCache cache, Assert.That(output.Attributes["hx-request"], Is.Null); } + [TestCase(HtmxRequestRedirect.Follow, "follow")] + [TestCase(HtmxRequestRedirect.Error, "error")] + [TestCase(HtmxRequestRedirect.Manual, "manual")] + public async Task ProcessAsync_SerializesHtmx4RedirectMode(HtmxRequestRedirect redirect, string expected) + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + helper.Redirect = redirect; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes["hx-config"]!.Value.ToString(), Is.EqualTo("{\"redirect\":\"" + expected + "\"}")); + Assert.That(output.Attributes["hx-request"], Is.Null); + } + + [Test] + public async Task ProcessAsync_Htmx4_OmitsClearedCacheAndRedirectModes() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + helper.Timeout = 500; + helper.Cache = HtmxRequestCache.Default; + helper.Redirect = HtmxRequestRedirect.Follow; + helper.Cache = null; + helper.Redirect = null; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes["hx-config"]!.Value.ToString(), Is.EqualTo("{\"timeout\":500}")); + } + + [TestCase(HtmxTargetVersion.V1)] + [TestCase(HtmxTargetVersion.V2)] + public async Task ProcessAsync_OmitsCacheAndRedirectModesForPriorVersions(HtmxTargetVersion version) + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(version); + helper.Timeout = 500; + helper.Cache = HtmxRequestCache.NoCache; + helper.Redirect = HtmxRequestRedirect.Manual; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes["hx-request"]!.Value.ToString(), Is.EqualTo("{\"timeout\":500}")); + Assert.That(output.Attributes["hx-config"], Is.Null); + } + + [Test] + public async Task ProcessAsync_Htmx4_OmitsUnsetConfiguration() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = CreateHelper(HtmxTargetVersion.V4); + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes, Is.Empty); + } + [Test] public async Task ProcessAsync_OmitsHtmx4OnlyCredentialsModeForPriorVersions() { From 7e72d05e1930a48cd56dc8a23a96ccac021b4bdf Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 10 Oct 2026 18:20:41 +0500 Subject: [PATCH 3/7] config: separate the response handling swap style and expression Change ResponseHandlingConfig.SwapOverride to HtmxSwap? and add SwapOverrideExpression for complete expressions with modifiers, following the existing Reswap and ReswapExpression pair. --- .../Configuration/ResponseHandlingConfig.cs | 24 +++++- .../TagHelpers/HtmxConfigTagHelperTests.cs | 80 ++++++++++++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/Configuration/ResponseHandlingConfig.cs b/src/Ramstack.HtmxToolkit/Configuration/ResponseHandlingConfig.cs index 6383234..1d74066 100644 --- a/src/Ramstack.HtmxToolkit/Configuration/ResponseHandlingConfig.cs +++ b/src/Ramstack.HtmxToolkit/Configuration/ResponseHandlingConfig.cs @@ -1,3 +1,7 @@ +using System.Text.Json.Serialization; + +using Ramstack.HtmxToolkit.Internal; + namespace Ramstack.HtmxToolkit.Configuration; /// @@ -37,7 +41,23 @@ public sealed class ResponseHandlingConfig public string? Target { get; set; } /// - /// Gets or sets an alternative swap mechanism for the response. + /// Gets or sets an alternative swap style for the response. + /// + /// + /// Reads the style from , or returns + /// if the style is not recognized. Assigning a style replaces the complete expression, + /// including any modifiers. Assigning clears the override. + /// + [JsonIgnore] + public HtmxSwap? SwapOverride + { + get => EnumHelper.ParseHtmxSwap(SwapOverrideExpression); + set => SwapOverrideExpression = value.GetSwapValue(); + } + + /// + /// Gets or sets the complete swap override expression, including any swap modifiers. /// - public string? SwapOverride { get; set; } + [JsonPropertyName("swapOverride")] + public string? SwapOverrideExpression { get; set; } } diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxConfigTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxConfigTagHelperTests.cs index 4bdd3c8..996bc99 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxConfigTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxConfigTagHelperTests.cs @@ -91,7 +91,7 @@ public async Task ProcessAsync_SerializesFullResponseHandlingConfig() IgnoreTitle = true, Select = "#content", Target = "#target", - SwapOverride = "innerHTML" + SwapOverride = HtmxSwap.InnerHtml } ]); @@ -110,6 +110,84 @@ public async Task ProcessAsync_SerializesFullResponseHandlingConfig() }); } + [TestCase(HtmxSwap.InnerHtml, "innerHTML")] + [TestCase(HtmxSwap.OuterHtml, "outerHTML")] + [TestCase(HtmxSwap.None, "none")] + public async Task ProcessAsync_SerializesResponseHandlingSwapOverride(HtmxSwap swap, string expected) + { + var options = new HtmxToolkitOptions(); + options.UseHtmxV2(config => + config.ResponseHandling = [new() { SwapOverride = swap }]); + + var json = await RenderJson(options); + + Assert.That( + json["responseHandling"].GetRawText(), + Is.EqualTo("[{\"swapOverride\":\"" + expected + "\"}]")); + } + + [TestCase("outerHTML settle:200ms", HtmxSwap.OuterHtml)] + [TestCase("customSwap settle:200ms", null)] + public async Task ProcessAsync_SerializesResponseHandlingSwapOverrideExpression(string expression, HtmxSwap? expectedStyle) + { + var handlingConfig = new ResponseHandlingConfig + { + SwapOverride = HtmxSwap.InnerHtml, + SwapOverrideExpression = expression + }; + + var options = new HtmxToolkitOptions(); + options.UseHtmxV2(config => config.ResponseHandling = [handlingConfig]); + + var json = await RenderJson(options); + + Assert.That(handlingConfig.SwapOverride, Is.EqualTo(expectedStyle)); + Assert.That( + json["responseHandling"].GetRawText(), + Is.EqualTo("[{\"swapOverride\":\"" + expression + "\"}]")); + } + + [Test] + public async Task ProcessAsync_ResponseHandlingTypedSwapOverride_ReplacesExpression() + { + var handlingConfig = new ResponseHandlingConfig + { + SwapOverrideExpression = "innerHTML settle:200ms", + SwapOverride = HtmxSwap.OuterHtml + }; + + var options = new HtmxToolkitOptions(); + options.UseHtmxV2(config => config.ResponseHandling = [handlingConfig]); + + var json = await RenderJson(options); + + Assert.That(handlingConfig.SwapOverrideExpression, Is.EqualTo("outerHTML")); + Assert.That( + json["responseHandling"].GetRawText(), + Is.EqualTo("[{\"swapOverride\":\"outerHTML\"}]")); + } + + [TestCase(false)] + [TestCase(true)] + public async Task ProcessAsync_OmitsClearedResponseHandlingSwapOverride(bool clearExpression) + { + var handlingConfig = new ResponseHandlingConfig { SwapOverrideExpression = "outerHTML settle:200ms" }; + + if (clearExpression) + handlingConfig.SwapOverrideExpression = null; + else + handlingConfig.SwapOverride = null; + + var options = new HtmxToolkitOptions(); + options.UseHtmxV2(config => config.ResponseHandling = [handlingConfig]); + + var json = await RenderJson(options); + + Assert.That(handlingConfig.SwapOverride, Is.Null); + Assert.That(handlingConfig.SwapOverrideExpression, Is.Null); + Assert.That(json["responseHandling"].GetRawText(), Is.EqualTo("[{}]")); + } + [Test] public async Task ProcessAsync_SerializesOnlyHtmx1Options() { From 2c5668a292ee1fe0ab7c4345bd456ba5de0d1d92 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 10 Oct 2026 19:14:54 +0500 Subject: [PATCH 4/7] config: remove WsReconnectDelay The built-in WebSocket support in HTMX 1.9 and the ws extension for HTMX 2 accept only a function or "full-jitter" for wsReconnectDelay. "full-jitter" is already the default, and a function cannot be passed through JSON, so the property could only repeat the default or make the client log an error instead of computing a reconnection delay. --- .../Configuration/HtmxV1Config.cs | 10 ---------- .../Configuration/HtmxV2Config.cs | 11 ----------- .../TagHelpers/HtmxConfigTagHelperTests.cs | 6 ++---- 3 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/Configuration/HtmxV1Config.cs b/src/Ramstack.HtmxToolkit/Configuration/HtmxV1Config.cs index d9aa4d2..1bd2dfc 100644 --- a/src/Ramstack.HtmxToolkit/Configuration/HtmxV1Config.cs +++ b/src/Ramstack.HtmxToolkit/Configuration/HtmxV1Config.cs @@ -182,16 +182,6 @@ public bool? UseTemplateFragments set => SetField(ref field, value); } - /// - /// Gets or sets the WebSocket reconnection delay strategy. - /// The HTMX default is full-jitter. - /// - public string? WsReconnectDelay - { - get; - set => SetField(ref field, value); - } - /// /// Gets or sets the type of binary data received over WebSocket connections. /// The HTMX default is . diff --git a/src/Ramstack.HtmxToolkit/Configuration/HtmxV2Config.cs b/src/Ramstack.HtmxToolkit/Configuration/HtmxV2Config.cs index ddcceae..ea56299 100644 --- a/src/Ramstack.HtmxToolkit/Configuration/HtmxV2Config.cs +++ b/src/Ramstack.HtmxToolkit/Configuration/HtmxV2Config.cs @@ -182,17 +182,6 @@ public string[]? AttributesToSettle set => SetField(ref field, value); } - /// - /// Gets or sets the WebSocket reconnection delay strategy. - /// The HTMX default is full-jitter. - /// - /// Requires the HTMX WebSocket extension. - public string? WsReconnectDelay - { - get; - set => SetField(ref field, value); - } - /// /// Gets or sets the type of binary data received over WebSocket connections. /// The HTMX default is . diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxConfigTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxConfigTagHelperTests.cs index 996bc99..1b0f64f 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxConfigTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxConfigTagHelperTests.cs @@ -211,7 +211,6 @@ public async Task ProcessAsync_SerializesOnlyHtmx1Options() htmx.InlineScriptNonce = "script-nonce"; htmx.AttributesToSettle = ["class", "style"]; htmx.UseTemplateFragments = true; - htmx.WsReconnectDelay = "exponential"; htmx.WsBinaryType = HtmxBinaryType.ArrayBuffer; htmx.DisableSelector = "[data-disable]"; htmx.WithCredentials = true; @@ -235,7 +234,7 @@ public async Task ProcessAsync_SerializesOnlyHtmx1Options() "defaultSwapDelay", "defaultSettleDelay", "includeIndicatorStyles", "indicatorClass", "requestClass", "addedClass", "swappingClass", "settlingClass", "allowEval", "allowScriptTags", "inlineScriptNonce", "attributesToSettle", "useTemplateFragments", - "wsReconnectDelay", "wsBinaryType", "disableSelector", "withCredentials", "timeout", + "wsBinaryType", "disableSelector", "withCredentials", "timeout", "selfRequestsOnly", "scrollBehavior", "defaultFocusScroll", "getCacheBusterParam", "globalViewTransitions", "methodsThatUseUrlParams", "ignoreTitle", "scrollIntoViewOnBoost", "triggerSpecsCache" @@ -270,7 +269,6 @@ public async Task ProcessAsync_SerializesOnlyHtmx2Options_InResponseHandlingOrde htmx.InlineScriptNonce = "script-nonce"; htmx.InlineStyleNonce = "style-nonce"; htmx.AttributesToSettle = ["class", "style"]; - htmx.WsReconnectDelay = "exponential"; htmx.WsBinaryType = HtmxBinaryType.Blob; htmx.DisableSelector = "[data-disable]"; htmx.WithCredentials = true; @@ -303,7 +301,7 @@ public async Task ProcessAsync_SerializesOnlyHtmx2Options_InResponseHandlingOrde "defaultSwapDelay", "defaultSettleDelay", "includeIndicatorStyles", "indicatorClass", "requestClass", "addedClass", "swappingClass", "settlingClass", "allowEval", "allowScriptTags", "inlineScriptNonce", "inlineStyleNonce", "attributesToSettle", - "wsReconnectDelay", "wsBinaryType", "disableSelector", "withCredentials", + "wsBinaryType", "disableSelector", "withCredentials", "disableInheritance", "timeout", "selfRequestsOnly", "scrollBehavior", "defaultFocusScroll", "getCacheBusterParam", "globalViewTransitions", "methodsThatUseUrlParams", "ignoreTitle", "scrollIntoViewOnBoost", "triggerSpecsCache", "responseHandling", "allowNestedOobSwaps", From e84d86e024f889c8eda957354ebdf088f3cca3d8 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 10 Oct 2026 19:17:21 +0500 Subject: [PATCH 5/7] config: clarify the HtmxV4Config.Extensions format --- src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs b/src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs index ca3bb1f..3ffeab4 100644 --- a/src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs +++ b/src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs @@ -121,9 +121,13 @@ public string? InlineScriptNonce } /// - /// Gets or sets a comma-separated list of extensions that HTMX is allowed to load. - /// The HTMX default is an empty string. + /// Gets or sets a comma-separated list of extensions that HTMX is allowed to register. + /// The HTMX default is an empty string, which allows all extensions. /// + /// + /// HTMX compares extension names exactly. Whitespace is allowed only after a comma; + /// any other whitespace becomes part of a name, so "sse ,ws" does not allow sse. + /// public string? Extensions { get; From b395d3548581d8833344cf62c6976c1c7adc59cb Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 10 Oct 2026 19:21:31 +0500 Subject: [PATCH 6/7] http: rename the TriggerEvent timing parameter Rename the trigger parameter of TriggerEvent(string, HtmxTriggerTiming) to timing to match the other TriggerEvent overloads. --- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 8885b63..88fe4a0 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -197,10 +197,10 @@ public HtmxResponse Reselect(string value) => SetHeader(this, HtmxResponseHeaderNames.Reselect, value); /// - /// Adds a client-side event to the response header selected by . + /// Adds a client-side event to the response header selected by . /// /// The event name to trigger. - /// The event timing. Defaults to . + /// The event timing. Defaults to . /// /// The current instance. /// @@ -209,8 +209,8 @@ public HtmxResponse Reselect(string value) => /// HX-Trigger and runs when the request completes (after the swap whenever one is performed). /// See PR #3900. /// - public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = HtmxTriggerTiming.Receive) => - AddToPendingEvent(this, eventName, "{}", trigger); + public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) => + AddToPendingEvent(this, eventName, "{}", timing); /// /// Adds a client-side event and its detail to the response header selected by From 5478616f6c8cbd718c7b404f2c9575850cef377a Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 10 Oct 2026 20:59:37 +0500 Subject: [PATCH 7/7] http: separate the HX-Location swap style and expression Add HtmxLocationOptions.SwapExpression for complete expressions with swap modifiers. --- .../HtmxLocationOptions.cs | 21 ++++++- .../HtmxLocationOptionsJsonView.cs | 5 +- .../HtmxResponseTests.cs | 61 +++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs index 25bb455..c5d64df 100644 --- a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs +++ b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs @@ -1,3 +1,5 @@ +using Ramstack.HtmxToolkit.Internal; + namespace Ramstack.HtmxToolkit; /// @@ -20,8 +22,25 @@ public sealed class HtmxLocationOptions /// /// Gets or sets how the response will be swapped relative to the target element. /// + /// + /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. + /// + /// Reads the style from , or returns + /// if the style is not recognized. Assigning a style replaces the complete expression, + /// including any modifiers. Assigning clears the swap. + /// + /// + public HtmxSwap? Swap + { + get => EnumHelper.ParseHtmxSwap(SwapExpression); + set => SwapExpression = value.GetSwapValue(); + } + + /// + /// Gets or sets the complete swap expression, including any swap modifiers. + /// /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. - public HtmxSwap? Swap { get; set; } + public string? SwapExpression { get; set; } /// /// Gets or sets the form field values to submit with the request. diff --git a/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonView.cs b/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonView.cs index 26df092..4fae8cd 100644 --- a/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonView.cs +++ b/src/Ramstack.HtmxToolkit/Serialization/HtmxLocationOptionsJsonView.cs @@ -25,10 +25,9 @@ internal readonly struct HtmxLocationOptionsJsonView(string path, HtmxLocationOp public string? Target => options.Target; /// - /// Gets the serialized swap style. + /// Gets the swap expression. /// - [JsonConverter(typeof(HtmxSwapJsonConverter))] - public HtmxSwap? Swap => options.Swap; + public string? Swap => options.SwapExpression; /// /// Gets the form field values. diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 05f6d5e..d6b1aea 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -54,6 +54,67 @@ public void Location_WithOptions_OmitsNullProperties() Assert.That(json.ContainsKey("swap"), Is.False); } + [TestCase("outerHTML settle:200ms", HtmxSwap.OuterHtml)] + [TestCase("customSwap settle:200ms", null)] + public void Location_WithSwapExpression_SerializesExpression(string expression, HtmxSwap? expectedStyle) + { + var context = TestHelper.CreateHtmxRequestContext(); + var options = new HtmxLocationOptions + { + Swap = HtmxSwap.InnerHtml, + SwapExpression = expression + }; + + context.Response.Htmx(r => r.Location("/bar", options)); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(options.Swap, Is.EqualTo(expectedStyle)); + Assert.That(json["swap"].GetString(), Is.EqualTo(expression)); + } + + [Test] + public void Location_WithTypedSwap_ReplacesExpression() + { + var context = TestHelper.CreateHtmxRequestContext(); + var options = new HtmxLocationOptions + { + SwapExpression = "innerHTML settle:200ms", + Swap = HtmxSwap.OuterHtml + }; + + context.Response.Htmx(r => r.Location("/bar", options)); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(options.SwapExpression, Is.EqualTo("outerHTML")); + Assert.That(json["swap"].GetString(), Is.EqualTo("outerHTML")); + } + + [TestCase(false)] + [TestCase(true)] + public void Location_WithClearedSwap_OmitsSwap(bool clearExpression) + { + var context = TestHelper.CreateHtmxRequestContext(); + var options = new HtmxLocationOptions { SwapExpression = "outerHTML settle:200ms" }; + + if (clearExpression) + options.SwapExpression = null; + else + options.Swap = null; + + context.Response.Htmx(r => r.Location("/bar", options)); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(options.Swap, Is.Null); + Assert.That(options.SwapExpression, Is.Null); + Assert.That(json.ContainsKey("swap"), Is.False); + } + [Test] public void Location_WithOptions_SerializesValuesAndHeaders() {