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()); } }