diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md index 41efa6b..5413dc4 100644 --- a/docs/articles/migrate-to-v2.md +++ b/docs/articles/migrate-to-v2.md @@ -471,6 +471,8 @@ These changes affect fewer applications: case-sensitively, and ignoring leading whitespace. A value such as `OUTERHTML` now returns `null`. - `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. - `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 b398176..43969a5 100644 --- a/docs/articles/tag-helpers.md +++ b/docs/articles/tag-helpers.md @@ -202,6 +202,11 @@ 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. + ## Send custom headers ```razor @@ -224,6 +229,9 @@ 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`, +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. Authenticate and authorize on the server. @@ -266,6 +274,10 @@ The output depends on the configured HTMX target: Unsupported properties are omitted from the generated JSON. In particular, `HtmxRequestCredentials.Omit` 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. + Use `HtmxRequestCache` and `HtmxRequestRedirect` for the HTMX 4.x Fetch options: ```razor diff --git a/src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs b/src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs new file mode 100644 index 0000000..0e5b3e6 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; + +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Ramstack.HtmxToolkit.Internal; + +/// +/// Provides extension methods for . +/// +internal static class TagHelperOutputExtensions +{ + /// + /// Throws an exception if the element already has the attribute that the tag helper renders, + /// 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 tag helper attributes that render , + /// as listed in the exception message. + public static void ThrowIfAttributeDeclared(this TagHelperOutput output, string attributeName, string sourceAttributes) + { + 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] == ':')) + Error_AttributeDeclared(name, attributeName, sourceAttributes); + } + } + + [DoesNotReturn] + private static void Error_AttributeDeclared(string declaredName, string attributeName, string sourceAttributes) + { + var message = $"The {declaredName} attribute cannot be combined with {sourceAttributes}, which render {attributeName}. Remove either the {declaredName} attribute or those attributes."; + throw new InvalidOperationException(message); + } +} diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs index a80088c..7e1091c 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs @@ -6,6 +6,7 @@ using Ramstack.HtmxToolkit.Collections; using Ramstack.HtmxToolkit.Configuration; +using Ramstack.HtmxToolkit.Internal; using Ramstack.HtmxToolkit.Serialization; namespace Ramstack.HtmxToolkit.TagHelpers; @@ -23,6 +24,10 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// /// In HTMX 4.x, the append modifier merges a child declaration into inherited headers. /// +/// +/// An is thrown when the element also has an hx-headers +/// attribute, with or without HTMX 4.x modifiers, rather than replacing it. +/// /// [HtmlTargetElement(Attributes = InheritedAttributeName)] [HtmlTargetElement(Attributes = AppendAttributeName)] @@ -81,6 +86,9 @@ public IDictionary Headers /// public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { + const string SourceAttributes = $"{HeadersPrefix}*, {HeadersDictionaryName}, {InheritedAttributeName}, or {AppendAttributeName}"; + output.ThrowIfAttributeDeclared("hx-headers", SourceAttributes); + if (Headers is { Count: > 0 }) { var name = options.Value.TargetVersion switch diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs index c0db01d..de85558 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using Ramstack.HtmxToolkit.Configuration; +using Ramstack.HtmxToolkit.Internal; using Ramstack.HtmxToolkit.Serialization; namespace Ramstack.HtmxToolkit.TagHelpers; @@ -22,6 +23,11 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// Options that the selected HTMX version does not support are not rendered. /// If no supported option is set, the tag helper renders no attribute. /// +/// +/// 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. +/// /// [HtmlTargetElement(Attributes = RequestInheritedAttributeName)] [HtmlTargetElement(Attributes = RequestAppendAttributeName)] @@ -198,6 +204,9 @@ 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"); + var json = options.Value.TargetVersion == HtmxTargetVersion.V4 ? JsonSerializer.Serialize(new HtmxRequestDataV4(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataV4) : JsonSerializer.Serialize(new HtmxRequestDataPrior(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataPrior); diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs index 94587a6..70c6077 100644 --- a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs @@ -6,6 +6,7 @@ using Ramstack.HtmxToolkit.Collections; using Ramstack.HtmxToolkit.Configuration; +using Ramstack.HtmxToolkit.Internal; using Ramstack.HtmxToolkit.Serialization; namespace Ramstack.HtmxToolkit.TagHelpers; @@ -23,6 +24,10 @@ namespace Ramstack.HtmxToolkit.TagHelpers; /// /// In HTMX 4.x, the append modifier merges a child declaration into inherited values. /// +/// +/// An is thrown when the element also has an hx-vals +/// attribute, with or without HTMX 4.x modifiers, rather than replacing it. +/// /// [HtmlTargetElement(Attributes = InheritedAttributeName)] [HtmlTargetElement(Attributes = AppendAttributeName)] @@ -81,6 +86,9 @@ public IDictionary Values /// public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) { + const string SourceAttributes = $"{ValuesPrefix}*, {ValuesDictionaryName}, {InheritedAttributeName}, or {AppendAttributeName}"; + output.ThrowIfAttributeDeclared("hx-vals", SourceAttributes); + if (Values is { Count: > 0 } values) { var name = options.Value.TargetVersion switch diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs index 027c87d..216a5ef 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxHeaderTagHelperTests.cs @@ -162,6 +162,23 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa Assert.That(output.Attributes["hx-inherit"], Is.Null); } + [TestCase("hx-headers")] + [TestCase("hx-headers:inherited:append")] + public void ProcessAsync_DeclaredHxHeaders_Throws(string name) + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute(name, new HtmlString("{\"X-A\":\"1\"}")) + ]); + var helper = CreateHelper(HtmxTargetVersion.V4); + helper.Headers["X-B"] = "2"; + + var exception = Assert.ThrowsAsync( + async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output)); + + Assert.That(exception!.Message, Does.StartWith($"The {name} attribute")); + } + private static HtmxHeaderTagHelper CreateHelper(HtmxTargetVersion version = HtmxTargetVersion.V2) { var options = new HtmxToolkitOptions(); diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs index 640e993..d6bd204 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxRequestTagHelperTests.cs @@ -379,6 +379,42 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa Assert.That(output.Attributes["hx-inherit"], Is.Null); } + [TestCase(HtmxTargetVersion.V1, "hx-request")] + [TestCase(HtmxTargetVersion.V2, "hx-request")] + [TestCase(HtmxTargetVersion.V4, "hx-config")] + [TestCase(HtmxTargetVersion.V4, "hx-config:inherited")] + public void ProcessAsync_DeclaredRenderedAttribute_Throws(HtmxTargetVersion version, string name) + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute(name, new HtmlString("{\"timeout\":100}")) + ]); + var helper = CreateHelper(version); + helper.Timeout = 200; + + var exception = Assert.ThrowsAsync( + async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output)); + + Assert.That(exception!.Message, Does.StartWith($"The {name} attribute")); + } + + [TestCase(HtmxTargetVersion.V2, "hx-config", "hx-request")] + [TestCase(HtmxTargetVersion.V4, "hx-request", "hx-config")] + public async Task ProcessAsync_AttributeOfOtherVersion_IsKept(HtmxTargetVersion version, string name, string rendered) + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute(name, new HtmlString("{\"timeout\":100}")) + ]); + var helper = CreateHelper(version); + helper.Timeout = 200; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes[name]!.Value.ToString(), Is.EqualTo("{\"timeout\":100}")); + Assert.That(output.Attributes[rendered]!.Value.ToString(), Is.EqualTo("{\"timeout\":200}")); + } + private static HtmxRequestTagHelper CreateHelper(HtmxTargetVersion version, bool? implicitInheritance = null) { var options = new HtmxToolkitOptions(); diff --git a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs index ea6119d..31aebad 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/TagHelpers/HtmxValsTagHelperTests.cs @@ -157,6 +157,53 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa Assert.That(output.Attributes["hx-inherit"], Is.Null); } + [TestCase("hx-vals")] + [TestCase("HX-VALS")] + [TestCase("hx-vals:inherited")] + public void ProcessAsync_DeclaredHxVals_Throws(string name) + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute(name, new HtmlString("{\"a\":\"1\"}")) + ]); + var helper = CreateHelper(HtmxTargetVersion.V4); + helper.Values["b"] = "2"; + + var exception = Assert.ThrowsAsync( + async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output)); + + Assert.That(exception!.Message, Does.StartWith($"The {name} attribute")); + } + + [Test] + public void ProcessAsync_DeclaredHxVals_WithEmptyDictionary_Throws() + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute("hx-vals", new HtmlString("js:{time: Date.now()}")) + ]); + var helper = CreateHelper(); + + Assert.ThrowsAsync( + async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output)); + } + + [Test] + public async Task ProcessAsync_AttributeStartingWithHxVals_IsKept() + { + var output = TestHelper.CreateTagHelperOutput(attributes: + [ + new TagHelperAttribute("hx-vals-extra", "x") + ]); + var helper = CreateHelper(); + helper.Values["b"] = "2"; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes["hx-vals-extra"]!.Value, Is.EqualTo("x")); + Assert.That(output.Attributes["hx-vals"]!.Value.ToString(), Is.EqualTo("{\"b\":\"2\"}")); + } + private static HtmxValsTagHelper CreateHelper(HtmxTargetVersion version = HtmxTargetVersion.V2) { var options = new HtmxToolkitOptions();