From c2e170c324af535aa2e9610e4da779e69dec2fb8 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 2 Nov 2026 20:52:48 +0500 Subject: [PATCH 1/3] tag-helpers: reject aliases of attributes that the Tag Helpers render The conflict check looked only at the hx-* name, but HTMX also reads an alias: data-hx-* on HTMX 1.9.x and 2.x, and the configured prefix on HTMX 4.x. With both on one element, one value was silently lost. A generated hx-vals hid a hand-written data-hx-vals, and on HTMX 4.x a hand-written data-hx-vals hid a generated hx-vals:inherited or hx-vals:append. The check now covers the alias and follows HtmxV4Config.Prefix: with a custom prefix, data-hx-* stays an ordinary attribute, and an empty prefix means there is no alias. --- .../Internal/TagHelperOutputExtensions.cs | 41 +++++++++++-- .../TagHelpers/HtmxHeaderTagHelper.cs | 2 +- .../TagHelpers/HtmxRequestTagHelper.cs | 2 +- .../TagHelpers/HtmxValsTagHelper.cs | 2 +- .../TagHelpers/HtmxHeaderTagHelperTests.cs | 2 + .../TagHelpers/HtmxRequestTagHelperTests.cs | 6 ++ .../TagHelpers/HtmxValsTagHelperTests.cs | 61 ++++++++++++++++++- 7 files changed, 107 insertions(+), 9 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs b/src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs index 0e5b3e6..7058e79 100644 --- a/src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs +++ b/src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs @@ -1,7 +1,10 @@ +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Razor.TagHelpers; +using Ramstack.HtmxToolkit.Configuration; + namespace Ramstack.HtmxToolkit.Internal; /// @@ -9,28 +12,58 @@ namespace Ramstack.HtmxToolkit.Internal; /// internal static class TagHelperOutputExtensions { + private const string AttributePrefix = "hx-"; + private const string DefaultAliasPrefix = "data-hx-"; + /// /// Throws an exception if the element already has the attribute that the tag helper renders, + /// or the alias that HTMX reads as the same attribute, such as data-hx-vals, /// with or without HTMX 4.x modifiers, such as hx-vals:inherited. /// /// The tag helper output to inspect. - /// The name of the attribute that the tag helper renders, without modifiers. + /// The hx-* name of the attribute that the tag helper renders, without modifiers. /// The tag helper attributes that render , /// as listed in the exception message. - public static void ThrowIfAttributeDeclared(this TagHelperOutput output, string attributeName, string sourceAttributes) + /// The options that determine the alias prefix. + public static void ThrowIfAttributeDeclared(this TagHelperOutput output, string attributeName, string sourceAttributes, HtmxToolkitOptions options) { + Debug.Assert(attributeName is "hx-vals" or "hx-headers" or "hx-request" or "hx-config"); + + // HTMX 1.9.x and 2.x always read data-hx-*. HTMX 4.x reads the configured prefix. + var aliasPrefix = (options.HtmxConfig as HtmxV4Config)?.Prefix ?? DefaultAliasPrefix; + var suffix = attributeName.AsSpan(AttributePrefix.Length); + var attributes = output.Attributes; var count = attributes.Count; for (var i = 0; i < count; i++) { var name = attributes[i].Name; - if (name.StartsWith(attributeName, StringComparison.OrdinalIgnoreCase) - && (name.Length == attributeName.Length || name[attributeName.Length] == ':')) + if (IsDeclared(name, AttributePrefix, suffix) || IsDeclared(name, aliasPrefix, suffix)) Error_AttributeDeclared(name, attributeName, sourceAttributes); } } + /// + /// Determines whether the attribute name consists of the prefix and the suffix, optionally followed by modifiers. + /// An empty prefix never matches: for HTMX 4.x, it means that there is no alias, not that the bare suffix is one. + /// + /// The attribute name to check. + /// The expected prefix. + /// The expected attribute name without the prefix and modifiers. + /// + /// if the attribute name matches; otherwise, . + /// + private static bool IsDeclared(string name, string prefix, ReadOnlySpan suffix) + { + if (prefix.Length == 0 || !name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + + var rest = name.AsSpan(prefix.Length); + return rest.StartsWith(suffix, StringComparison.OrdinalIgnoreCase) + && (rest.Length == suffix.Length || rest[suffix.Length] == ':'); + } + [DoesNotReturn] private static void Error_AttributeDeclared(string declaredName, string attributeName, string sourceAttributes) { diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs index 7e1091c..163069f 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs @@ -87,7 +87,7 @@ public IDictionary Headers public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { const string SourceAttributes = $"{HeadersPrefix}*, {HeadersDictionaryName}, {InheritedAttributeName}, or {AppendAttributeName}"; - output.ThrowIfAttributeDeclared("hx-headers", SourceAttributes); + output.ThrowIfAttributeDeclared("hx-headers", SourceAttributes, options.Value); if (Headers is { Count: > 0 }) { diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs index de85558..af5fcce 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -205,7 +205,7 @@ public bool? Validate public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { var attributeName = options.Value.TargetVersion == HtmxTargetVersion.V4 ? "hx-config" : "hx-request"; - output.ThrowIfAttributeDeclared(attributeName, "hx-request-* attributes"); + output.ThrowIfAttributeDeclared(attributeName, "hx-request-* attributes", options.Value); var json = options.Value.TargetVersion == HtmxTargetVersion.V4 ? JsonSerializer.Serialize(new HtmxRequestDataV4(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataV4) diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs index 70c6077..ac56181 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs @@ -87,7 +87,7 @@ public IDictionary Values public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { const string SourceAttributes = $"{ValuesPrefix}*, {ValuesDictionaryName}, {InheritedAttributeName}, or {AppendAttributeName}"; - output.ThrowIfAttributeDeclared("hx-vals", SourceAttributes); + output.ThrowIfAttributeDeclared("hx-vals", SourceAttributes, options.Value); if (Values is { Count: > 0 } values) { diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs index 216a5ef..8b2e933 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs @@ -164,6 +164,8 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa [TestCase("hx-headers")] [TestCase("hx-headers:inherited:append")] + [TestCase("data-hx-headers")] + [TestCase("data-hx-headers:inherited")] public void ProcessAsync_DeclaredHxHeaders_Throws(string name) { var output = TestHelper.CreateTagHelperOutput(attributes: diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs index d6bd204..1c2b8dd 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs @@ -383,6 +383,10 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa [TestCase(HtmxTargetVersion.V2, "hx-request")] [TestCase(HtmxTargetVersion.V4, "hx-config")] [TestCase(HtmxTargetVersion.V4, "hx-config:inherited")] + [TestCase(HtmxTargetVersion.V1, "data-hx-request")] + [TestCase(HtmxTargetVersion.V2, "data-hx-request")] + [TestCase(HtmxTargetVersion.V4, "data-hx-config")] + [TestCase(HtmxTargetVersion.V4, "data-hx-config:inherited")] public void ProcessAsync_DeclaredRenderedAttribute_Throws(HtmxTargetVersion version, string name) { var output = TestHelper.CreateTagHelperOutput(attributes: @@ -400,6 +404,8 @@ public void ProcessAsync_DeclaredRenderedAttribute_Throws(HtmxTargetVersion vers [TestCase(HtmxTargetVersion.V2, "hx-config", "hx-request")] [TestCase(HtmxTargetVersion.V4, "hx-request", "hx-config")] + [TestCase(HtmxTargetVersion.V2, "data-hx-config", "hx-request")] + [TestCase(HtmxTargetVersion.V4, "data-hx-request", "hx-config")] public async Task ProcessAsync_AttributeOfOtherVersion_IsKept(HtmxTargetVersion version, string name, string rendered) { var output = TestHelper.CreateTagHelperOutput(attributes: diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs index 31aebad..3701f18 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs @@ -175,6 +175,63 @@ public void ProcessAsync_DeclaredHxVals_Throws(string name) Assert.That(exception!.Message, Does.StartWith($"The {name} attribute")); } + [TestCase(HtmxTargetVersion.V1, "data-hx-vals")] + [TestCase(HtmxTargetVersion.V2, "data-hx-vals")] + [TestCase(HtmxTargetVersion.V4, "data-hx-vals")] + [TestCase(HtmxTargetVersion.V4, "DATA-HX-VALS")] + [TestCase(HtmxTargetVersion.V4, "data-hx-vals:inherited")] + public void ProcessAsync_DeclaredAlias_Throws(HtmxTargetVersion version, string name) + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute(name, new HtmlString("{\"a\":\"1\"}")) + ]); + var helper = CreateHelper(version); + helper.Values["b"] = "2"; + + var exception = Assert.ThrowsAsync( + async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output)); + + Assert.That(exception!.Message, Does.StartWith($"The {name} attribute")); + } + + [TestCase("x-vals")] + [TestCase("x-vals:append")] + public void ProcessAsync_DeclaredAliasWithCustomPrefix_Throws(string name) + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute(name, new HtmlString("{\"a\":\"1\"}")) + ]); + var helper = CreateHelper(HtmxTargetVersion.V4, prefix: "x-"); + helper.Values["b"] = "2"; + + var exception = Assert.ThrowsAsync( + async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output)); + + Assert.That(exception!.Message, Does.StartWith($"The {name} attribute")); + } + + [TestCase("x-", "data-hx-vals")] + [TestCase("", "data-hx-vals")] + [TestCase("", "vals")] + [TestCase(null, "x-vals")] + [TestCase(null, "data-hx-vals-extra")] + public async Task ProcessAsync_AttributeThatIsNotAlias_IsKept(string? prefix, string name) + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute(name, "x") + ]); + var helper = CreateHelper(HtmxTargetVersion.V4, prefix); + helper.Values["b"] = "2"; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes[name]!.Value, Is.EqualTo("x")); + Assert.That(output.Attributes["hx-vals"]!.Value.ToString(), Is.EqualTo("{\"b\":\"2\"}")); + } + [Test] public void ProcessAsync_DeclaredHxVals_WithEmptyDictionary_Throws() { @@ -204,7 +261,7 @@ public async Task ProcessAsync_AttributeStartingWithHxVals_IsKept() Assert.That(output.Attributes["hx-vals"]!.Value.ToString(), Is.EqualTo("{\"b\":\"2\"}")); } - private static HtmxValsTagHelper CreateHelper(HtmxTargetVersion version = HtmxTargetVersion.V2) + private static HtmxValsTagHelper CreateHelper(HtmxTargetVersion version = HtmxTargetVersion.V2, string? prefix = null) { var options = new HtmxToolkitOptions(); @@ -217,7 +274,7 @@ private static HtmxValsTagHelper CreateHelper(HtmxTargetVersion version = HtmxTa options.UseHtmxV2(); break; case HtmxTargetVersion.V4: - options.UseHtmxV4(); + options.UseHtmxV4(config => config.Prefix = prefix); break; default: throw new ArgumentOutOfRangeException(nameof(version)); From f8061b6a0745e4ef7759071692030d77b35a9a20 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 2 Nov 2026 21:26:05 +0500 Subject: [PATCH 2/3] tag-helpers: document the conflict with attribute aliases --- docs/articles/migrate-to-v2.md | 2 +- docs/articles/tag-helpers.md | 13 +++++++------ .../TagHelpers/HtmxHeaderTagHelper.cs | 4 +++- .../TagHelpers/HtmxRequestTagHelper.cs | 5 +++-- .../TagHelpers/HtmxValsTagHelper.cs | 4 +++- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index e5e0ba2..578b10e 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -479,7 +479,7 @@ These changes affect fewer applications: - `HtmxHeaderTagHelper.Headers` changed from `Dictionary` to `IDictionary`, and header names are compared without case. - `hx-header-*` and `hx-all-headers` throw `InvalidOperationException` when the element also has a hand-written - `hx-headers`. In 1.x, the generated attribute silently replaced it. + `hx-headers` or `data-hx-headers`. In 1.x, the hand-written value was silently lost. - `HtmxScrollBehavior` gained `Instant`. - `Location`, `Redirect`, `Reswap(string)`, `Retarget`, and `Reselect` throw `ArgumentException` for a `null` or empty value, and `Location(string, HtmxLocationOptions)` also throws for `null` options. diff --git a/docs/articles/tag-helpers.md b/docs/articles/tag-helpers.md index 43969a5..8ec994d 100644 --- a/docs/articles/tag-helpers.md +++ b/docs/articles/tag-helpers.md @@ -203,9 +203,10 @@ object entirely. Add `hx-vals-append="true"` to merge the child object instead: The button emits `hx-vals:append='{"format":"summary"}'`. An element with `hx-val-*`, `hx-all-vals`, `hx-vals-inherited`, or `hx-vals-append` cannot also have -a hand-written `hx-vals`, with or without modifiers. The Tag Helper throws `InvalidOperationException` -rather than replace it. To combine `hx-val-*` with values computed in JavaScript, put the hand-written -`hx-vals` on a parent element and rely on the inheritance rules above. +a hand-written `hx-vals` or `data-hx-vals`, with or without modifiers. HTMX reads both names as one attribute, +so one of the values would be lost; the Tag Helper throws `InvalidOperationException` instead. +For HTMX 4.x, `HtmxV4Config.Prefix` determines the second name. To combine `hx-val-*` with values computed +in JavaScript, put the hand-written `hx-vals` on a parent element and rely on the inheritance rules above. ## Send custom headers @@ -229,7 +230,7 @@ For HTMX 4.x, use the Toolkit input `hx-headers-inherited="true"` on a parent el This generates `hx-headers:inherited`. Use `hx-headers-append="true"` on a child declaration to merge its headers into the inherited object. -As with `hx-vals`, an element with these inputs cannot also have a hand-written `hx-headers`, +As with `hx-vals`, an element with these inputs cannot also have a hand-written `hx-headers` or `data-hx-headers`, with or without modifiers; the Tag Helper throws `InvalidOperationException`. Do not use custom client headers as proof of identity or authorization; clients can modify them. @@ -275,8 +276,8 @@ Unsupported properties are omitted from the generated JSON. In particular, `Htmx cannot be represented by HTMX 1.9.x or 2.x and is omitted for those targets. An element with `hx-request-*` attributes cannot also have a hand-written `hx-request` for HTMX 1.9.x and 2.x -or `hx-config` for HTMX 4.x, with or without modifiers. The Tag Helper throws `InvalidOperationException` -rather than replace it. +or `hx-config` for HTMX 4.x, with or without modifiers or the `data-hx-` prefix. +The Tag Helper throws `InvalidOperationException` rather than lose one of the values. Use `HtmxRequestCache` and `HtmxRequestRedirect` for the HTMX 4.x Fetch options: diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs index 163069f..a34b4e8 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs @@ -26,7 +26,9 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// /// /// An is thrown when the element also has an hx-headers -/// attribute, with or without HTMX 4.x modifiers, rather than replacing it. +/// attribute, or the data-hx-headers alias that HTMX reads as the same attribute, with or without +/// HTMX 4.x modifiers, rather than losing one of the values. For HTMX 4.x, +/// determines the alias. /// /// [HtmlTargetElement(Attributes = InheritedAttributeName)] diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs index af5fcce..d6c46bf 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -25,8 +25,9 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// /// /// An is thrown when the element also has the attribute -/// that the tag helper renders for the selected version, with or without HTMX 4.x modifiers, -/// rather than replacing it. +/// that the tag helper renders for the selected version, or its data-hx-* alias that HTMX reads +/// as the same attribute, with or without HTMX 4.x modifiers, rather than losing one of the values. +/// For HTMX 4.x, determines the alias. /// /// [HtmlTargetElement(Attributes = RequestInheritedAttributeName)] diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs index ac56181..9299633 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs @@ -26,7 +26,9 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// /// /// An is thrown when the element also has an hx-vals -/// attribute, with or without HTMX 4.x modifiers, rather than replacing it. +/// attribute, or the data-hx-vals alias that HTMX reads as the same attribute, with or without +/// HTMX 4.x modifiers, rather than losing one of the values. For HTMX 4.x, +/// determines the alias. /// /// [HtmlTargetElement(Attributes = InheritedAttributeName)] From 4190243de901025719c4c324c7e4d42e48b43a80 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 2 Nov 2026 21:27:35 +0500 Subject: [PATCH 3/3] config: document that an empty Prefix disables the alias --- src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs b/src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs index 578dd2b..a2f8720 100644 --- a/src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs +++ b/src/Ramstack.HtmxToolkit/Configuration/HtmxV4Config.cs @@ -24,6 +24,11 @@ public bool? LogAll /// Gets or sets the secondary attribute prefix recognized alongside hx-*. /// The HTMX default is data-hx-. /// + /// + /// An empty string disables the secondary prefix, so HTMX reads only hx-*. This helps when the application + /// renders user-supplied HTML through a sanitizer that allows data-* attributes, + /// which would otherwise let data-hx-* attributes reach HTMX. + /// public string? Prefix { get;