From aa1d776702494fbb916311f4a189a0fc551377b8 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 29 Aug 2026 21:58:52 +0500 Subject: [PATCH 01/14] Replace object with strongly-typed value in AjaxContext.Values --- src/Ramstack.HtmxToolkit/AjaxContext.cs | 2 +- .../AjaxContextJsonSerializerContext.cs | 25 ++ .../AjaxContextWrapper.cs | 2 +- src/Ramstack.HtmxToolkit/HtmxHistoryMode.cs | 2 - src/Ramstack.HtmxToolkit/HtmxResponse.cs | 6 +- src/Ramstack.HtmxToolkit/HtmxValue.cs | 241 ++++++++++++++++++ .../HtmxValueJsonConverter.cs | 37 +++ .../Internal/JsonOptions.cs | 10 - .../Properties/CollectionBuilderAttribute.cs | 31 +++ .../HtmxResponseTests.cs | 14 +- 10 files changed, 351 insertions(+), 19 deletions(-) create mode 100644 src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs create mode 100644 src/Ramstack.HtmxToolkit/HtmxValue.cs create mode 100644 src/Ramstack.HtmxToolkit/HtmxValueJsonConverter.cs create mode 100644 src/Ramstack.HtmxToolkit/Properties/CollectionBuilderAttribute.cs diff --git a/src/Ramstack.HtmxToolkit/AjaxContext.cs b/src/Ramstack.HtmxToolkit/AjaxContext.cs index 3b5a166..b7edbd3 100644 --- a/src/Ramstack.HtmxToolkit/AjaxContext.cs +++ b/src/Ramstack.HtmxToolkit/AjaxContext.cs @@ -38,7 +38,7 @@ public sealed class AjaxContext /// /// Gets or sets the values to submit with the request. /// - public object? Values { get; set; } + public IDictionary? Values { get; set; } /// /// Gets or sets the headers to include with the request. diff --git a/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs new file mode 100644 index 0000000..91b0870 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +using Ramstack.HtmxToolkit.Internal; + +namespace Ramstack.HtmxToolkit; + +/// +/// Provides source-generated JSON serialization metadata for AJAX context data. +/// +[JsonSourceGenerationOptions( + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + GenerationMode = JsonSourceGenerationMode.Default)] +[JsonSerializable(typeof(AjaxContextWrapper))] +internal partial class AjaxContextJsonSerializerContext : JsonSerializerContext +{ + /// + /// Initializes the default serializer context with HTML-safe Unicode encoding. + /// + static AjaxContextJsonSerializerContext() + { + JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); + s_defaultContext = new AjaxContextJsonSerializerContext(s_defaultOptions); + } +} diff --git a/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs b/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs index ea72b15..bd3cbf3 100644 --- a/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs +++ b/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs @@ -17,7 +17,7 @@ internal readonly struct AjaxContextWrapper [JsonPropertyName("handler")] public string? Handler => _context.Handler; [JsonPropertyName("target")] public string? Target => _context.Target; [JsonPropertyName("swap")] public string? Swap => _context.Swap.GetSwapValue(); - [JsonPropertyName("values")] public object? Values => _context.Values; + [JsonPropertyName("values")] public IDictionary? Values => _context.Values; [JsonPropertyName("headers")] public IDictionary? Headers => _context.Headers; [JsonPropertyName("select")] public string? Select => _context.Select; diff --git a/src/Ramstack.HtmxToolkit/HtmxHistoryMode.cs b/src/Ramstack.HtmxToolkit/HtmxHistoryMode.cs index 96c07fa..c5eec00 100644 --- a/src/Ramstack.HtmxToolkit/HtmxHistoryMode.cs +++ b/src/Ramstack.HtmxToolkit/HtmxHistoryMode.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace Ramstack.HtmxToolkit; /// diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 83cdaa5..72ce3f6 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -2,7 +2,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; using Ramstack.HtmxToolkit.Internal; @@ -61,7 +60,10 @@ public HtmxResponse Location(string path, AjaxContext context) static HtmxResponse LocationImpl(HtmxResponse response, string path, AjaxContext context) { - var value = JsonSerializer.Serialize(new AjaxContextWrapper(path, context), JsonOptions.PreserveKeyCase); + var value = JsonSerializer.Serialize( + new AjaxContextWrapper(path, context), + AjaxContextJsonSerializerContext.Default.AjaxContextWrapper); + return SetHeader(response, HtmxResponseHeaderNames.Location, value); } } diff --git a/src/Ramstack.HtmxToolkit/HtmxValue.cs b/src/Ramstack.HtmxToolkit/HtmxValue.cs new file mode 100644 index 0000000..4bde3b0 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxValue.cs @@ -0,0 +1,241 @@ +using System.Collections; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace Ramstack.HtmxToolkit; + +/// +/// Represents one or more string values for an HTMX request parameter. +/// +/// +/// A single value can be assigned from a string. Multiple values can be specified with a collection expression. +/// +[CollectionBuilder(typeof(HtmxValue), nameof(Create))] +[JsonConverter(typeof(HtmxValueJsonConverter))] +public readonly struct HtmxValue : IReadOnlyList +{ + private readonly object? _values; + + /// + public int Count + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + var values = _values; + if (values is null) + return 0; + + if (values is string) + return 1; + + return Unsafe.As(values).Length; + } + } + + /// + public string this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + var values = _values; + if (values is string s) + { + if (index == 0) + return s; + } + else if (values is not null) + { + return Unsafe.As(values)[index]; + } + + return OutOfBounds(); + } + } + + /// + /// Gets the underlying value representation. + /// + /// + /// when the collection is empty, a + /// when it contains one value, or a string array when it contains multiple values. + /// + public object? Values => _values; + + /// + /// Initializes a new instance of the structure with a single value. + /// + /// The value to store. + public HtmxValue(string value) => + _values = value; + + /// + /// Initializes a new instance of the structure with the specified values. + /// + /// + /// The values to store, or to create an empty instance. + /// + /// + /// The specified array is stored directly and is not copied. + /// + public HtmxValue(string[]? values) => + _values = values; + + /// + /// Creates an from the specified values. + /// + /// The values to include. + /// + /// An containing the specified values. + /// + public static HtmxValue Create(ReadOnlySpan values) + { + return values.Length switch + { + 0 => default, + 1 => new HtmxValue(values[0]), + _ => new HtmxValue(CreateArray(values)) + }; + } + + /// + /// Converts a string to an . + /// + /// The value to convert. + public static implicit operator HtmxValue(string value) => + new(value); + + /// + /// Converts an array of strings to an . + /// + /// The values to convert. + public static implicit operator HtmxValue(string[] values) => + new(values); + + /// + /// Returns an enumerator that iterates through the values. + /// + /// + /// An enumerator for the values. + /// + public Enumerator GetEnumerator() => + new(this); + + #region IEnumerable: explicit interface implementations + + /// + IEnumerator IEnumerable.GetEnumerator() => + GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => + GetEnumerator(); + + #endregion + + [MethodImpl(MethodImplOptions.NoInlining)] + private static string OutOfBounds() => + Array.Empty()[0]; + + /// + /// Copies the specified read-only span to a new array. + /// + /// The values to copy. + /// + /// A new array containing the specified values. + /// + /// + /// The JIT compiler inlines the implementation of + /// into its caller, producing a disproportionately large amount of native code at the call site. + /// This non-inlined wrapper keeps that implementation out of . + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private static string[] CreateArray(ReadOnlySpan s) => + s.ToArray(); + + #region Inner type: Enumerator + + /// + /// Enumerates the strings represented by an . + /// + public struct Enumerator : IEnumerator + { + private readonly string[]? _values; + private string? _current; + private int _index; + + /// + public readonly string Current => _current!; + + /// + /// Initializes a new enumerator for the specified underlying value representation. + /// + /// A single string, an array of strings, + /// or for an empty sequence. + private Enumerator(object? value) + { + if (value is string s) + { + (_values, _current) = (null, s); + } + else + { + (_values, _current) = (Unsafe.As(value), null); + } + + _index = 0; + } + + /// + /// Initializes a new enumerator for the specified . + /// + /// The value whose strings to enumerate. + public Enumerator(HtmxValue value) : this(value._values) + { + } + + /// + public bool MoveNext() + { + var index = _index; + if (index < 0) + return false; + + var values = _values; + if (values is not null) + { + if ((uint)index < (uint)values.Length) + { + _index = index + 1; + _current = values[index]; + return true; + } + + _index = -1; + return false; + } + + _index = -1; + return _current is not null; + } + + /// + public readonly void Dispose() + { + } + + #region IEnumerator: explicit interface implementations + + /// + readonly object IEnumerator.Current => Current; + + /// + void IEnumerator.Reset() => + throw new NotSupportedException(); + + #endregion + } + + #endregion +} diff --git a/src/Ramstack.HtmxToolkit/HtmxValueJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxValueJsonConverter.cs new file mode 100644 index 0000000..dca590b --- /dev/null +++ b/src/Ramstack.HtmxToolkit/HtmxValueJsonConverter.cs @@ -0,0 +1,37 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Ramstack.HtmxToolkit; + +/// +/// Represents a JSON converter for . +/// +internal sealed class HtmxValueJsonConverter : JsonConverter +{ + /// + public override HtmxValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + /// + public override void Write(Utf8JsonWriter writer, HtmxValue value, JsonSerializerOptions options) + { + var values = value.Values; + switch (values) + { + case string s: + writer.WriteStringValue(s); + break; + + default: + writer.WriteStartArray(); + + if (values is not null) + foreach (var s in Unsafe.As(values)) + writer.WriteStringValue(s); + + writer.WriteEndArray(); + break; + } + } +} diff --git a/src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs b/src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs index 4b8e29f..c0fd200 100644 --- a/src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs +++ b/src/Ramstack.HtmxToolkit/Internal/JsonOptions.cs @@ -34,14 +34,4 @@ public static void ConfigureHtmlSafeUnicode(JsonSerializerOptions options) => DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; - - /// - /// JSON serializer options that preserve original property and key casing - /// while ignoring properties with values. - /// - public static readonly JsonSerializerOptions PreserveKeyCase = new() - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = false - }; } diff --git a/src/Ramstack.HtmxToolkit/Properties/CollectionBuilderAttribute.cs b/src/Ramstack.HtmxToolkit/Properties/CollectionBuilderAttribute.cs new file mode 100644 index 0000000..444806b --- /dev/null +++ b/src/Ramstack.HtmxToolkit/Properties/CollectionBuilderAttribute.cs @@ -0,0 +1,31 @@ +#if !NET8_0_OR_GREATER +namespace System.Runtime.CompilerServices; + +/// +/// Indicates the method used to build a collection from a collection expression. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface, Inherited = false)] +internal sealed class CollectionBuilderAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The type containing the builder method. + /// The name of the builder method. + public CollectionBuilderAttribute(Type builderType, string methodName) + { + BuilderType = builderType; + MethodName = methodName; + } + + /// + /// Gets the type containing the builder method. + /// + public Type BuilderType { get; } + + /// + /// Gets the name of the builder method. + /// + public string MethodName { get; } +} +#endif diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 0adf4a3..7789e11 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -57,15 +57,23 @@ public void Location_WithContext_SerializesHandlerValuesAndHeaders() context.Response.Htmx(r => r.Location("/bar", new AjaxContext { Handler = "handleResponse", - Values = new Dictionary { ["id"] = 42 }, - Headers = new Dictionary { ["X-Test"] = "abc" } + Values = new Dictionary + { + ["id"] = "42", + ["tags"] = ["dotnet", "web"] + }, + Headers = new Dictionary + { + ["X-Test"] = "abc" + } })); var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); var json = JsonHelper.ParseJson(header); Assert.That(json["handler"].GetString(), Is.EqualTo("handleResponse")); - Assert.That(json["values"].GetProperty("id").GetInt32(), Is.EqualTo(42)); + Assert.That(json["values"].GetProperty("id").GetString(), Is.EqualTo("42")); + Assert.That(json["values"].GetProperty("tags").GetRawText(), Is.EqualTo("[\"dotnet\",\"web\"]")); Assert.That(json["headers"].GetProperty("X-Test").GetString(), Is.EqualTo("abc")); } From 2a7be65eaa231cffaba2b1c4ad7a98978432bb4c Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 29 Aug 2026 22:19:47 +0500 Subject: [PATCH 02/14] Get rid of AjaxContextWrapper and serialize AjaxContext directly --- src/Ramstack.HtmxToolkit/AjaxContext.cs | 5 ++- .../AjaxContextJsonSerializerContext.cs | 3 +- .../AjaxContextWrapper.cs | 34 ------------------- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 5 ++- 4 files changed, 8 insertions(+), 39 deletions(-) delete mode 100644 src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs diff --git a/src/Ramstack.HtmxToolkit/AjaxContext.cs b/src/Ramstack.HtmxToolkit/AjaxContext.cs index b7edbd3..1c5f12a 100644 --- a/src/Ramstack.HtmxToolkit/AjaxContext.cs +++ b/src/Ramstack.HtmxToolkit/AjaxContext.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Ramstack.HtmxToolkit; /// @@ -8,7 +10,7 @@ public sealed class AjaxContext /// /// Gets or sets the path used for the AJAX request. /// - internal string? Path { get; set; } + public string? Path { get; internal set; } /// /// Gets or sets the source element that initiated the request. @@ -33,6 +35,7 @@ public sealed class AjaxContext /// /// Gets or sets how the response will be swapped relative to the target element. /// + [JsonConverter(typeof(HtmxSwapJsonConverter))] public HtmxSwap? Swap { get; set; } /// diff --git a/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs index 91b0870..b8ff5e1 100644 --- a/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs @@ -9,9 +9,10 @@ namespace Ramstack.HtmxToolkit; /// [JsonSourceGenerationOptions( WriteIndented = false, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, GenerationMode = JsonSourceGenerationMode.Default)] -[JsonSerializable(typeof(AjaxContextWrapper))] +[JsonSerializable(typeof(AjaxContext))] internal partial class AjaxContextJsonSerializerContext : JsonSerializerContext { /// diff --git a/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs b/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs deleted file mode 100644 index bd3cbf3..0000000 --- a/src/Ramstack.HtmxToolkit/AjaxContextWrapper.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text.Json.Serialization; - -using Ramstack.HtmxToolkit.Internal; - -namespace Ramstack.HtmxToolkit; - -/// -/// Represents a wrapper for the class, used for serialization. -/// -internal readonly struct AjaxContextWrapper -{ - private readonly AjaxContext _context; - - [JsonPropertyName("path")] public string? Path => _context.Path; - [JsonPropertyName("source")] public string? Source => _context.Source; - [JsonPropertyName("event")] public string? Event => _context.Event; - [JsonPropertyName("handler")] public string? Handler => _context.Handler; - [JsonPropertyName("target")] public string? Target => _context.Target; - [JsonPropertyName("swap")] public string? Swap => _context.Swap.GetSwapValue(); - [JsonPropertyName("values")] public IDictionary? Values => _context.Values; - [JsonPropertyName("headers")] public IDictionary? Headers => _context.Headers; - [JsonPropertyName("select")] public string? Select => _context.Select; - - /// - /// Initializes a new instance of the . - /// - /// The path of the AJAX request. - /// The . - public AjaxContextWrapper(string path, AjaxContext context) - { - context.Path = path; - _context = context; - } -} diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 72ce3f6..26293a5 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -60,10 +60,9 @@ public HtmxResponse Location(string path, AjaxContext context) static HtmxResponse LocationImpl(HtmxResponse response, string path, AjaxContext context) { - var value = JsonSerializer.Serialize( - new AjaxContextWrapper(path, context), - AjaxContextJsonSerializerContext.Default.AjaxContextWrapper); + context.Path = path; + var value = JsonSerializer.Serialize(context, AjaxContextJsonSerializerContext.Default.AjaxContext); return SetHeader(response, HtmxResponseHeaderNames.Location, value); } } From 44ca7cc70bb58a984d4ac0bac06b96baad0b5af8 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 29 Aug 2026 22:57:11 +0500 Subject: [PATCH 03/14] Rename HtmxValue to HtmxValues --- src/Ramstack.HtmxToolkit/AjaxContext.cs | 2 +- .../{HtmxValue.cs => HtmxValues.cs} | 40 +++++++++---------- ...onverter.cs => HtmxValuesJsonConverter.cs} | 8 ++-- .../HtmxResponseTests.cs | 2 +- 4 files changed, 26 insertions(+), 26 deletions(-) rename src/Ramstack.HtmxToolkit/{HtmxValue.cs => HtmxValues.cs} (83%) rename src/Ramstack.HtmxToolkit/{HtmxValueJsonConverter.cs => HtmxValuesJsonConverter.cs} (67%) diff --git a/src/Ramstack.HtmxToolkit/AjaxContext.cs b/src/Ramstack.HtmxToolkit/AjaxContext.cs index 1c5f12a..d800204 100644 --- a/src/Ramstack.HtmxToolkit/AjaxContext.cs +++ b/src/Ramstack.HtmxToolkit/AjaxContext.cs @@ -41,7 +41,7 @@ public sealed class AjaxContext /// /// Gets or sets the values to submit with the request. /// - public IDictionary? Values { get; set; } + public IDictionary? Values { get; set; } /// /// Gets or sets the headers to include with the request. diff --git a/src/Ramstack.HtmxToolkit/HtmxValue.cs b/src/Ramstack.HtmxToolkit/HtmxValues.cs similarity index 83% rename from src/Ramstack.HtmxToolkit/HtmxValue.cs rename to src/Ramstack.HtmxToolkit/HtmxValues.cs index 4bde3b0..c8ac9ee 100644 --- a/src/Ramstack.HtmxToolkit/HtmxValue.cs +++ b/src/Ramstack.HtmxToolkit/HtmxValues.cs @@ -10,9 +10,9 @@ namespace Ramstack.HtmxToolkit; /// /// A single value can be assigned from a string. Multiple values can be specified with a collection expression. /// -[CollectionBuilder(typeof(HtmxValue), nameof(Create))] -[JsonConverter(typeof(HtmxValueJsonConverter))] -public readonly struct HtmxValue : IReadOnlyList +[CollectionBuilder(typeof(HtmxValues), nameof(Create))] +[JsonConverter(typeof(HtmxValuesJsonConverter))] +public readonly struct HtmxValues : IReadOnlyList { private readonly object? _values; @@ -64,14 +64,14 @@ public string this[int index] public object? Values => _values; /// - /// Initializes a new instance of the structure with a single value. + /// Initializes a new instance of the structure with a single value. /// /// The value to store. - public HtmxValue(string value) => + public HtmxValues(string value) => _values = value; /// - /// Initializes a new instance of the structure with the specified values. + /// Initializes a new instance of the structure with the specified values. /// /// /// The values to store, or to create an empty instance. @@ -79,38 +79,38 @@ public HtmxValue(string value) => /// /// The specified array is stored directly and is not copied. /// - public HtmxValue(string[]? values) => + public HtmxValues(string[]? values) => _values = values; /// - /// Creates an from the specified values. + /// Creates an from the specified values. /// /// The values to include. /// - /// An containing the specified values. + /// An containing the specified values. /// - public static HtmxValue Create(ReadOnlySpan values) + public static HtmxValues Create(ReadOnlySpan values) { return values.Length switch { - 0 => default, - 1 => new HtmxValue(values[0]), - _ => new HtmxValue(CreateArray(values)) + 0 => new HtmxValues([]), + 1 => new HtmxValues(values[0]), + _ => new HtmxValues(CreateArray(values)) }; } /// - /// Converts a string to an . + /// Converts a string to an . /// /// The value to convert. - public static implicit operator HtmxValue(string value) => + public static implicit operator HtmxValues(string value) => new(value); /// - /// Converts an array of strings to an . + /// Converts an array of strings to an . /// /// The values to convert. - public static implicit operator HtmxValue(string[] values) => + public static implicit operator HtmxValues(string[] values) => new(values); /// @@ -157,7 +157,7 @@ private static string[] CreateArray(ReadOnlySpan s) => #region Inner type: Enumerator /// - /// Enumerates the strings represented by an . + /// Enumerates the strings represented by an . /// public struct Enumerator : IEnumerator { @@ -188,10 +188,10 @@ private Enumerator(object? value) } /// - /// Initializes a new enumerator for the specified . + /// Initializes a new enumerator for the specified . /// /// The value whose strings to enumerate. - public Enumerator(HtmxValue value) : this(value._values) + public Enumerator(HtmxValues value) : this(value._values) { } diff --git a/src/Ramstack.HtmxToolkit/HtmxValueJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxValuesJsonConverter.cs similarity index 67% rename from src/Ramstack.HtmxToolkit/HtmxValueJsonConverter.cs rename to src/Ramstack.HtmxToolkit/HtmxValuesJsonConverter.cs index dca590b..fe0ace7 100644 --- a/src/Ramstack.HtmxToolkit/HtmxValueJsonConverter.cs +++ b/src/Ramstack.HtmxToolkit/HtmxValuesJsonConverter.cs @@ -5,16 +5,16 @@ namespace Ramstack.HtmxToolkit; /// -/// Represents a JSON converter for . +/// Represents a JSON converter for . /// -internal sealed class HtmxValueJsonConverter : JsonConverter +internal sealed class HtmxValuesJsonConverter : JsonConverter { /// - public override HtmxValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + public override HtmxValues Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException(); /// - public override void Write(Utf8JsonWriter writer, HtmxValue value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, HtmxValues value, JsonSerializerOptions options) { var values = value.Values; switch (values) diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 7789e11..e5f9329 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -57,7 +57,7 @@ public void Location_WithContext_SerializesHandlerValuesAndHeaders() context.Response.Htmx(r => r.Location("/bar", new AjaxContext { Handler = "handleResponse", - Values = new Dictionary + Values = new Dictionary { ["id"] = "42", ["tags"] = ["dotnet", "web"] From 5b82c7d48fb04a1c5eecb09a667dfffa4cfe22c0 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 29 Aug 2026 23:29:16 +0500 Subject: [PATCH 04/14] Rename HtmxValues to HtmxFieldValues for clarity --- src/Ramstack.HtmxToolkit/AjaxContext.cs | 4 +- .../{HtmxValues.cs => HtmxFieldValues.cs} | 42 +++++++++---------- ...ter.cs => HtmxFieldValuesJsonConverter.cs} | 8 ++-- .../HtmxResponseTests.cs | 2 +- 4 files changed, 28 insertions(+), 28 deletions(-) rename src/Ramstack.HtmxToolkit/{HtmxValues.cs => HtmxFieldValues.cs} (81%) rename src/Ramstack.HtmxToolkit/{HtmxValuesJsonConverter.cs => HtmxFieldValuesJsonConverter.cs} (65%) diff --git a/src/Ramstack.HtmxToolkit/AjaxContext.cs b/src/Ramstack.HtmxToolkit/AjaxContext.cs index d800204..b173925 100644 --- a/src/Ramstack.HtmxToolkit/AjaxContext.cs +++ b/src/Ramstack.HtmxToolkit/AjaxContext.cs @@ -39,9 +39,9 @@ public sealed class AjaxContext public HtmxSwap? Swap { get; set; } /// - /// Gets or sets the values to submit with the request. + /// Gets or sets the form field values to submit with the request. /// - public IDictionary? Values { get; set; } + public IDictionary? Values { get; set; } /// /// Gets or sets the headers to include with the request. diff --git a/src/Ramstack.HtmxToolkit/HtmxValues.cs b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs similarity index 81% rename from src/Ramstack.HtmxToolkit/HtmxValues.cs rename to src/Ramstack.HtmxToolkit/HtmxFieldValues.cs index c8ac9ee..1192038 100644 --- a/src/Ramstack.HtmxToolkit/HtmxValues.cs +++ b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs @@ -5,14 +5,14 @@ namespace Ramstack.HtmxToolkit; /// -/// Represents one or more string values for an HTMX request parameter. +/// Represents one or more values for a form field submitted with an HTMX request. /// /// /// A single value can be assigned from a string. Multiple values can be specified with a collection expression. /// -[CollectionBuilder(typeof(HtmxValues), nameof(Create))] -[JsonConverter(typeof(HtmxValuesJsonConverter))] -public readonly struct HtmxValues : IReadOnlyList +[JsonConverter(typeof(HtmxFieldValuesJsonConverter))] +[CollectionBuilder(typeof(HtmxFieldValues), nameof(Create))] +public readonly struct HtmxFieldValues : IReadOnlyList { private readonly object? _values; @@ -64,14 +64,14 @@ public string this[int index] public object? Values => _values; /// - /// Initializes a new instance of the structure with a single value. + /// Initializes a new instance of the structure with a single value. /// /// The value to store. - public HtmxValues(string value) => + public HtmxFieldValues(string value) => _values = value; /// - /// Initializes a new instance of the structure with the specified values. + /// Initializes a new instance of the structure with the specified values. /// /// /// The values to store, or to create an empty instance. @@ -79,38 +79,38 @@ public HtmxValues(string value) => /// /// The specified array is stored directly and is not copied. /// - public HtmxValues(string[]? values) => + public HtmxFieldValues(string[]? values) => _values = values; /// - /// Creates an from the specified values. + /// Creates an from the specified values. /// /// The values to include. /// - /// An containing the specified values. + /// An containing the specified values. /// - public static HtmxValues Create(ReadOnlySpan values) + public static HtmxFieldValues Create(ReadOnlySpan values) { return values.Length switch { - 0 => new HtmxValues([]), - 1 => new HtmxValues(values[0]), - _ => new HtmxValues(CreateArray(values)) + 0 => new HtmxFieldValues([]), + 1 => new HtmxFieldValues(values[0]), + _ => new HtmxFieldValues(CreateArray(values)) }; } /// - /// Converts a string to an . + /// Converts a string to an . /// /// The value to convert. - public static implicit operator HtmxValues(string value) => + public static implicit operator HtmxFieldValues(string value) => new(value); /// - /// Converts an array of strings to an . + /// Converts an array of strings to an . /// /// The values to convert. - public static implicit operator HtmxValues(string[] values) => + public static implicit operator HtmxFieldValues(string[] values) => new(values); /// @@ -157,7 +157,7 @@ private static string[] CreateArray(ReadOnlySpan s) => #region Inner type: Enumerator /// - /// Enumerates the strings represented by an . + /// Enumerates the strings represented by an . /// public struct Enumerator : IEnumerator { @@ -188,10 +188,10 @@ private Enumerator(object? value) } /// - /// Initializes a new enumerator for the specified . + /// Initializes a new enumerator for the specified . /// /// The value whose strings to enumerate. - public Enumerator(HtmxValues value) : this(value._values) + public Enumerator(HtmxFieldValues value) : this(value._values) { } diff --git a/src/Ramstack.HtmxToolkit/HtmxValuesJsonConverter.cs b/src/Ramstack.HtmxToolkit/HtmxFieldValuesJsonConverter.cs similarity index 65% rename from src/Ramstack.HtmxToolkit/HtmxValuesJsonConverter.cs rename to src/Ramstack.HtmxToolkit/HtmxFieldValuesJsonConverter.cs index fe0ace7..98cd0d1 100644 --- a/src/Ramstack.HtmxToolkit/HtmxValuesJsonConverter.cs +++ b/src/Ramstack.HtmxToolkit/HtmxFieldValuesJsonConverter.cs @@ -5,16 +5,16 @@ namespace Ramstack.HtmxToolkit; /// -/// Represents a JSON converter for . +/// Represents a JSON converter for . /// -internal sealed class HtmxValuesJsonConverter : JsonConverter +internal sealed class HtmxFieldValuesJsonConverter : JsonConverter { /// - public override HtmxValues Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + public override HtmxFieldValues Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException(); /// - public override void Write(Utf8JsonWriter writer, HtmxValues value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, HtmxFieldValues value, JsonSerializerOptions options) { var values = value.Values; switch (values) diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index e5f9329..df3541d 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -57,7 +57,7 @@ public void Location_WithContext_SerializesHandlerValuesAndHeaders() context.Response.Htmx(r => r.Location("/bar", new AjaxContext { Handler = "handleResponse", - Values = new Dictionary + Values = new Dictionary { ["id"] = "42", ["tags"] = ["dotnet", "web"] From c6d1ed8eb3413ca5636b28e5518e227e2c2c5256 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 29 Aug 2026 23:47:42 +0500 Subject: [PATCH 05/14] Clean up --- src/Ramstack.HtmxToolkit/HtmxFieldValues.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs index 1192038..281f8f1 100644 --- a/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs +++ b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs @@ -57,25 +57,19 @@ public string this[int index] /// /// Gets the underlying value representation. /// - /// - /// when the collection is empty, a - /// when it contains one value, or a string array when it contains multiple values. - /// public object? Values => _values; /// /// Initializes a new instance of the structure with a single value. /// /// The value to store. - public HtmxFieldValues(string value) => + public HtmxFieldValues(string? value) => _values = value; /// /// Initializes a new instance of the structure with the specified values. /// - /// - /// The values to store, or to create an empty instance. - /// + /// The values to store. /// /// The specified array is stored directly and is not copied. /// From 2115123079ecf39b9d8f97e5aaebcac76d2cbbc3 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 00:00:49 +0500 Subject: [PATCH 06/14] Add tests --- .../HtmxFieldValuesTests.cs | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 tests/Ramstack.HtmxToolkit.Tests/HtmxFieldValuesTests.cs diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxFieldValuesTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxFieldValuesTests.cs new file mode 100644 index 0000000..de8bc12 --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxFieldValuesTests.cs @@ -0,0 +1,241 @@ +using System.Collections; +using System.Text.Json; + +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxFieldValuesTests +{ + [Test] + public void DefaultValue_IsEmpty() + { + var value = default(HtmxFieldValues); + + Assert.Multiple(() => + { + Assert.That(value.Count, Is.Zero); + Assert.That(value.Values, Is.Null); + Assert.That(value, Is.Empty); + Assert.That(value, Is.EquivalentTo(Array.Empty())); + }); + } + + [Test] + public void Enumeration_DefaultValue_ForeachCompletesWithoutItems() + { + var values = default(HtmxFieldValues); + var list = new List(); + + Assert.DoesNotThrow(() => + { + foreach (var item in values) + list.Add(item); + }); + + Assert.That(list, Is.Empty); + } + + [Test] + public void Constructor_SingleValue_UsesStringRepresentation() + { + var values = new HtmxFieldValues("value"); + Assert.That(values, Is.EqualTo(["value"])); + } + + [Test] + public void Constructor_Array_StoresArrayWithoutCopying() + { + var source = new[] { "first", "second" }; + var values = new HtmxFieldValues(source); + + source[1] = "changed"; + + Assert.Multiple(() => + { + Assert.That(values.Count, Is.EqualTo(2)); + Assert.That(values.Values, Is.SameAs(source)); + Assert.That(values[1], Is.EqualTo("changed")); + Assert.That(values, Is.EqualTo(source)); + }); + } + + [Test] + public void Constructor_EmptyArray_PreservesArrayRepresentation() + { + var source = new string [0]; + var values = new HtmxFieldValues(source); + + Assert.Multiple(() => + { + Assert.That(values.Count, Is.Zero); + Assert.That(values.Values, Is.SameAs(source)); + Assert.That(values, Is.Empty); + }); + } + + [Test] + public void Constructor_NullArray_IsEmpty() + { + var values = new HtmxFieldValues((string[]?)null); + + Assert.Multiple(() => + { + Assert.That(values.Count, Is.Zero); + Assert.That(values.Values, Is.Null); + Assert.That(values, Is.Empty); + }); + } + + [Test] + public void Create_EmptySpan_ReturnsEmptyArrayRepresentation() + { + var values = HtmxFieldValues.Create(ReadOnlySpan.Empty); + + Assert.Multiple(() => + { + Assert.That(values.Count, Is.Zero); + Assert.That(values.Values, Is.TypeOf()); + Assert.That(values.Values, Is.Empty); + }); + } + + [Test] + public void Create_SingleValue_UsesStringRepresentation() + { + var source = new[] { "single" }; + var values = HtmxFieldValues.Create(source); + + Assert.Multiple(() => + { + Assert.That(values.Count, Is.EqualTo(1)); + Assert.That(values.Values, Is.TypeOf()); + Assert.That(values[0], Is.EqualTo("single")); + Assert.That(values, Is.EqualTo(["single"])); + }); + } + + [Test] + public void Create_MultipleValues_CopiesTheSpan() + { + var source = new[] { "before", "first", "second", "after" }; + var values = HtmxFieldValues.Create(source.AsSpan()); + source[1] = "changed"; + + Assert.Multiple(() => + { + Assert.That(values.Count, Is.EqualTo(4)); + Assert.That(values.Values, Is.TypeOf()); + Assert.That(values.Values, Is.Not.SameAs(source)); + Assert.That(values, Is.EqualTo(["before", "first", "second", "after"])); + }); + } + + [Test] + public void CollectionExpressions_SelectCompactRepresentations() + { + HtmxFieldValues empty = []; + HtmxFieldValues single = ["one"]; + HtmxFieldValues multiple = ["one", "two"]; + + Assert.Multiple(() => + { + Assert.That(empty.Values, Is.TypeOf()); + Assert.That(empty, Is.Empty); + Assert.That(single.Values, Is.TypeOf()); + Assert.That(single, Is.EqualTo(["one"])); + Assert.That(multiple.Values, Is.TypeOf()); + Assert.That(multiple, Is.EqualTo(["one", "two"])); + }); + } + + [Test] + public void ImplicitConversions_PreserveSourceRepresentations() + { + var array = new[] { "one", "two" }; + + HtmxFieldValues single = "value"; + HtmxFieldValues multiple = array; + + Assert.Multiple(() => + { + Assert.That(single.Values, Is.EqualTo("value")); + Assert.That(single, Is.EqualTo(["value"])); + Assert.That(multiple.Values, Is.SameAs(array)); + Assert.That(multiple, Is.EqualTo(array)); + }); + } + + [TestCase(-1)] + [TestCase(1)] + public void Indexer_SingleValueInvalidIndex_ThrowsException(int index) + { + var values = new HtmxFieldValues("value"); + + Assert.Throws(() => _ = values[index]); + } + + [TestCase(-1)] + [TestCase(2)] + public void Indexer_ArrayInvalidIndex_ThrowsException(int index) + { + var values = new HtmxFieldValues(["one", "two"]); + + Assert.Throws(() => _ = values[index]); + } + + [Test] + public void Enumeration_GenericAndNonGenericInterfaces_ReturnValuesInOrder() + { + HtmxFieldValues values = ["first", "second", "third"]; + + // ReSharper disable once RedundantCast + var generic = ((IEnumerable)values).ToArray(); + + // ReSharper disable once RedundantCast + var nonGeneric = ((IEnumerable)values).Cast().ToArray(); + + Assert.Multiple(() => + { + Assert.That(generic, Is.EqualTo(["first", "second", "third"])); + Assert.That(nonGeneric, Is.EqualTo(generic)); + }); + } + + [Test] + public void Enumerator_AfterSequenceEnds_RemainsCompleted() + { + var enumerator = new HtmxFieldValues.Enumerator(["first", "second"]); + + Assert.Multiple(() => + { + Assert.That(enumerator.MoveNext(), Is.True); + Assert.That(enumerator.Current, Is.EqualTo("first")); + Assert.That(enumerator.MoveNext(), Is.True); + Assert.That(enumerator.Current, Is.EqualTo("second")); + Assert.That(enumerator.MoveNext(), Is.False); + Assert.That(enumerator.MoveNext(), Is.False); + }); + } + + [Test] + public void Enumerator_Reset_ThrowsNotSupportedException() + { + IEnumerator enumerator = new HtmxFieldValues.Enumerator("value"); + + Assert.Throws(enumerator.Reset); + } + + [Test] + public void JsonSerialization_EmptySingleAndMultipleValues_UsesExpectedShapes() + { + HtmxFieldValues single = "a\"b"; + HtmxFieldValues multiple = ["first", "second"]; + + Assert.Multiple(() => + { + Assert.That(JsonSerializer.Serialize(default(HtmxFieldValues)), Is.EqualTo("[]")); + Assert.That(JsonSerializer.Serialize(single), Is.EqualTo("\"a\\u0022b\"")); + Assert.That(JsonSerializer.Serialize(multiple), Is.EqualTo("[\"first\",\"second\"]")); + }); + } +} From 6381304f07e5bdadd784153fa5b91636d486aa18 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 00:22:28 +0500 Subject: [PATCH 07/14] Rename AjaxContext to HtmxLocationOptions --- .../{AjaxContext.cs => HtmxLocationOptions.cs} | 4 ++-- ...s => HtmxLocationOptionsJsonSerializerContext.cs} | 10 +++++----- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 12 ++++++------ .../Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs | 12 ++++++------ 4 files changed, 19 insertions(+), 19 deletions(-) rename src/Ramstack.HtmxToolkit/{AjaxContext.cs => HtmxLocationOptions.cs} (94%) rename src/Ramstack.HtmxToolkit/{AjaxContextJsonSerializerContext.cs => HtmxLocationOptionsJsonSerializerContext.cs} (60%) diff --git a/src/Ramstack.HtmxToolkit/AjaxContext.cs b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs similarity index 94% rename from src/Ramstack.HtmxToolkit/AjaxContext.cs rename to src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs index b173925..3505461 100644 --- a/src/Ramstack.HtmxToolkit/AjaxContext.cs +++ b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs @@ -3,9 +3,9 @@ namespace Ramstack.HtmxToolkit; /// -/// Represents the context for an AJAX request. +/// Represents the options for an HX-Location request. /// -public sealed class AjaxContext +public sealed class HtmxLocationOptions { /// /// Gets or sets the path used for the AJAX request. diff --git a/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/HtmxLocationOptionsJsonSerializerContext.cs similarity index 60% rename from src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs rename to src/Ramstack.HtmxToolkit/HtmxLocationOptionsJsonSerializerContext.cs index b8ff5e1..90e5059 100644 --- a/src/Ramstack.HtmxToolkit/AjaxContextJsonSerializerContext.cs +++ b/src/Ramstack.HtmxToolkit/HtmxLocationOptionsJsonSerializerContext.cs @@ -5,22 +5,22 @@ namespace Ramstack.HtmxToolkit; /// -/// Provides source-generated JSON serialization metadata for AJAX context data. +/// Provides source-generated JSON serialization metadata for HX-Location options. /// [JsonSourceGenerationOptions( WriteIndented = false, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, GenerationMode = JsonSourceGenerationMode.Default)] -[JsonSerializable(typeof(AjaxContext))] -internal partial class AjaxContextJsonSerializerContext : JsonSerializerContext +[JsonSerializable(typeof(HtmxLocationOptions))] +internal partial class HtmxLocationOptionsJsonSerializerContext : JsonSerializerContext { /// /// Initializes the default serializer context with HTML-safe Unicode encoding. /// - static AjaxContextJsonSerializerContext() + static HtmxLocationOptionsJsonSerializerContext() { JsonOptions.ConfigureHtmlSafeUnicode(s_defaultOptions); - s_defaultContext = new AjaxContextJsonSerializerContext(s_defaultOptions); + s_defaultContext = new HtmxLocationOptionsJsonSerializerContext(s_defaultOptions); } } diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs index 26293a5..dfa1c8d 100644 --- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs +++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs @@ -50,19 +50,19 @@ public HtmxResponse Location(string value) => /// Sets the HX-Location header to a client-side redirect that does not do a full page reload. /// /// The path of the request. - /// The AJAX context of the request. + /// The options for the HX-Location request. /// /// The current instance. /// - public HtmxResponse Location(string path, AjaxContext context) + public HtmxResponse Location(string path, HtmxLocationOptions options) { - return LocationImpl(this, path, context); + return LocationImpl(this, path, options); - static HtmxResponse LocationImpl(HtmxResponse response, string path, AjaxContext context) + static HtmxResponse LocationImpl(HtmxResponse response, string path, HtmxLocationOptions options) { - context.Path = path; + options.Path = path; - var value = JsonSerializer.Serialize(context, AjaxContextJsonSerializerContext.Default.AjaxContext); + var value = JsonSerializer.Serialize(options, HtmxLocationOptionsJsonSerializerContext.Default.HtmxLocationOptions); return SetHeader(response, HtmxResponseHeaderNames.Location, value); } } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index df3541d..8545b7d 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -13,10 +13,10 @@ public void Location_SetsHeader() } [Test] - public void Location_WithContext_SerializesJson() + public void Location_WithOptions_SerializesJson() { var context = TestHelper.CreateHtmxRequestContext(); - context.Response.Htmx(r => r.Location("/bar", new AjaxContext + context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions { Source = "button", Event = "click", @@ -37,10 +37,10 @@ public void Location_WithContext_SerializesJson() } [Test] - public void Location_WithContext_OmitsNullProperties() + public void Location_WithOptions_OmitsNullProperties() { var context = TestHelper.CreateHtmxRequestContext(); - context.Response.Htmx(r => r.Location("/bar", new AjaxContext())); + context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions())); var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); var json = JsonHelper.ParseJson(header); @@ -51,10 +51,10 @@ public void Location_WithContext_OmitsNullProperties() } [Test] - public void Location_WithContext_SerializesHandlerValuesAndHeaders() + public void Location_WithOptions_SerializesHandlerValuesAndHeaders() { var context = TestHelper.CreateHtmxRequestContext(); - context.Response.Htmx(r => r.Location("/bar", new AjaxContext + context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions { Handler = "handleResponse", Values = new Dictionary From 22cc2eb89d1be5be893312bfc2cc959ac8f00fa0 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 00:38:53 +0500 Subject: [PATCH 08/14] Add missing HX-Location options --- .../HtmxLocationOptions.cs | 16 ++++++++++++++++ .../HtmxResponseTests.cs | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs index 3505461..b4d20be 100644 --- a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs +++ b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs @@ -53,4 +53,20 @@ public sealed class HtmxLocationOptions /// Gets or sets a selector used to filter the content to swap from the response. /// public string? Select { get; set; } + + /// + /// Gets or sets a selector used to select content for out-of-band swaps from the response. + /// + public string? SelectOOB { get; set; } + + /// + /// Gets or sets the path to push into the browser history. + /// Set to false to prevent the URL from being pushed. + /// + public string? Push { get; set; } + + /// + /// Gets or sets the path that replaces the current URL in the browser history. + /// + public string? Replace { get; set; } } diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 8545b7d..34e9e63 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -77,6 +77,25 @@ public void Location_WithOptions_SerializesHandlerValuesAndHeaders() Assert.That(json["headers"].GetProperty("X-Test").GetString(), Is.EqualTo("abc")); } + [Test] + public void Location_WithOptions_SerializesHistoryAndOutOfBandOptions() + { + var context = TestHelper.CreateHtmxRequestContext(); + context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions + { + SelectOOB = "#alerts", + Push = "false", + Replace = "/replaced" + })); + + var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); + var json = JsonHelper.ParseJson(header); + + Assert.That(json["selectOOB"].GetString(), Is.EqualTo("#alerts")); + Assert.That(json["push"].GetString(), Is.EqualTo("false")); + Assert.That(json["replace"].GetString(), Is.EqualTo("/replaced")); + } + [Test] public void PushUrl_SetsHeader() { From 7037b95881baaf38d91810c793f0b9d0e2631aa4 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 00:42:28 +0500 Subject: [PATCH 09/14] Add HTMX version support notes to HtmxLocationOptions properties --- src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs index b4d20be..dc076d5 100644 --- a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs +++ b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs @@ -10,11 +10,13 @@ public sealed class HtmxLocationOptions /// /// Gets or sets the path used for the AJAX request. /// + /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. public string? Path { get; internal set; } /// /// Gets or sets the source element that initiated the request. /// + /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. public string? Source { get; set; } /// @@ -30,43 +32,51 @@ public sealed class HtmxLocationOptions /// /// Gets or sets the target element into which the response will be swapped. /// + /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. public string? Target { get; set; } /// /// 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. [JsonConverter(typeof(HtmxSwapJsonConverter))] public HtmxSwap? Swap { get; set; } /// /// Gets or sets the form field values to submit with the request. /// + /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. public IDictionary? Values { get; set; } /// /// Gets or sets the headers to include with the request. /// Header values must be strings; complex data should be passed as a pre-serialized JSON string. /// + /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. public IDictionary? Headers { get; set; } /// /// Gets or sets a selector used to filter the content to swap from the response. /// + /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. public string? Select { get; set; } /// /// Gets or sets a selector used to select content for out-of-band swaps from the response. /// + /// Supported in HTMX 2.x and HTMX 4.x. public string? SelectOOB { get; set; } /// /// Gets or sets the path to push into the browser history. /// Set to false to prevent the URL from being pushed. /// + /// Supported in HTMX 2.x and HTMX 4.x. public string? Push { get; set; } /// /// Gets or sets the path that replaces the current URL in the browser history. /// + /// Supported in HTMX 2.x and HTMX 4.x. public string? Replace { get; set; } } From 21cc719e0aeedd8eb205535d3a99ac4372405d8e Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 01:26:36 +0500 Subject: [PATCH 10/14] Remove non-serializable HX-Location options Remove Event and Handler from HtmxLocationOptions. HX-Location is transported as JSON, while htmx.ajax expects event to be a live browser Event instance and handler to be a callable JavaScript function. Serializing either value as a string cannot recreate the expected runtime object. HTMX 1.x and 2.x invoke handler directly, while HTMX 4.x no longer supports the handler option. --- src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs | 10 ---------- tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs | 6 +----- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs index dc076d5..a01f6d7 100644 --- a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs +++ b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs @@ -19,16 +19,6 @@ public sealed class HtmxLocationOptions /// Supported in HTMX 1.9.x, HTMX 2.x, and HTMX 4.x. public string? Source { get; set; } - /// - /// Gets or sets the event that triggered the request. - /// - public string? Event { get; set; } - - /// - /// Gets or sets the name of the client-side callback function that will handle the response HTML. - /// - public string? Handler { get; set; } - /// /// Gets or sets the target element into which the response will be swapped. /// diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index 34e9e63..aa8cece 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -19,7 +19,6 @@ public void Location_WithOptions_SerializesJson() context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions { Source = "button", - Event = "click", Target = "#content", Swap = HtmxSwap.OuterHtml, Select = "#list" @@ -30,7 +29,6 @@ public void Location_WithOptions_SerializesJson() Assert.That(json["path"].GetString(), Is.EqualTo("/bar")); Assert.That(json["source"].GetString(), Is.EqualTo("button")); - Assert.That(json["event"].GetString(), Is.EqualTo("click")); Assert.That(json["target"].GetString(), Is.EqualTo("#content")); Assert.That(json["swap"].GetString(), Is.EqualTo("outerHTML")); Assert.That(json["select"].GetString(), Is.EqualTo("#list")); @@ -51,12 +49,11 @@ public void Location_WithOptions_OmitsNullProperties() } [Test] - public void Location_WithOptions_SerializesHandlerValuesAndHeaders() + public void Location_WithOptions_SerializesValuesAndHeaders() { var context = TestHelper.CreateHtmxRequestContext(); context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions { - Handler = "handleResponse", Values = new Dictionary { ["id"] = "42", @@ -71,7 +68,6 @@ public void Location_WithOptions_SerializesHandlerValuesAndHeaders() var header = context.Response.Headers[HtmxResponseHeaderNames.Location].ToString(); var json = JsonHelper.ParseJson(header); - Assert.That(json["handler"].GetString(), Is.EqualTo("handleResponse")); Assert.That(json["values"].GetProperty("id").GetString(), Is.EqualTo("42")); Assert.That(json["values"].GetProperty("tags").GetRawText(), Is.EqualTo("[\"dotnet\",\"web\"]")); Assert.That(json["headers"].GetProperty("X-Test").GetString(), Is.EqualTo("abc")); From 90d224cfd4527d0a08c3ac4d24eb998e3ac52a24 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 01:35:17 +0500 Subject: [PATCH 11/14] Fix accidental public exposure of HtmxFieldValues.Values --- src/Ramstack.HtmxToolkit/HtmxFieldValues.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs index 281f8f1..477379c 100644 --- a/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs +++ b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs @@ -57,7 +57,7 @@ public string this[int index] /// /// Gets the underlying value representation. /// - public object? Values => _values; + internal object? Values => _values; /// /// Initializes a new instance of the structure with a single value. From a4ddcfb05ee694568e20568491824c198917954e Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 01:43:58 +0500 Subject: [PATCH 12/14] Allow nullable inputs in HtmxFieldValues implicit conversions --- src/Ramstack.HtmxToolkit/HtmxFieldValues.cs | 12 +++---- .../HtmxFieldValuesTests.cs | 33 ++++++++++++++++--- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs index 477379c..fc42b3e 100644 --- a/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs +++ b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs @@ -62,14 +62,14 @@ public string this[int index] /// /// Initializes a new instance of the structure with a single value. /// - /// The value to store. + /// The value to store, or to represent no values. public HtmxFieldValues(string? value) => _values = value; /// /// Initializes a new instance of the structure with the specified values. /// - /// The values to store. + /// The values to store, or to represent no values. /// /// The specified array is stored directly and is not copied. /// @@ -96,15 +96,15 @@ public static HtmxFieldValues Create(ReadOnlySpan values) /// /// Converts a string to an . /// - /// The value to convert. - public static implicit operator HtmxFieldValues(string value) => + /// The value to convert, or to represent no values. + public static implicit operator HtmxFieldValues(string? value) => new(value); /// /// Converts an array of strings to an . /// - /// The values to convert. - public static implicit operator HtmxFieldValues(string[] values) => + /// The values to convert, or to represent no values. + public static implicit operator HtmxFieldValues(string[]? values) => new(values); /// diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxFieldValuesTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxFieldValuesTests.cs index de8bc12..caee480 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxFieldValuesTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxFieldValuesTests.cs @@ -76,13 +76,19 @@ public void Constructor_EmptyArray_PreservesArrayRepresentation() [Test] public void Constructor_NullArray_IsEmpty() { - var values = new HtmxFieldValues((string[]?)null); + var single = new HtmxFieldValues((string?)null); + var multiple = new HtmxFieldValues((string[]?)null); Assert.Multiple(() => { - Assert.That(values.Count, Is.Zero); - Assert.That(values.Values, Is.Null); - Assert.That(values, Is.Empty); + Assert.That(single.Count, Is.Zero); + Assert.That(multiple.Count, Is.Zero); + + Assert.That(single.Values, Is.Null); + Assert.That(multiple.Values, Is.Null); + + Assert.That(single, Is.Empty); + Assert.That(multiple, Is.Empty); }); } @@ -165,6 +171,25 @@ public void ImplicitConversions_PreserveSourceRepresentations() }); } + [Test] + public void ImplicitConversions_NullSources_AreEmpty() + { + HtmxFieldValues single = (string?)null; + HtmxFieldValues multiple = (string[]?)null; + + Assert.Multiple(() => + { + Assert.That(single.Count, Is.Zero); + Assert.That(multiple.Count, Is.Zero); + + Assert.That(single.Values, Is.Null); + Assert.That(multiple.Values, Is.Null); + + Assert.That(single, Is.Empty); + Assert.That(multiple, Is.Empty); + }); + } + [TestCase(-1)] [TestCase(1)] public void Indexer_SingleValueInvalidIndex_ThrowsException(int index) From 4f4a072835725e3aa2b64e3884e5defd5f889acc Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 02:00:08 +0500 Subject: [PATCH 13/14] Clean up --- src/Ramstack.HtmxToolkit/HtmxFieldValues.cs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs index fc42b3e..6e908c2 100644 --- a/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs +++ b/src/Ramstack.HtmxToolkit/HtmxFieldValues.cs @@ -163,32 +163,23 @@ public struct Enumerator : IEnumerator public readonly string Current => _current!; /// - /// Initializes a new enumerator for the specified underlying value representation. + /// Initializes a new enumerator for the specified . /// - /// A single string, an array of strings, - /// or for an empty sequence. - private Enumerator(object? value) + /// The value whose strings to enumerate. + public Enumerator(HtmxFieldValues value) { - if (value is string s) + if (value.Values is string s) { (_values, _current) = (null, s); } else { - (_values, _current) = (Unsafe.As(value), null); + (_values, _current) = (Unsafe.As(value.Values), null); } _index = 0; } - /// - /// Initializes a new enumerator for the specified . - /// - /// The value whose strings to enumerate. - public Enumerator(HtmxFieldValues value) : this(value._values) - { - } - /// public bool MoveNext() { From ab020c9eb8878d4dfa930022d5c606910d27f61d Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 30 Aug 2026 02:04:56 +0500 Subject: [PATCH 14/14] Rename SelectOOB to follow .NET naming conventions --- src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs | 3 ++- tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs index a01f6d7..7d96aad 100644 --- a/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs +++ b/src/Ramstack.HtmxToolkit/HtmxLocationOptions.cs @@ -55,7 +55,8 @@ public sealed class HtmxLocationOptions /// Gets or sets a selector used to select content for out-of-band swaps from the response. /// /// Supported in HTMX 2.x and HTMX 4.x. - public string? SelectOOB { get; set; } + [JsonPropertyName("selectOOB")] + public string? SelectOob { get; set; } /// /// Gets or sets the path to push into the browser history. diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs index aa8cece..40860b6 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs @@ -79,7 +79,7 @@ public void Location_WithOptions_SerializesHistoryAndOutOfBandOptions() var context = TestHelper.CreateHtmxRequestContext(); context.Response.Htmx(r => r.Location("/bar", new HtmxLocationOptions { - SelectOOB = "#alerts", + SelectOob = "#alerts", Push = "false", Replace = "/replaced" }));