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
2 changes: 2 additions & 0 deletions src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ private static HtmxResponse TriggerEventCore<T>(HtmxResponse response, string ev

private static string SerializeEventDetail<T>(T detail, JsonTypeInfo<T> jsonTypeInfo)
{
ArgumentNullException.ThrowIfNull(jsonTypeInfo);

var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true }))
JsonSerializer.Serialize(writer, detail, jsonTypeInfo);
Expand Down
15 changes: 15 additions & 0 deletions src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ public string Reselect
/// and written into the header immediately before the response starts.
/// </para>
/// <para>
/// The returned dictionary is a live view of the accumulated events, provided for
/// inspection only. Do not cast it to a mutable interface or modify it: doing so
/// corrupts the pending events and breaks header serialization.
/// </para>
/// <para>
/// HTMX 1.x and 2.x trigger these events when the response is received,
/// whereas HTMX 4.x triggers them when the request completes
/// (after the swap whenever one is performed).
Expand All @@ -141,6 +146,11 @@ public string Reselect
/// and written into the header immediately before the response starts.
/// </para>
/// <para>
/// The returned dictionary is a live view of the accumulated events, provided for
/// inspection only. Do not cast it to a mutable interface or modify it: doing so
/// corrupts the pending events and breaks header serialization.
/// </para>
/// <para>
/// In HTMX 4.x, assigned events are accumulated in <see cref="Trigger" />
/// and emitted through <c>HX-Trigger</c> when the request completes
/// (after the swap whenever one is performed).
Expand All @@ -157,6 +167,11 @@ public string Reselect
/// and written into the header immediately before the response starts.
/// </para>
/// <para>
/// The returned dictionary is a live view of the accumulated events, provided for
/// inspection only. Do not cast it to a mutable interface or modify it: doing so
/// corrupts the pending events and breaks header serialization.
/// </para>
/// <para>
/// In HTMX 4.x, assigned events are accumulated in <see cref="Trigger" />
/// and emitted through <c>HX-Trigger</c> when the request completes
/// (after the swap whenever one is performed); the requested after-settle timing
Expand Down
18 changes: 16 additions & 2 deletions src/Ramstack.HtmxToolkit/PendingEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,20 @@ private PendingEvents(HttpResponse response) =>
/// <param name="timing">The time at which to trigger the events.</param>
/// <param name="eventName">The event name.</param>
/// <param name="detail">The event detail.</param>
public void AddEvent(HtmxTriggerTiming timing, string eventName, string detail)
public void AddEvent(HtmxTriggerTiming timing, string eventName, string? detail)
{
ArgumentNullException.ThrowIfNull(eventName);

if (string.IsNullOrWhiteSpace(eventName))
throw new ArgumentException("Event name cannot be empty.", nameof(eventName));

if (eventName == ProxyEventName)
throw new ArgumentException($"The event name '{ProxyEventName}' is reserved.", nameof(eventName));
throw new ArgumentException(
$"The event name '{ProxyEventName}' is reserved.",
nameof(eventName));

if (string.IsNullOrWhiteSpace(detail))
detail = "{}";

timing = NormalizeTiming(timing);

Expand Down Expand Up @@ -76,6 +86,10 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, string detail)
/// <returns>
/// The pending events, or <see langword="null" /> if none were registered.
/// </returns>
/// <remarks>
/// Returns a live view of the internal accumulator for inspection only;
/// callers must not mutate it.
/// </remarks>
public IReadOnlyDictionary<string, object>? GetEvents(HtmxTriggerTiming timing)
{
timing = NormalizeTiming(timing);
Expand Down
37 changes: 37 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,43 @@ public void TriggerEvent_WithIndentedJsonTypeInfo_SerializesImmediatelyAsCompact
});
}

[Test]
public void TriggerEvent_NullObjectDetail_SerializesAsNull()
{
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.TriggerEvent("e", (object?)null!));

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

Assert.That(
context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(),
Is.EqualTo("{\"e\":null}"));
}

[Test]
public void TriggerEvent_EmptyStringDetail_SerializesAsEmptyString()
{
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.TriggerEvent("e", ""));

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

Assert.That(
context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString(),
Is.EqualTo("{\"e\":\"\"}"));
}

[Test]
public void TriggerEvent_NullJsonTypeInfo_ThrowsArgumentNullException()
{
var context = TestHelper.CreateHtmxRequestContext();

var exception = Assert.Throws<ArgumentNullException>(() =>
context.Response.Htmx(static htmx => htmx.TriggerEvent("e", new TriggerEventDetail(), null!)));

Assert.That(exception?.ParamName, Is.EqualTo("jsonTypeInfo"));
}

[Test]
public void TriggerEvent_ReservedProxyEventName_ThrowsArgumentException()
{
Expand Down
54 changes: 54 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,60 @@ public void Flush_SerializesNullEventDetail_AsNull()
Assert.That(header, Is.EqualTo("{\"e\":null}"));
}

[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void AddEvent_NullOrWhitespaceDetail_NormalizesToEmptyObject(string? detail)
{
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvent(HtmxTriggerTiming.Receive, "e", detail);
pending.Flush();

var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString();
Assert.That(header, Is.EqualTo("{\"e\":{}}"));
}

[Test]
public void AddEvent_NullEventName_ThrowsArgumentNullException()
{
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

Assert.Throws<ArgumentNullException>(() =>
pending.AddEvent(HtmxTriggerTiming.Receive, null!, "1"));
}

[TestCase("")]
[TestCase(" ")]
public void AddEvent_EmptyOrWhitespaceEventName_ThrowsArgumentException(string eventName)
{
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

var exception = Assert.Throws<ArgumentException>(() =>
pending.AddEvent(HtmxTriggerTiming.Receive, eventName, "1"));

Assert.That(exception?.ParamName, Is.EqualTo("eventName"));
}

[Test]
public void GetEvents_MutatingReturnedView_BreaksFlush()
{
// The returned dictionary is a live inspection-only view: writing a value
// that is not a serialized JSON fragment corrupts the pending events.
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvent(HtmxTriggerTiming.Receive, "a", "1");

var mutable = (IDictionary<string, object>)pending.GetEvents(HtmxTriggerTiming.Receive)!;
mutable["a"] = 2;

Assert.Throws<InvalidCastException>(pending.Flush);
}

[Test]
public void Flush_RejectsInvalidJsonDetail()
{
Expand Down