Skip to content

Commit a9bb201

Browse files
authored
Merge pull request #43 from rameel/htmx4-trigger-events
Preserve trigger events across HTMX 4.x timing changes
2 parents 164cba7 + d0db4ef commit a9bb201

9 files changed

Lines changed: 310 additions & 30 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,15 @@ Response.Htmx(h => h
364364
.StopPolling(ShouldStopPolling));
365365
```
366366

367+
`TriggerEvent` and `TriggerEvents` accept an optional `HtmxTriggerTiming` value.
368+
In HTMX 1.x and 2.x, the value selects `HX-Trigger`, `HX-Trigger-After-Swap`, or
369+
`HX-Trigger-After-Settle`. HTMX 4.x supports only `HX-Trigger`, so the toolkit
370+
emits events requested for any timing through that header rather than dropping
371+
them. These events run after the swap; in particular, the 1.x/2.x `Receive` and
372+
`AfterSettle` timings cannot be preserved. See
373+
[htmx pull request #3900](https://github.com/bigskysoftware/htmx/pull/3900) for
374+
the upstream timing change.
375+
367376
:bulb: The generic overload accepts an additional state parameter to avoid closure allocations:
368377

369378
```csharp

src/Ramstack.HtmxToolkit/HtmxResponse.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ public HtmxResponse Reselect(string value) =>
205205
/// <returns>
206206
/// The current <see cref="HtmxResponse" /> instance.
207207
/// </returns>
208+
/// <remarks>
209+
/// In HTMX 4.x, every <see cref="HtmxTriggerTiming" /> value is emitted through
210+
/// <c>HX-Trigger</c> and runs after the swap.
211+
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
212+
/// </remarks>
208213
public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = HtmxTriggerTiming.Receive) =>
209214
TriggerEvent(eventName, "", trigger);
210215

