diff --git a/docs/articles/migrate-to-v2.md b/docs/articles/migrate-to-v2.md
index 5413dc4..3d7f5b1 100644
--- a/docs/articles/migrate-to-v2.md
+++ b/docs/articles/migrate-to-v2.md
@@ -28,7 +28,7 @@ step; see [Version compatibility](version-compatibility.md) for that.
| [HX-Location](#replace-ajaxcontext-with-htmxlocationoptions) | `AjaxContext` becomes `HtmxLocationOptions` | Build |
| [Client events](#update-client-events) | Event names and detail dictionary keys keep their case; repeated names are kept | Build, **silent** |
| [Typed response headers](#update-typed-response-headers) | `HtmxResponseHeaders` is a struct; event properties are read-only | Build |
-| [`[HtmxResponse]`](#update-htmxresponse) | Restricted to classes and methods, one per target, inherited | Build, **silent** |
+| [`[HtmxResponse]`](#update-htmxresponse) | Restricted to classes and methods, one per target, inherited; code overrides it | Build, **silent** |
## Update the package
@@ -459,6 +459,9 @@ Other changes:
- Merge several `[HtmxResponse]` attributes on one target into one. Duplicates and other targets are now build errors.
- An attribute on a base controller or page model now applies to derived classes. In 1.x, it was ignored there.
An attribute on a derived class replaces the inherited one.
+- The attribute sets its headers before the action or page handler runs, so values set in code take precedence.
+ In 1.x, it set them before the result executed and replaced the values that the action had set,
+ for example with `Response.Htmx(htmx => htmx.Retarget(...))`. Review actions that rely on the attribute winning.
- `StopPolling` was removed; see [Replace StopPolling](#replace-stoppolling).
## Other changes
diff --git a/docs/articles/responses.md b/docs/articles/responses.md
index d227f4d..bbb184d 100644
--- a/docs/articles/responses.md
+++ b/docs/articles/responses.md
@@ -312,6 +312,10 @@ When a controller and its action specify the same header, the action's value tak
A derived class inherits the attribute from its base class; an attribute declared on the derived class
replaces the inherited one entirely.
+The attribute sets its headers before the action or page handler runs, so it works as a default.
+The code can replace a header with `Response.Htmx(...)` or remove it through `Response.GetHtmxHeaders()`,
+for example to retarget validation errors.
+
## Access headers directly
Use `Response.GetHtmxHeaders()` when middleware or shared infrastructure needs property access instead of fluent chaining:
diff --git a/src/Ramstack.HtmxToolkit/HtmxResponseAttribute.cs b/src/Ramstack.HtmxToolkit/HtmxResponseAttribute.cs
index 2d8c48e..1d5479e 100644
--- a/src/Ramstack.HtmxToolkit/HtmxResponseAttribute.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxResponseAttribute.cs
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;
using Ramstack.HtmxToolkit.Internal;
@@ -8,13 +9,16 @@
namespace Ramstack.HtmxToolkit;
///
-/// Specifies HTMX response headers to apply when an action result is executed for an HTMX request.
+/// Specifies HTMX response headers to apply to the response to an HTMX request
+/// before the action or page handler runs.
///
///
///
/// A property that is not set, or is set to , ,
-/// or an empty string, adds no header. The specified headers replace any values that the action
-/// has already set for them.
+/// or an empty string, adds no header. Because the headers are set before the action or page handler runs,
+/// the code can replace or remove them, for example with
+/// .
+/// If another filter short-circuits the action or page handler, the headers are not set.
///
///
/// The attribute can be applied to an action, a controller, or a Razor Pages page model.
@@ -28,7 +32,7 @@ namespace Ramstack.HtmxToolkit;
///
///
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)]
-public sealed class HtmxResponseAttribute : Attribute, IResultFilter
+public sealed class HtmxResponseAttribute : Attribute, IActionFilter, IPageFilter
{
private readonly List<(string Key, string Value)> _headers = [];
@@ -94,22 +98,42 @@ public string Reselect
}
///
- public void OnResultExecuting(ResultExecutingContext context)
+ public void OnActionExecuting(ActionExecutingContext context) =>
+ ApplyHeaders(context.HttpContext);
+
+ ///
+ public void OnActionExecuted(ActionExecutedContext context)
{
- if (context.HttpContext.Request.IsHtmxRequest())
- {
- var response = context.HttpContext.Response;
- var headers = response.Headers;
- foreach (ref var kvp in CollectionsMarshal.AsSpan(_headers))
- headers[kvp.Key] = kvp.Value;
- }
}
///
- public void OnResultExecuted(ResultExecutedContext context)
+ public void OnPageHandlerSelected(PageHandlerSelectedContext context)
{
}
+ ///
+ public void OnPageHandlerExecuting(PageHandlerExecutingContext context) =>
+ ApplyHeaders(context.HttpContext);
+
+ ///
+ public void OnPageHandlerExecuted(PageHandlerExecutedContext context)
+ {
+ }
+
+ ///
+ /// Sets the specified headers if the request is an HTMX request.
+ ///
+ /// The HTTP context of the request.
+ private void ApplyHeaders(HttpContext context)
+ {
+ if (context.Request.IsHtmxRequest())
+ {
+ var headers = context.Response.Headers;
+ foreach (ref var kvp in CollectionsMarshal.AsSpan(_headers))
+ headers[kvp.Key] = kvp.Value;
+ }
+ }
+
///
/// Gets the pending value of the specified response header.
///
diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs
index 46de7d6..e886cf8 100644
--- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs
+++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Routing;
namespace Ramstack.HtmxToolkit.Tests;
@@ -10,7 +11,7 @@ namespace Ramstack.HtmxToolkit.Tests;
public class HtmxResponseAttributeTests
{
[Test]
- public void OnResultExecuting_SetsHeaders_ForHtmxRequest()
+ public void OnActionExecuting_SetsHeaders_ForHtmxRequest()
{
var ctx = TestHelper.CreateHtmxRequestContext();
var res = new HtmxResponseAttribute
@@ -21,7 +22,7 @@ public void OnResultExecuting_SetsHeaders_ForHtmxRequest()
Reselect = "#list"
};
- res.OnResultExecuting(CreateContext(ctx));
+ res.OnActionExecuting(CreateActionContext(ctx));
Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Refresh], Is.EqualTo("true"));
Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Reswap], Is.EqualTo("outerHTML"));
@@ -30,25 +31,36 @@ public void OnResultExecuting_SetsHeaders_ForHtmxRequest()
}
[Test]
- public void OnResultExecuting_SetsNoHeaders_ForNonHtmxRequest()
+ public void OnPageHandlerExecuting_SetsHeaders_ForHtmxRequest()
+ {
+ var ctx = TestHelper.CreateHtmxRequestContext();
+ var res = new HtmxResponseAttribute { Retarget = "#content" };
+
+ res.OnPageHandlerExecuting(CreatePageHandlerContext(ctx));
+
+ Assert.That(ctx.Response.Headers[HtmxResponseHeaderNames.Retarget], Is.EqualTo("#content"));
+ }
+
+ [Test]
+ public void OnActionExecuting_SetsNoHeaders_ForNonHtmxRequest()
{
var ctx = TestHelper.CreateHttpContext();
var res = new HtmxResponseAttribute { Retarget = "#content" };
- res.OnResultExecuting(CreateContext(ctx));
+ res.OnActionExecuting(CreateActionContext(ctx));
Assert.That(ctx.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False);
}
[Test]
- public void OnResultExecuting_SetsNoHeaders_ForHistoryRestoreRequest()
+ public void OnActionExecuting_SetsNoHeaders_ForHistoryRestoreRequest()
{
var ctx = TestHelper.CreateHttpContext(
(HtmxRequestHeaderNames.Request, "true"),
(HtmxRequestHeaderNames.HistoryRestoreRequest, "true"));
var res = new HtmxResponseAttribute { Retarget = "#content" };
- res.OnResultExecuting(CreateContext(ctx));
+ res.OnActionExecuting(CreateActionContext(ctx));
Assert.That(ctx.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False);
}
@@ -79,12 +91,22 @@ public void ReswapExpression_RoundTrips()
Assert.That(res.ReswapExpression, Is.EqualTo("outerHTML ignoreTitle:true"));
}
- private static ResultExecutingContext CreateContext(HttpContext context)
+ private static ActionExecutingContext CreateActionContext(HttpContext context)
{
- return new ResultExecutingContext(
+ return new ActionExecutingContext(
new ActionContext(context, new RouteData(), new ActionDescriptor()),
new List(),
- new EmptyResult(),
+ new Dictionary(),
+ new object());
+ }
+
+ private static PageHandlerExecutingContext CreatePageHandlerContext(HttpContext context)
+ {
+ return new PageHandlerExecutingContext(
+ new PageContext(new ActionContext(context, new RouteData(), new CompiledPageActionDescriptor())),
+ new List(),
+ handlerMethod: null,
+ new Dictionary(),
new object());
}
}