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
11 changes: 9 additions & 2 deletions docs/articles/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,13 @@ Confirm the request is actually sent by HTMX and is not a history restoration re
For infrastructure that must write a header unconditionally, use `Response.GetHtmxHeaders()`
and make the condition explicit.

## Setting an HX header throws InvalidOperationException

Response headers, including the events added by `TriggerEvent()`, can be set only before the response starts.
The response starts when the first part of the body is sent, for example when a view calls `FlushAsync()`.
By the time `OnResultExecuted` of a filter runs, the result has usually written the body. Set the headers
before that point, for example in `OnResultExecuting`.

## HX-Redirect does not navigate the browser

Check the response status. HTMX cannot process `HX-Redirect` or other `HX-*` headers from an intermediate 3xx response
Expand Down Expand Up @@ -251,8 +258,8 @@ See [Response events](version-compatibility.md#response-events).
## A response event does not refresh another component

First check the actual response in the Network panel for `HX-Trigger`, `HX-Trigger-After-Swap`, or
`HX-Trigger-After-Settle`. Events added by `TriggerEvent()` are written when the response starts; inspecting
the headers immediately after the method call in server code can be too early.
`HX-Trigger-After-Settle`. `TriggerEvent()` sets the header in `Response.Headers` right away, so server code
can also inspect it after the call.

If the header is present:

Expand Down
21 changes: 21 additions & 0 deletions src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,13 @@ public HtmxResponse Reselect(string value) =>
/// is triggered. In HTMX 4.x, this also applies to a name added with different timings.
/// The event name <c>rs:event</c> is reserved for this purpose and cannot be used.
/// </para>
/// <para>
/// Each call sets the header in <see cref="HttpResponse.Headers" />, replacing its value with
/// the events added for it. If the header has been removed, for example by
/// <see cref="ResponseExtensions.Clear(HttpResponse)" />, the events added before that are discarded.
/// Like any other response header, an event cannot be added after the response has started;
/// the method then throws <see cref="InvalidOperationException" />.
/// </para>
/// </remarks>
public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
AddToPendingEvent(this, eventName, "{}", timing);
Expand Down Expand Up @@ -262,6 +269,13 @@ public HtmxResponse TriggerEvent(string eventName, HtmxTriggerTiming timing = Ht
/// is triggered. In HTMX 4.x, this also applies to a name added with different timings.
/// The event name <c>rs:event</c> is reserved for this purpose and cannot be used.
/// </para>
/// <para>
/// Each call sets the header in <see cref="HttpResponse.Headers" />, replacing its value with
/// the events added for it. If the header has been removed, for example by
/// <see cref="ResponseExtensions.Clear(HttpResponse)" />, the events added before that are discarded.
/// Like any other response header, an event cannot be added after the response has started;
/// the method then throws <see cref="InvalidOperationException" />.
/// </para>
/// </remarks>
[RequiresDynamicCode("Event details are serialized using reflection. Use the TriggerEvent overload that accepts JsonTypeInfo<T> for Native AOT applications.")]
[RequiresUnreferencedCode("Event details are serialized using reflection. Use the TriggerEvent overload that accepts JsonTypeInfo<T> for trimmed applications.")]
Expand Down Expand Up @@ -301,6 +315,13 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob
/// is triggered. In HTMX 4.x, this also applies to a name added with different timings.
/// The event name <c>rs:event</c> is reserved for this purpose and cannot be used.
/// </para>
/// <para>
/// Each call sets the header in <see cref="HttpResponse.Headers" />, replacing its value with
/// the events added for it. If the header has been removed, for example by
/// <see cref="ResponseExtensions.Clear(HttpResponse)" />, the events added before that are discarded.
/// Like any other response header, an event cannot be added after the response has started;
/// the method then throws <see cref="InvalidOperationException" />.
/// </para>
/// </remarks>
public HtmxResponse TriggerEvent<T>(string eventName, T detail, JsonTypeInfo<T> jsonTypeInfo, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
TriggerEventCore(this, eventName, detail, jsonTypeInfo, timing);
Expand Down
18 changes: 9 additions & 9 deletions src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,9 @@ public string? Reselect
/// <para>
/// Each entry pairs an event name with its detail serialized as JSON. Events are listed
/// in the order they were added, including repeated names. The list is a read-only live view
/// of the events accumulated for the current response, which are written into the header
/// immediately before the response starts. If no events have been added,
/// the property returns <see langword="null" />.
/// of the events added for the current response. The header is updated each time an event
/// is added. If no events have been added, or the header has been removed, for example by
/// <see cref="ResponseExtensions.Clear(HttpResponse)" />, the property returns <see langword="null" />.
/// </para>
/// <para>
/// HTMX 1.9.x and 2.x trigger these events when the response is received,
Expand All @@ -181,9 +181,9 @@ public string? Reselect
/// <para>
/// Each entry pairs an event name with its detail serialized as JSON. Events are listed
/// in the order they were added, including repeated names. The list is a read-only live view
/// of the events accumulated for the current response, which are written into the header
/// immediately before the response starts. If no events have been added,
/// the property returns <see langword="null" />.
/// of the events added for the current response. The header is updated each time an event
/// is added. If no events have been added, or the header has been removed, for example by
/// <see cref="ResponseExtensions.Clear(HttpResponse)" />, the property returns <see langword="null" />.
/// </para>
/// <para>
/// HTMX 4.x has no <c>HX-Trigger-After-Swap</c> header, so these events are emitted through
Expand All @@ -201,9 +201,9 @@ public string? Reselect
/// <para>
/// Each entry pairs an event name with its detail serialized as JSON. Events are listed
/// in the order they were added, including repeated names. The list is a read-only live view
/// of the events accumulated for the current response, which are written into the header
/// immediately before the response starts. If no events have been added,
/// the property returns <see langword="null" />.
/// of the events added for the current response. The header is updated each time an event
/// is added. If no events have been added, or the header has been removed, for example by
/// <see cref="ResponseExtensions.Clear(HttpResponse)" />, the property returns <see langword="null" />.
/// </para>
/// <para>
/// HTMX 4.x has no <c>HX-Trigger-After-Settle</c> header, so these events are emitted through
Expand Down
118 changes: 70 additions & 48 deletions src/Ramstack.HtmxToolkit/Internal/PendingEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
namespace Ramstack.HtmxToolkit.Internal;

/// <summary>
/// Accumulates HTMX events by <see cref="HtmxTriggerTiming" /> for a single request,
/// deferring header serialization until the response is about to start.
/// Accumulates HTMX events by <see cref="HtmxTriggerTiming" /> for a single request
/// and sets the corresponding response headers as the events are added.
/// </summary>
internal sealed class PendingEvents
{
Expand Down Expand Up @@ -43,11 +43,17 @@ private PendingEvents(HttpResponse response) =>
(_response, _version) = (response, GetTargetVersion(response));

/// <summary>
/// Adds the specified event to the end of the pending events for <paramref name="timing" />.
/// Adds the specified event to the end of the events for <paramref name="timing" />
/// and rewrites the corresponding response header.
/// </summary>
/// <param name="timing">The time at which to trigger the event.</param>
/// <param name="eventName">The event name.</param>
/// <param name="detailJson">The event detail as JSON.</param>
/// <remarks>
/// The header is the source of truth: if it has been removed, for example by
/// <see cref="ResponseExtensions.Clear(HttpResponse)" /> in an exception handler,
/// the events written into it are discarded as well.
/// </remarks>
public void AddEvent(HtmxTriggerTiming timing, string eventName, string detailJson)
{
Argument.ThrowIfNullOrWhiteSpace(eventName);
Expand All @@ -58,25 +64,35 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, string detailJs
$"The event name '{ProxyEventName}' is reserved.",
nameof(eventName));

if (_response.HasStarted)
throw new InvalidOperationException(
$"Cannot trigger the event '{eventName}' because the response has already started.");

timing = NormalizeTiming(timing);

var events = timing switch
{
HtmxTriggerTiming.Receive => _receive ??= new EventList(),
HtmxTriggerTiming.AfterSwap => _afterSwap ??= new EventList(),
_ => _afterSettle ??= new EventList()
};
var name = GetHeaderName(timing);
ref var events = ref GetEventList(timing);

// The header is missing if it has not been set yet or has been removed, for example
// by Response.Clear() in an exception handler. Start a new list so that the removed
// events are not written back into the header.
if (!_response.Headers.ContainsKey(name))
events = null;

events ??= new EventList();
events.Add(eventName, detailJson);

SetHeader(name, events);
}

/// <summary>
/// Returns the pending events for the specified <paramref name="timing" />
/// Returns the events for the specified <paramref name="timing" />
/// in the order they were added.
/// </summary>
/// <param name="timing">The time at which to trigger the events.</param>
/// <returns>
/// The pending events, or <see langword="null" /> if none were registered.
/// The events, or <see langword="null" /> if none were registered
/// or their header has been removed.
/// </returns>
/// <remarks>
/// Returns a read-only live view of the internal accumulator.
Expand All @@ -85,25 +101,9 @@ public void AddEvent(HtmxTriggerTiming timing, string eventName, string detailJs
{
timing = NormalizeTiming(timing);

return timing switch
{
HtmxTriggerTiming.Receive => _receive,
HtmxTriggerTiming.AfterSwap => _afterSwap,
_ => _afterSettle
};
}

/// <summary>
/// Serializes the accumulated events, if any, into the corresponding
/// <c>HX-Trigger</c> response headers.
/// </summary>
public void Flush()
{
var buffer = new ArrayBufferWriter<byte>();

SetHeader(HtmxResponseHeaderNames.Trigger, _receive, buffer);
SetHeader(HtmxResponseHeaderNames.TriggerAfterSwap, _afterSwap, buffer);
SetHeader(HtmxResponseHeaderNames.TriggerAfterSettle, _afterSettle, buffer);
return _response.Headers.ContainsKey(GetHeaderName(timing))
? GetEventList(timing)
: null;
}

/// <summary>
Expand Down Expand Up @@ -133,35 +133,23 @@ public static PendingEvents GetOrCreate(HttpResponse response)
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;
}

/// <summary>
/// Serializes the specified events into a response header
/// when the collection is not <see langword="null" />.
/// Serializes the specified events into a response header.
/// </summary>
/// <param name="name">The response header name.</param>
/// <param name="events">The events to serialize.</param>
/// <param name="buffer">The buffer that receives the serialized header value.</param>
/// <remarks>
/// HTMX parses the header with <c>JSON.parse</c>, which keeps only the last of repeated names.
/// If an event name repeats, all events are written in order under the <see cref="ProxyEventName" /> key,
/// and the Toolkit script replays them.
/// Writing only the repeats there would change the order in which the events are triggered.
/// </remarks>
private void SetHeader(string name, EventList? events, ArrayBufferWriter<byte> buffer)
private void SetHeader(string name, EventList events)
{
if (events is null)
return;

buffer.Clear();
var buffer = new ArrayBufferWriter<byte>();

using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Encoder = JsonOptions.Encoder, SkipValidation = true }))
{
Expand Down Expand Up @@ -199,6 +187,41 @@ private void SetHeader(string name, EventList? events, ArrayBufferWriter<byte> b
_response.Headers[name] = Encoding.UTF8.GetString(buffer.WrittenSpan);
}

/// <summary>
/// Returns a reference to the field that stores the events for the specified timing.
/// </summary>
/// <param name="timing">The normalized event timing.</param>
/// <returns>
/// A reference to the field that stores the events.
/// </returns>
private ref EventList? GetEventList(HtmxTriggerTiming timing)
{
if (timing == HtmxTriggerTiming.Receive)
return ref _receive;

if (timing == HtmxTriggerTiming.AfterSwap)
return ref _afterSwap;

return ref _afterSettle;
}

/// <summary>
/// Returns the name of the response header that carries the events for the specified timing.
/// </summary>
/// <param name="timing">The normalized event timing.</param>
/// <returns>
/// The response header name.
/// </returns>
private static string GetHeaderName(HtmxTriggerTiming timing)
{
return timing switch
{
HtmxTriggerTiming.Receive => HtmxResponseHeaderNames.Trigger,
HtmxTriggerTiming.AfterSwap => HtmxResponseHeaderNames.TriggerAfterSwap,
_ => HtmxResponseHeaderNames.TriggerAfterSettle
};
}

/// <summary>
/// Determines whether any event name occurs more than once.
/// </summary>
Expand All @@ -209,8 +232,7 @@ private void SetHeader(string name, EventList? events, ArrayBufferWriter<byte> b
private static bool HasRepeatedNames(EventList events)
{
// A response usually carries only a few events, and for them a quadratic scan
// is cheaper than allocating a set. Events can be added in a loop, though,
// so a set protects against a large number of them.
// is cheaper than allocating a set.
var storage = events.Storage;

if (storage.Count <= LinearScanThreshold)
Expand Down Expand Up @@ -248,7 +270,7 @@ private static void WriteDetail(Utf8JsonWriter writer, string detailJson)
}
else
{
writer.WriteRawValue(detailJson);
writer.WriteRawValue(detailJson, skipInputValidation: true);
}
}

Expand Down
2 changes: 0 additions & 2 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseHeadersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,6 @@ public void TriggerTimingProperties_Htmx4_AliasReceiveTrigger()
Assert.That(headers.TriggerAfterSettle, Is.SameAs(headers.Trigger));
});

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

Assert.Multiple(() =>
{
Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Trigger), Is.True);
Expand Down
Loading
Loading