From 2551047d1b83c69c04638ffeea03afad4632c458 Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 29 Oct 2026 18:28:50 +0500 Subject: [PATCH 1/2] http: apply [HtmxResponse] headers before the action runs The attribute set its headers in OnResultExecuting, after the action had run, so it replaced the values that the action set in code, such as a Response.Htmx(htmx => htmx.Retarget(...)) call for validation errors. Code inside the action is more specific than an attribute, in the same way that an action's attribute already wins over the controller's. The attribute is now an action filter and a page filter, and sets the headers before the action or page handler runs. The code can replace or remove them, and the action's attribute still wins over the controller's. --- .../HtmxResponseAttribute.cs | 50 ++++++++++++++----- .../HtmxResponseAttributeTests.cs | 40 +++++++++++---- 2 files changed, 68 insertions(+), 22 deletions(-) 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()); } } From db74ae9cdd05753a80e51de35234c16ee908c2dd Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 29 Oct 2026 18:35:48 +0500 Subject: [PATCH 2/2] http: document that code overrides [HtmxResponse] --- docs/articles/migrate-to-v2.md | 5 ++++- docs/articles/responses.md | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) 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: