Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ Response.Htmx(h => h
.StopPolling(ShouldStopPolling));
```

`TriggerEvent` and `TriggerEvents` accept an optional `HtmxTriggerTiming` value.
In HTMX 1.x and 2.x, the value selects `HX-Trigger`, `HX-Trigger-After-Swap`, or
`HX-Trigger-After-Settle`. HTMX 4.x supports only `HX-Trigger`, so the toolkit
emits events requested for any timing through that header rather than dropping
them. These events run after the swap; in particular, the 1.x/2.x `Receive` and
`AfterSettle` timings cannot be preserved. See
[htmx pull request #3900](https://github.com/bigskysoftware/htmx/pull/3900) for
the upstream timing change.

:bulb: The generic overload accepts an additional state parameter to avoid closure allocations:

```csharp
Expand Down
15 changes: 15 additions & 0 deletions src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ public HtmxResponse Reselect(string value) =>
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
/// <remarks>
/// In HTMX 4.x, every <see cref="HtmxTriggerTiming" /> value is emitted through
/// <c>HX-Trigger</c> and runs after the swap.
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </remarks>
public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = HtmxTriggerTiming.Receive) =>
TriggerEvent(eventName, "", trigger);

Expand All @@ -218,6 +223,11 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = H
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
/// <remarks>
/// In HTMX 4.x, every <see cref="HtmxTriggerTiming" /> value is emitted through
/// <c>HX-Trigger</c> and runs after the swap.
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </remarks>
public HtmxResponse TriggerEvent(string eventName, object detail, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive)
{
return TriggerEventImpl(this, eventName, detail, timing);
Expand All @@ -234,6 +244,11 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
/// <remarks>
/// In HTMX 4.x, every <see cref="HtmxTriggerTiming" /> value is emitted through
/// <c>HX-Trigger</c> and runs after the swap.
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </remarks>
public HtmxResponse TriggerEvents(IReadOnlyDictionary<string, object> events, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
AddEvents(this, events, timing);

Expand Down
21 changes: 17 additions & 4 deletions src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,14 @@ public string Reselect
/// Gets or sets the client-side events to trigger through the <c>HX-Trigger</c> header.
/// </summary>
/// <remarks>
/// Event values are accumulated for the current response and serialized
/// into the header immediately before the response starts.
/// <para>
/// Event values are accumulated for the current response and serialized
/// into the header immediately before the response starts.
/// </para>
/// <para>
/// HTMX 1.x and 2.x trigger these events when the response is received,
/// whereas HTMX 4.x triggers them after the swap.
/// </para>
/// </remarks>
[MaybeNull]
public IReadOnlyDictionary<string, object> Trigger
Expand All @@ -139,7 +145,10 @@ public IReadOnlyDictionary<string, object> Trigger
/// Event values are accumulated for the current response and serialized
/// into the header immediately before the response starts.
/// </para>
/// <para>This header is supported only in HTMX 1.x and 2.x.</para>
/// <para>
/// In HTMX 4.x, assigned events are accumulated in <see cref="Trigger" />
/// and emitted through <c>HX-Trigger</c> after the swap.
/// </para>
/// </remarks>
[MaybeNull]
public IReadOnlyDictionary<string, object> TriggerAfterSwap
Expand All @@ -157,7 +166,11 @@ public IReadOnlyDictionary<string, object> TriggerAfterSwap
/// Event values are accumulated for the current response and serialized
/// into the header immediately before the response starts.
/// </para>
/// <para>This header is supported only in HTMX 1.x and 2.x.</para>
/// <para>
/// In HTMX 4.x, assigned events are accumulated in <see cref="Trigger" />
/// and emitted through <c>HX-Trigger</c> after the swap; the requested
/// after-settle timing cannot be preserved.
/// </para>
/// </remarks>
[MaybeNull]
public IReadOnlyDictionary<string, object> TriggerAfterSettle
Expand Down
22 changes: 19 additions & 3 deletions src/Ramstack.HtmxToolkit/HtmxTriggerTiming.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,36 @@ public enum HtmxTriggerTiming
/// </summary>
/// <remarks>
/// <para>HTMX 1.x and 2.x trigger these events when the response is received.</para>
/// <para>HTMX 4.x triggers them after the swap completes.</para>
/// <para>
/// HTMX 4.x triggers them after the swap completes.
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </para>
/// </remarks>
Receive,

/// <summary>
/// Maps to the <c>HX-Trigger-After-Swap</c> header, which triggers events after the swap step.
/// </summary>
/// <remarks>Supported only in HTMX 1.x and 2.x.</remarks>
/// <remarks>
/// <para>HTMX 1.x and 2.x emit these events through <c>HX-Trigger-After-Swap</c>.</para>
/// <para>
/// HTMX 4.x emits them through <c>HX-Trigger</c>, which also runs after the swap.
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </para>
/// </remarks>
AfterSwap,

/// <summary>
/// Maps to the <c>HX-Trigger-After-Settle</c> header, which triggers events
/// after the settle step.
/// </summary>
/// <remarks>Supported only in HTMX 1.x and 2.x.</remarks>
/// <remarks>
/// <para>HTMX 1.x and 2.x emit these events through <c>HX-Trigger-After-Settle</c>.</para>
/// <para>
/// HTMX 4.x emits them through <c>HX-Trigger</c> after the swap; the requested
/// after-settle timing cannot be preserved.
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </para>
/// </remarks>
AfterSettle
}
40 changes: 39 additions & 1 deletion src/Ramstack.HtmxToolkit/PendingEvents.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System.Text.Json;

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;

using Ramstack.HtmxToolkit.Collections;
using Ramstack.HtmxToolkit.Configuration;
using Ramstack.HtmxToolkit.Serialization;

namespace Ramstack.HtmxToolkit;
Expand All @@ -16,6 +18,8 @@ internal sealed class PendingEvents
private const string ProxyEventName = "rs:events";

private readonly HttpResponse _response;
private readonly HtmxTargetVersion _version;

private SmallDictionary<string, object>? _receive;
private SmallDictionary<string, object>? _afterSwap;
private SmallDictionary<string, object>? _afterSettle;
Expand All @@ -25,7 +29,7 @@ internal sealed class PendingEvents
/// </summary>
/// <param name="response">The HTTP response to which the events belong.</param>
private PendingEvents(HttpResponse response) =>
_response = response;
(_response, _version) = (response, GetTargetVersion(response));

/// <summary>
/// Adds the specified events to the pending set for <paramref name="timing" />.
Expand All @@ -36,6 +40,8 @@ private PendingEvents(HttpResponse response) =>
/// <param name="events">The event names and their associated details.</param>
public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, object> events)
{
timing = NormalizeTiming(timing);

var current = timing switch
{
HtmxTriggerTiming.Receive => _receive ??= new SmallDictionary<string, object>(StringComparer.Ordinal),
Expand Down Expand Up @@ -67,6 +73,8 @@ public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, obje
/// </returns>
public IReadOnlyDictionary<string, object>? GetEvents(HtmxTriggerTiming timing)
{
timing = NormalizeTiming(timing);

return timing switch
{
HtmxTriggerTiming.Receive => _receive,
Expand All @@ -82,6 +90,8 @@ public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, obje
/// <param name="events">The replacement event names and their associated details.</param>
public void SetEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, object> events)
{
timing = NormalizeTiming(timing);

var replacement = new SmallDictionary<string, object>(events, StringComparer.Ordinal);
switch (timing)
{
Expand Down Expand Up @@ -156,4 +166,32 @@ private void SetHeader(string name, SmallDictionary<string, object>? events)
if (events is not null)
_response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase);
}

/// <summary>
/// Normalizes unsupported HTMX 4.x trigger timings to the primary trigger header.
/// </summary>
/// <param name="timing">The requested event timing.</param>
/// <returns>
/// The timing supported by the configured HTMX version.
/// </returns>
private HtmxTriggerTiming NormalizeTiming(HtmxTriggerTiming timing) =>
_version == HtmxTargetVersion.V4
? HtmxTriggerTiming.Receive
: timing;

/// <summary>
/// Returns the configured HTMX target version, defaulting to HTMX 2.x
/// when toolkit services are unavailable.
/// </summary>
/// <param name="response">The response whose request services are inspected.</param>
/// <returns>
/// The configured HTMX target version.
/// </returns>
private static HtmxTargetVersion GetTargetVersion(HttpResponse response)
{
var p = response.HttpContext.RequestServices;
var options = p.GetService(typeof(IOptions<HtmxToolkitOptions>)) as IOptions<HtmxToolkitOptions>;

return options?.Value.TargetVersion ?? HtmxTargetVersion.V2;
}
}
44 changes: 35 additions & 9 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Ramstack.HtmxToolkit.Configuration;

namespace Ramstack.HtmxToolkit.Tests;

Expand All @@ -8,7 +8,7 @@ public class HtmxResponseHeadersTests
[Test]
public void Properties_RoundTrips()
{
var context = new DefaultHttpContext();
var context = TestHelper.CreateHttpContext();
var headers = context.Response.GetHtmxHeaders();

headers.Location = "/foo";
Expand Down Expand Up @@ -42,7 +42,7 @@ public void Properties_RoundTrips()
[Test]
public void Refresh_WhenTrue_SetsTrue()
{
var context = new DefaultHttpContext();
var context = TestHelper.CreateHttpContext();
var headers = context.Response.GetHtmxHeaders();

headers.Refresh = true;
Expand All @@ -54,7 +54,7 @@ public void Refresh_WhenTrue_SetsTrue()
[Test]
public void Refresh_IsFalse_ByDefault()
{
var context = new DefaultHttpContext();
var context = TestHelper.CreateHttpContext();
var headers = context.Response.GetHtmxHeaders();

Assert.That(headers.Refresh, Is.False);
Expand All @@ -63,7 +63,7 @@ public void Refresh_IsFalse_ByDefault()
[Test]
public void Reswap_IsNull_WhenHeaderAbsent()
{
var context = new DefaultHttpContext();
var context = TestHelper.CreateHttpContext();
var headers = context.Response.GetHtmxHeaders();

Assert.That(headers.Reswap, Is.Null);
Expand All @@ -72,7 +72,7 @@ public void Reswap_IsNull_WhenHeaderAbsent()
[Test]
public void Reswap_IsNull_WhenHeaderUnknown()
{
var context = new DefaultHttpContext();
var context = TestHelper.CreateHttpContext();
var headers = context.Response.GetHtmxHeaders();

context.Response.Headers[HtmxResponseHeaderNames.Reswap] = "bogus";
Expand All @@ -83,7 +83,7 @@ public void Reswap_IsNull_WhenHeaderUnknown()
[Test]
public void ReswapExpression_KeepsFullExpression_WhileReswapParsesOnlyStyle()
{
var context = new DefaultHttpContext();
var context = TestHelper.CreateHttpContext();
var headers = context.Response.GetHtmxHeaders();

headers.ReswapExpression = "outerHTML show:top";
Expand All @@ -95,7 +95,7 @@ public void ReswapExpression_KeepsFullExpression_WhileReswapParsesOnlyStyle()
[Test]
public void SettingNull_DoesNotAddHeader()
{
var context = new DefaultHttpContext();
var context = TestHelper.CreateHttpContext();
var headers = context.Response.GetHtmxHeaders();

headers.Location = null!;
Expand All @@ -106,11 +106,37 @@ public void SettingNull_DoesNotAddHeader()
[Test]
public void Trigger_IsNull_WhenNotSet()
{
var context = new DefaultHttpContext();
var context = TestHelper.CreateHttpContext();
var headers = context.Response.GetHtmxHeaders();

Assert.That(headers.Trigger, Is.Null);
Assert.That(headers.TriggerAfterSwap, Is.Null);
Assert.That(headers.TriggerAfterSettle, Is.Null);
}

[Test]
public void TriggerTimingProperties_Htmx4_AliasReceiveTrigger()
{
var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4);
var headers = context.Response.GetHtmxHeaders();
var events = new Dictionary<string, object> { ["swapped"] = true };

headers.TriggerAfterSwap = events;

Assert.Multiple(() =>
{
Assert.That(headers.Trigger, Is.EqualTo(events));
Assert.That(headers.TriggerAfterSwap, Is.EqualTo(events));
Assert.That(headers.TriggerAfterSettle, Is.EqualTo(events));
});

PendingEvents.GetOrCreate(context.Response).Flush();

Assert.Multiple(() =>
{
Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Trigger), Is.True);
Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSwap), Is.False);
Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSettle), Is.False);
});
}
}
23 changes: 23 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Ramstack.HtmxToolkit.Configuration;

namespace Ramstack.HtmxToolkit.Tests;

[TestFixture]
Expand Down Expand Up @@ -260,4 +262,25 @@ public void TriggerEvents_SetsMultipleEvents()
Assert.That(events["a"], Is.EqualTo(1));
Assert.That(events["b"], Is.EqualTo(2));
}

[Test]
public void TriggerEvent_Htmx4_AddsEveryTimingToReceiveTrigger()
{
var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4);

context.Response.Htmx(r => r
.TriggerEvent("received")
.TriggerEvent("swapped", HtmxTriggerTiming.AfterSwap)
.TriggerEvent("settled", HtmxTriggerTiming.AfterSettle));

var headers = context.Response.GetHtmxHeaders();
var events = headers.Trigger!;

Assert.Multiple(() =>
{
Assert.That(events.Keys, Is.EqualTo(["received", "swapped", "settled"]));
Assert.That(headers.TriggerAfterSwap, Is.SameAs(events));
Assert.That(headers.TriggerAfterSettle, Is.SameAs(events));
});
}
}
Loading
Loading