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); + } +}