diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs
index ac4f4cc..84195c8 100644
--- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs
@@ -11,6 +11,10 @@ namespace Ramstack.HtmxToolkit;
///
/// Represents HTTP response to set htmx response headers.
///
+///
+/// Like and themselves, this type is not thread-safe.
+/// Its members should not be called concurrently from multiple threads for the same request.
+///
[DebuggerTypeProxy(typeof(HtmxResponseDebugView))]
public readonly struct HtmxResponse
{
@@ -211,7 +215,7 @@ public HtmxResponse TriggerEvent(string eventName, object detail, HtmxTriggerTim
return TriggerEventImpl(this, eventName, detail, timing);
static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing) =>
- SetEvents(response, new Dictionary { [eventName] = detail }, timing);
+ AddEvents(response, new Dictionary { [eventName] = detail }, timing);
}
///
@@ -224,7 +228,7 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob
/// The current instance.
///
public HtmxResponse TriggerEvents(IReadOnlyDictionary events, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
- SetEvents(this, events, timing);
+ AddEvents(this, events, timing);
///
/// Sets the special HTTP status code 286 that is used to stop the polling.
@@ -256,34 +260,9 @@ private static HtmxResponse SetHeader(HtmxResponse response, string key, string
return response;
}
- private static HtmxResponse SetEvents(HtmxResponse response, IReadOnlyDictionary events, HtmxTriggerTiming timing)
+ private static HtmxResponse AddEvents(HtmxResponse response, IReadOnlyDictionary events, HtmxTriggerTiming timing)
{
- var key = timing switch
- {
- HtmxTriggerTiming.Receive => HtmxResponseHeaderNames.Trigger,
- HtmxTriggerTiming.AfterSettle => HtmxResponseHeaderNames.TriggerAfterSettle,
- _ => HtmxResponseHeaderNames.TriggerAfterSwap
- };
-
- if (response._response.Headers.TryGetValue(key, out var values))
- {
- var current = JsonSerializer.Deserialize>(values.ToString())!;
-
- if (events is Dictionary dictionary)
- {
- foreach (var (k, v) in dictionary)
- current.TryAdd(k, v);
- }
- else
- {
- foreach (var (k, v) in events)
- current.TryAdd(k, v);
- }
-
- events = current;
- }
-
- response._response.Headers[key] = JsonSerializer.Serialize(events, JsonOptions.CamelCase);
+ PendingEvents.GetOrCreate(response._response).AddEvents(timing, events);
return response;
}
diff --git a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
index 379318f..a63b1e6 100644
--- a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
@@ -1,7 +1,5 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
-using System.Text.Json;
-
using Microsoft.AspNetCore.Http;
using Ramstack.HtmxToolkit.Internal;
@@ -14,14 +12,14 @@ namespace Ramstack.HtmxToolkit;
[DebuggerTypeProxy(typeof(HtmxResponseHeadersDebugView))]
public sealed class HtmxResponseHeaders
{
- private readonly IHeaderDictionary _headers;
+ private readonly HttpResponse _response;
///
/// Initializes a new instance of the structure.
///
/// The HTTP response.
internal HtmxResponseHeaders(HttpResponse response) =>
- _headers = response.Headers;
+ _response = response;
///
/// Gets or sets the HX-Location header to perform a client-side redirect without a full page reload.
@@ -29,8 +27,8 @@ internal HtmxResponseHeaders(HttpResponse response) =>
[MaybeNull]
public string Location
{
- get => GetHeader(_headers, HtmxResponseHeaderNames.Location);
- set => SetHeader(_headers, HtmxResponseHeaderNames.Location, value);
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.Location);
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.Location, value);
}
///
@@ -39,8 +37,8 @@ public string Location
[MaybeNull]
public string PushUrl
{
- get => GetHeader(_headers, HtmxResponseHeaderNames.PushUrl);
- set => SetHeader(_headers, HtmxResponseHeaderNames.PushUrl, value);
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.PushUrl);
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.PushUrl, value);
}
///
@@ -49,8 +47,8 @@ public string PushUrl
[MaybeNull]
public string Redirect
{
- get => GetHeader(_headers, HtmxResponseHeaderNames.Redirect);
- set => SetHeader(_headers, HtmxResponseHeaderNames.Redirect, value);
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.Redirect);
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.Redirect, value);
}
///
@@ -58,8 +56,8 @@ public string Redirect
///
public bool Refresh
{
- get => GetHeader(_headers, HtmxResponseHeaderNames.Refresh) == "true";
- set => SetHeader(_headers, HtmxResponseHeaderNames.Refresh, value ? "true" : null);
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.Refresh) == "true";
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.Refresh, value ? "true" : null);
}
///
@@ -69,8 +67,8 @@ public bool Refresh
[MaybeNull]
public string ReplaceUrl
{
- get => GetHeader(_headers, HtmxResponseHeaderNames.ReplaceUrl);
- set => SetHeader(_headers, HtmxResponseHeaderNames.ReplaceUrl, value);
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.ReplaceUrl);
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.ReplaceUrl, value);
}
///
@@ -79,8 +77,8 @@ public string ReplaceUrl
[DisallowNull]
public HtmxSwap? Reswap
{
- get => EnumHelper.ParseHtmxSwap(GetHeader(_headers, HtmxResponseHeaderNames.Reswap));
- set => SetHeader(_headers, HtmxResponseHeaderNames.Reswap, value.GetValueOrDefault().GetSwapValue());
+ get => EnumHelper.ParseHtmxSwap(GetHeader(_response.Headers, HtmxResponseHeaderNames.Reswap));
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.Reswap, value.GetValueOrDefault().GetSwapValue());
}
///
@@ -89,8 +87,8 @@ public HtmxSwap? Reswap
[MaybeNull]
public string ReswapExpression
{
- get => GetHeader(_headers, HtmxResponseHeaderNames.Reswap);
- set => SetHeader(_headers, HtmxResponseHeaderNames.Reswap, value);
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.Reswap);
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.Reswap, value);
}
///
@@ -100,8 +98,8 @@ public string ReswapExpression
[MaybeNull]
public string Retarget
{
- get => GetHeader(_headers, HtmxResponseHeaderNames.Retarget);
- set => SetHeader(_headers, HtmxResponseHeaderNames.Retarget, value);
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.Retarget);
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.Retarget, value);
}
///
@@ -111,41 +109,53 @@ public string Retarget
[MaybeNull]
public string Reselect
{
- get => GetHeader(_headers, HtmxResponseHeaderNames.Reselect);
- set => SetHeader(_headers, HtmxResponseHeaderNames.Reselect, value);
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.Reselect);
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.Reselect, value);
}
///
/// Gets or sets the HX-Trigger header, which specifies a dictionary of client-side events
/// to trigger after the server response is processed.
///
+ ///
+ /// Event values are accumulated for the current response and serialized into the header immediately
+ /// before the response starts.
+ ///
[MaybeNull]
public IReadOnlyDictionary Trigger
{
- get => GetEvents(_headers, HtmxResponseHeaderNames.Trigger);
- set => SetEvents(_headers, HtmxResponseHeaderNames.Trigger, value);
+ get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.Receive);
+ set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.Receive, value);
}
///
/// Gets or sets the HX-Trigger-After-Settle header, which specifies a dictionary of client-side events
/// to trigger after the htmx request has settled.
///
+ ///
+ /// Event values are accumulated for the current response and serialized into the header immediately
+ /// before the response starts.
+ ///
[MaybeNull]
public IReadOnlyDictionary TriggerAfterSettle
{
- get => GetEvents(_headers, HtmxResponseHeaderNames.TriggerAfterSettle);
- set => SetEvents(_headers, HtmxResponseHeaderNames.TriggerAfterSettle, value);
+ get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSettle);
+ set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.AfterSettle, value);
}
///
/// Gets or sets the HX-Trigger-After-Swap header, which specifies a dictionary of client-side events
/// to trigger after the response content has been swapped into the DOM.
///
+ ///
+ /// Event values are accumulated for the current response and serialized into the header immediately
+ /// before the response starts.
+ ///
[MaybeNull]
public IReadOnlyDictionary TriggerAfterSwap
{
- get => GetEvents(_headers, HtmxResponseHeaderNames.TriggerAfterSwap);
- set => SetEvents(_headers, HtmxResponseHeaderNames.TriggerAfterSwap, value);
+ get => PendingEvents.TryGet(_response)?.GetEvents(HtmxTriggerTiming.AfterSwap);
+ set => PendingEvents.GetOrCreate(_response).SetEvents(HtmxTriggerTiming.AfterSwap, value);
}
private static string? GetHeader(IHeaderDictionary headers, string key)
@@ -160,23 +170,12 @@ private static void SetHeader(IHeaderDictionary headers, string key, string? val
headers[key] = value;
}
- private static Dictionary? GetEvents(IHeaderDictionary headers, string key)
- {
- if (headers.TryGetValue(key, out var values))
- return JsonSerializer.Deserialize>(values.ToString());
-
- return null;
- }
-
- private static void SetEvents(IHeaderDictionary headers, string key, IReadOnlyDictionary events) =>
- headers[key] = JsonSerializer.Serialize(events, JsonOptions.CamelCase);
-
#region Inner type: HtmxResponseHeadersDebugView
private sealed class HtmxResponseHeadersDebugView(HtmxResponseHeaders headers)
{
[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
- public KeyValuePair[] Items => DebugHelpers.GetHeaders(headers._headers);
+ public KeyValuePair[] Items => DebugHelpers.GetHeaders(headers._response.Headers);
}
#endregion
diff --git a/src/Ramstack.HtmxToolkit/PendingEvents.cs b/src/Ramstack.HtmxToolkit/PendingEvents.cs
new file mode 100644
index 0000000..6b08225
--- /dev/null
+++ b/src/Ramstack.HtmxToolkit/PendingEvents.cs
@@ -0,0 +1,136 @@
+using System.Text.Json;
+
+using Microsoft.AspNetCore.Http;
+
+using Ramstack.HtmxToolkit.Internal;
+
+namespace Ramstack.HtmxToolkit;
+
+///
+/// Accumulates htmx events per for a single request,
+/// deferring header serialization until the response is about to start.
+///
+internal sealed class PendingEvents(HttpResponse response)
+{
+ private Dictionary? _receive;
+ private Dictionary? _afterSettle;
+ private Dictionary? _afterSwap;
+
+ ///
+ /// Adds the specified events to the pending set for the given .
+ /// Keys already present are preserved.
+ ///
+ /// The time at which the events will be triggered.
+ /// A dictionary containing event names as keys and event details as values.
+ public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary events)
+ {
+ var current = timing switch
+ {
+ HtmxTriggerTiming.Receive => _receive ??= new Dictionary(),
+ HtmxTriggerTiming.AfterSettle => _afterSettle ??= new Dictionary(),
+ _ => _afterSwap ??= new Dictionary()
+ };
+
+ if (events is Dictionary dictionary)
+ {
+ foreach (var (k, v) in dictionary)
+ current.TryAdd(k, v);
+ }
+ else
+ {
+ foreach (var (k, v) in events)
+ current.TryAdd(k, v);
+ }
+ }
+
+ ///
+ /// Returns the pending events for the specified .
+ ///
+ /// The time at which the events will be triggered.
+ ///
+ /// The pending events, or if none were registered.
+ ///
+ public IReadOnlyDictionary? GetEvents(HtmxTriggerTiming timing)
+ {
+ return timing switch
+ {
+ HtmxTriggerTiming.Receive => _receive,
+ HtmxTriggerTiming.AfterSettle => _afterSettle,
+ _ => _afterSwap
+ };
+ }
+
+ ///
+ /// Replaces the pending events for the specified .
+ ///
+ /// The time at which the events will be triggered.
+ /// A dictionary containing event names as keys and event details as values.
+ public void SetEvents(HtmxTriggerTiming timing, IReadOnlyDictionary events)
+ {
+ var replacement = new Dictionary(events);
+ switch (timing)
+ {
+ case HtmxTriggerTiming.Receive:
+ _receive = replacement;
+ break;
+ case HtmxTriggerTiming.AfterSettle:
+ _afterSettle = replacement;
+ break;
+ default:
+ _afterSwap = replacement;
+ break;
+ }
+ }
+
+ ///
+ /// Serializes the accumulated events, if any, into the corresponding HX-Trigger response headers.
+ ///
+ public void Flush()
+ {
+ SetHeader(HtmxResponseHeaderNames.Trigger, _receive);
+ SetHeader(HtmxResponseHeaderNames.TriggerAfterSettle, _afterSettle);
+ SetHeader(HtmxResponseHeaderNames.TriggerAfterSwap, _afterSwap);
+ }
+
+ ///
+ /// Returns the events accumulator previously registered for the response.
+ ///
+ /// The HTTP response that owns the events.
+ ///
+ /// The pending events accumulator, or if none was registered.
+ ///
+ public static PendingEvents? TryGet(HttpResponse response) =>
+ response.HttpContext.Items[typeof(PendingEvents)] as PendingEvents;
+
+ ///
+ /// Returns the pending events accumulator, creating and registering it for the response when necessary.
+ ///
+ /// The HTTP response that owns the events.
+ ///
+ /// The pending events accumulator for the response.
+ ///
+ public static PendingEvents GetOrCreate(HttpResponse response)
+ {
+ var context = response.HttpContext;
+ if (TryGet(response) is { } pending)
+ return pending;
+
+ pending = new PendingEvents(response);
+ context.Items[typeof(PendingEvents)] = pending;
+
+ response.OnStarting(static o =>
+ {
+ var state = (PendingEvents)o;
+ state.Flush();
+ return Task.CompletedTask;
+ }, pending);
+
+ return pending;
+ }
+
+ private void SetHeader(string name, Dictionary? events)
+ {
+ if (events is not null)
+ response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase);
+ }
+}