@@ -218,6 +223,11 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming trigger = H
218223
/// <returns>
219224
/// The current <see cref="HtmxResponse" /> instance.
220225
/// </returns>
226+
/// <remarks>
227+
/// In HTMX 4.x, every <see cref="HtmxTriggerTiming" /> value is emitted through
228+
/// <c>HX-Trigger</c> and runs after the swap.
229+
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
230+
/// </remarks>
221231
public HtmxResponse TriggerEvent(string eventName, object detail, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive)
222232
{
223233
return TriggerEventImpl(this, eventName, detail, timing);
@@ -234,6 +244,11 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob
234244
/// <returns>
235245
/// The current <see cref="HtmxResponse" /> instance.
236246
/// </returns>
247+
/// <remarks>
248+
/// In HTMX 4.x, every <see cref="HtmxTriggerTiming" /> value is emitted through
249+
/// <c>HX-Trigger</c> and runs after the swap.
250+
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
251+
/// </remarks>
237252
public HtmxResponse TriggerEvents(IReadOnlyDictionary<string, object> events, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
238253
AddEvents(this, events, timing);
239254

src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,14 @@ public string Reselect
120120
/// Gets or sets the client-side events to trigger through the <c>HX-Trigger</c> header.
121121
/// </summary>
122122
/// <remarks>
123-
/// Event values are accumulated for the current response and serialized
124-
/// into the header immediately before the response starts.
123+
/// <para>
124+
/// Event values are accumulated for the current response and serialized
125+
/// into the header immediately before the response starts.
126+
/// </para>
127+
/// <para>
128+
/// HTMX 1.x and 2.x trigger these events when the response is received,
129+
/// whereas HTMX 4.x triggers them after the swap.
130+
/// </para>
125131
/// </remarks>
126132
[MaybeNull]
127133
public IReadOnlyDictionary<string, object> Trigger
@@ -139,7 +145,10 @@ public IReadOnlyDictionary<string, object> Trigger
139145
/// Event values are accumulated for the current response and serialized
140146
/// into the header immediately before the response starts.
141147
/// </para>
142-
/// <para>This header is supported only in HTMX 1.x and 2.x.</para>
148+
/// <para>
149+
/// In HTMX 4.x, assigned events are accumulated in <see cref="Trigger" />
150+
/// and emitted through <c>HX-Trigger</c> after the swap.
151+
/// </para>
143152
/// </remarks>
144153
[MaybeNull]
145154
public IReadOnlyDictionary<string, object> TriggerAfterSwap
@@ -157,7 +166,11 @@ public IReadOnlyDictionary<string, object> TriggerAfterSwap
157166
/// Event values are accumulated for the current response and serialized
158167
/// into the header immediately before the response starts.
159168
/// </para>
160-
/// <para>This header is supported only in HTMX 1.x and 2.x.</para>
169+
/// <para>
170+
/// In HTMX 4.x, assigned events are accumulated in <see cref="Trigger" />
171+
/// and emitted through <c>HX-Trigger</c> after the swap; the requested
172+
/// after-settle timing cannot be preserved.
173+
/// </para>
161174
/// </remarks>
162175
[MaybeNull]
163176
public IReadOnlyDictionary<string, object> TriggerAfterSettle

src/Ramstack.HtmxToolkit/HtmxTriggerTiming.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,36 @@ public enum HtmxTriggerTiming
1010
/// </summary>
1111
/// <remarks>
1212
/// <para>HTMX 1.x and 2.x trigger these events when the response is received.</para>
13-
/// <para>HTMX 4.x triggers them after the swap completes.</para>
13+
/// <para>
14+
/// HTMX 4.x triggers them after the swap completes.
15+
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
16+
/// </para>
1417
/// </remarks>
1518
Receive,
1619

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

2332
/// <summary>
2433
/// Maps to the <c>HX-Trigger-After-Settle</c> header, which triggers events
2534
/// after the settle step.
2635
/// </summary>
27-
/// <remarks>Supported only in HTMX 1.x and 2.x.</remarks>
36+
/// <remarks>
37+
/// <para>HTMX 1.x and 2.x emit these events through <c>HX-Trigger-After-Settle</c>.</para>
38+
/// <para>
39+
/// HTMX 4.x emits them through <c>HX-Trigger</c> after the swap; the requested
40+
/// after-settle timing cannot be preserved.
41+
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
42+
/// </para>
43+
/// </remarks>
2844
AfterSettle
2945
}

src/Ramstack.HtmxToolkit/PendingEvents.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System.Text.Json;
22

33
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Options;
45

56
using Ramstack.HtmxToolkit.Collections;
7+
using Ramstack.HtmxToolkit.Configuration;
68
using Ramstack.HtmxToolkit.Serialization;
79

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

1820
private readonly HttpResponse _response;
21+
private readonly HtmxTargetVersion _version;
22+
1923
private SmallDictionary<string, object>? _receive;
2024
private SmallDictionary<string, object>? _afterSwap;
2125
private SmallDictionary<string, object>? _afterSettle;
@@ -25,7 +29,7 @@ internal sealed class PendingEvents
2529
/// </summary>
2630
/// <param name="response">The HTTP response to which the events belong.</param>
2731
private PendingEvents(HttpResponse response) =>
28-
_response = response;
32+
(_response, _version) = (response, GetTargetVersion(response));
2933

3034
/// <summary>
3135
/// Adds the specified events to the pending set for <paramref name="timing" />.
@@ -36,6 +40,8 @@ private PendingEvents(HttpResponse response) =>
3640
/// <param name="events">The event names and their associated details.</param>
3741
public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, object> events)
3842
{
43+
timing = NormalizeTiming(timing);
44+
3945
var current = timing switch
4046
{
4147
HtmxTriggerTiming.Receive => _receive ??= new SmallDictionary<string, object>(StringComparer.Ordinal),
@@ -67,6 +73,8 @@ public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, obje
6773
/// </returns>
6874
public IReadOnlyDictionary<string, object>? GetEvents(HtmxTriggerTiming timing)
6975
{
76+
timing = NormalizeTiming(timing);
77+
7078
return timing switch
7179
{
7280
HtmxTriggerTiming.Receive => _receive,
@@ -82,6 +90,8 @@ public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, obje
8290
/// <param name="events">The replacement event names and their associated details.</param>
8391
public void SetEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, object> events)
8492
{
93+
timing = NormalizeTiming(timing);
94+
8595
var replacement = new SmallDictionary<string, object>(events, StringComparer.Ordinal);
8696
switch (timing)
8797
{
@@ -156,4 +166,32 @@ private void SetHeader(string name, SmallDictionary<string, object>? events)
156166
if (events is not null)
157167
_response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase);
158168
}
169+
170+
/// <summary>
171+
/// Normalizes unsupported HTMX 4.x trigger timings to the primary trigger header.
172+
/// </summary>
173+
/// <param name="timing">The requested event timing.</param>
174+
/// <returns>
175+
/// The timing supported by the configured HTMX version.
176+
/// </returns>
177+
private HtmxTriggerTiming NormalizeTiming(HtmxTriggerTiming timing) =>
178+
_version == HtmxTargetVersion.V4
179+
? HtmxTriggerTiming.Receive
180+
: timing;
181+
182+
/// <summary>
183+
/// Returns the configured HTMX target version, defaulting to HTMX 2.x
184+
/// when toolkit services are unavailable.
185+
/// </summary>
186+
/// <param name="response">The response whose request services are inspected.</param>
187+
/// <returns>
188+
/// The configured HTMX target version.
189+
/// </returns>
190+
private static HtmxTargetVersion GetTargetVersion(HttpResponse response)
191+
{
192+
var p = response.HttpContext.RequestServices;
193+
var options = p.GetService(typeof(IOptions<HtmxToolkitOptions>)) as IOptions<HtmxToolkitOptions>;
194+
195+
return options?.Value.TargetVersion ?? HtmxTargetVersion.V2;
196+
}
159197
}

tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using Microsoft.AspNetCore.Http;
1+
using Ramstack.HtmxToolkit.Configuration;
22

33
namespace Ramstack.HtmxToolkit.Tests;
44

@@ -8,7 +8,7 @@ public class HtmxResponseHeadersTests
88
[Test]
99
public void Properties_RoundTrips()
1010
{
11-
var context = new DefaultHttpContext();
11+
var context = TestHelper.CreateHttpContext();
1212
var headers = context.Response.GetHtmxHeaders();
1313

1414
headers.Location = "/foo";
@@ -42,7 +42,7 @@ public void Properties_RoundTrips()
4242
[Test]
4343
public void Refresh_WhenTrue_SetsTrue()
4444
{
45-
var context = new DefaultHttpContext();
45+
var context = TestHelper.CreateHttpContext();
4646
var headers = context.Response.GetHtmxHeaders();
4747

4848
headers.Refresh = true;
@@ -54,7 +54,7 @@ public void Refresh_WhenTrue_SetsTrue()
5454
[Test]
5555
public void Refresh_IsFalse_ByDefault()
5656
{
57-
var context = new DefaultHttpContext();
57+
var context = TestHelper.CreateHttpContext();
5858
var headers = context.Response.GetHtmxHeaders();
5959

6060
Assert.That(headers.Refresh, Is.False);
@@ -63,7 +63,7 @@ public void Refresh_IsFalse_ByDefault()
6363
[Test]
6464
public void Reswap_IsNull_WhenHeaderAbsent()
6565
{
66-
var context = new DefaultHttpContext();
66+
var context = TestHelper.CreateHttpContext();
6767
var headers = context.Response.GetHtmxHeaders();
6868

6969
Assert.That(headers.Reswap, Is.Null);
@@ -72,7 +72,7 @@ public void Reswap_IsNull_WhenHeaderAbsent()
7272
[Test]
7373
public void Reswap_IsNull_WhenHeaderUnknown()
7474
{
75-
var context = new DefaultHttpContext();
75+
var context = TestHelper.CreateHttpContext();
7676
var headers = context.Response.GetHtmxHeaders();
7777

7878
context.Response.Headers[HtmxResponseHeaderNames.Reswap] = "bogus";
@@ -83,7 +83,7 @@ public void Reswap_IsNull_WhenHeaderUnknown()
8383
[Test]
8484
public void ReswapExpression_KeepsFullExpression_WhileReswapParsesOnlyStyle()
8585
{
86-
var context = new DefaultHttpContext();
86+
var context = TestHelper.CreateHttpContext();
8787
var headers = context.Response.GetHtmxHeaders();
8888

8989
headers.ReswapExpression = "outerHTML show:top";
@@ -95,7 +95,7 @@ public void ReswapExpression_KeepsFullExpression_WhileReswapParsesOnlyStyle()
9595
[Test]
9696
public void SettingNull_DoesNotAddHeader()
9797
{
98-
var context = new DefaultHttpContext();
98+
var context = TestHelper.CreateHttpContext();
9999
var headers = context.Response.GetHtmxHeaders();
100100

101101
headers.Location = null!;
@@ -106,11 +106,37 @@ public void SettingNull_DoesNotAddHeader()
106106
[Test]
107107
public void Trigger_IsNull_WhenNotSet()
108108
{
109-
var context = new DefaultHttpContext();
109+
var context = TestHelper.CreateHttpContext();
110110
var headers = context.Response.GetHtmxHeaders();
111111

112112
Assert.That(headers.Trigger, Is.Null);
113113
Assert.That(headers.TriggerAfterSwap, Is.Null);
114114
Assert.That(headers.TriggerAfterSettle, Is.Null);
115115
}
116+
117+
[Test]
118+
public void TriggerTimingProperties_Htmx4_AliasReceiveTrigger()
119+
{
120+
var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4);
121+
var headers = context.Response.GetHtmxHeaders();
122+
var events = new Dictionary<string, object> { ["swapped"] = true };
123+
124+
headers.TriggerAfterSwap = events;
125+
126+
Assert.Multiple(() =>
127+
{
128+
Assert.That(headers.Trigger, Is.EqualTo(events));
129+
Assert.That(headers.TriggerAfterSwap, Is.EqualTo(events));
130+
Assert.That(headers.TriggerAfterSettle, Is.EqualTo(events));
131+
});
132+
133+
PendingEvents.GetOrCreate(context.Response).Flush();
134+
135+
Assert.Multiple(() =>
136+
{
137+
Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Trigger), Is.True);
138+
Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSwap), Is.False);
139+
Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.TriggerAfterSettle), Is.False);
140+
});
141+
}
116142
}

tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using Ramstack.HtmxToolkit.Configuration;
2+
13
namespace Ramstack.HtmxToolkit.Tests;
24

35
[TestFixture]
@@ -260,4 +262,25 @@ public void TriggerEvents_SetsMultipleEvents()
260262
Assert.That(events["a"], Is.EqualTo(1));
261263
Assert.That(events["b"], Is.EqualTo(2));
262264
}
265+
266+
[Test]
267+
public void TriggerEvent_Htmx4_AddsEveryTimingToReceiveTrigger()
268+
{
269+
var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4);
270+
271+
context.Response.Htmx(r => r
272+
.TriggerEvent("received")
273+
.TriggerEvent("swapped", HtmxTriggerTiming.AfterSwap)
274+
.TriggerEvent("settled", HtmxTriggerTiming.AfterSettle));
275+
276+
var headers = context.Response.GetHtmxHeaders();
277+
var events = headers.Trigger!;
278+
279+
Assert.Multiple(() =>
280+
{
281+
Assert.That(events.Keys, Is.EqualTo(["received", "swapped", "settled"]));
282+
Assert.That(headers.TriggerAfterSwap, Is.SameAs(events));
283+
Assert.That(headers.TriggerAfterSettle, Is.SameAs(events));
284+
});
285+
}
263286
}

0 commit comments

Comments
 (0)