diff --git a/docs/articles/requests.md b/docs/articles/requests.md index 205c14c..42e009d 100644 --- a/docs/articles/requests.md +++ b/docs/articles/requests.md @@ -21,7 +21,8 @@ public IActionResult Details(int id) Everything else gets the page: direct visits, boosted navigation that replaces the ``, `hx-select` requests, and history restoration. See [Check full or partial requests](#check-full-or-partial-requests). -`IsHtmxRequest()` tells whether HTMX sent the request at all. +`IsHtmxRequest()` tells whether HTMX sent the request as an HTMX request: the `HX-Request` header is present, +and the request does not restore history. See [Handle history restoration](#handle-history-restoration). The overload with `out HtmxRequestHeaders` combines detection and strongly typed header access: ```csharp @@ -55,7 +56,8 @@ var htmx = Request.GetHtmxHeaders(); | `RequestType` | `HtmxRequestType.Partial` or `HtmxRequestType.Full`; added by the Toolkit script in HTMX 1.9.x and 2.x | Missing or version-inapplicable string headers return `null`. -`Request` is `true` whenever the `HX-Request` header is present, whatever its value. +`Request` is `true` whenever the `HX-Request` header is present, whatever its value; +unlike `IsHtmxRequest()`, it does not exclude history restoration. `Boosted` and `HistoryRestoreRequest` are `true` only when their header is exactly `true`. In HTMX 4.x, the `hx-prompt` extension sends the prompt answer URI-encoded; decode it with `Uri.UnescapeDataString` before use. @@ -71,6 +73,19 @@ When `HistoryRestoreRequest` is true, return the full document rather than a tar and HTMX 1.9.x and 2.x send no `HX-Request-Type`, which `RequestType` reports as `HtmxRequestType.Full`. So `IsHtmxPartialRequest()` returns `false`, and the example in [Detect a request](#detect-a-request) returns the page. +HTMX treats history restoration as a full page load, not as an HTMX request, so `IsHtmxRequest()` returns `false` +for it in every version. HTMX 4.x sends this request without `HX-Request`, and so does HTMX 2.x +when `HistoryRestoreAsHxRequest` is `false`. HTMX 1.9.x, and HTMX 2.x by default, send the header, +so `IsHtmxRequest()` also checks `HistoryRestoreRequest`. As a result: + +- code that returns a fragment to every HTMX request still returns the page; +- `[HtmxRequest]` does not select an action for the request; +- `Response.Htmx(...)` and `[HtmxResponse]` add no headers. HTMX 1.9.x and 2.x do not process response headers + when they restore history. + +`HistoryRestoreRequest`, `RequestType`, and `IsHtmxFullRequest()` still identify the request. +To check for the `HX-Request` header alone, read `Request` from `GetHtmxHeaders()`. + HTMX 1.9.x and 2.x send this request after a miss in their local snapshot cache. HTMX 4.x requests history entries from the server by default; see [History behavior](configuration-v4.md#history-behavior). @@ -135,7 +150,8 @@ HTMX requests for a fragment go to `ProfileFragment`. Everything else, including boosted navigation that replaces the ``, and history restoration, goes to `Profile`. `RequestType = HtmxRequestType.Unspecified`, the default, does not restrict the request type. -`Full` and `Partial` require a matching `RequestType`. History restoration requests match `Full`. +`Full` and `Partial` require a matching `RequestType`. History restoration requests are not HTMX requests, +so the attribute rejects them whatever `RequestType` is, and they reach the unconstrained action. Other requests without the `HX-Request-Type` header, such as HTMX 1.9.x and 2.x requests from a page without the Toolkit script, match neither. diff --git a/docs/articles/responses.md b/docs/articles/responses.md index 6fa0a5c..c233120 100644 --- a/docs/articles/responses.md +++ b/docs/articles/responses.md @@ -17,7 +17,7 @@ return PartialView("_Content", model); This example assumes a fragment endpoint. `Response.Htmx(...)` changes headers, not the result body or status code; choose `PartialView`, `View`, or another result to match the request. -The callback runs only for an HTMX request. An action that also handles normal navigation can use separate results: +The callback runs only when `Request.IsHtmxRequest()` is `true`, so not for history restoration. An action that also handles normal navigation can use separate results: ```csharp var url = Url.Action("Index", "Dashboard")!; diff --git a/docs/articles/troubleshooting.md b/docs/articles/troubleshooting.md index f83f076..23c882a 100644 --- a/docs/articles/troubleshooting.md +++ b/docs/articles/troubleshooting.md @@ -178,8 +178,9 @@ See [Static assets and deployment](static-assets.md) for the complete setup and ## Response has no HX headers -`Response.Htmx(...)` intentionally invokes its callback only when the incoming request contains the HTMX request marker. -Confirm the request is actually sent by HTMX. +`Response.Htmx(...)` intentionally invokes its callback only when `Request.IsHtmxRequest()` is `true`: +the incoming request contains the HTMX request marker and does not restore history. +Confirm the request is actually sent by HTMX and is not a history restoration request. For infrastructure that must write a header unconditionally, use `Response.GetHtmxHeaders()` and make the condition explicit. diff --git a/docs/articles/version-compatibility.md b/docs/articles/version-compatibility.md index 562fb74..63d5ad4 100644 --- a/docs/articles/version-compatibility.md +++ b/docs/articles/version-compatibility.md @@ -7,7 +7,7 @@ and response events use the behavior expected by that version. | Information | HTMX 1.9.x / 2.x | HTMX 4.x | Toolkit property | |---|---|---|---| -| HTMX request marker | `HX-Request` | `HX-Request` | `Request` | +| HTMX request marker | `HX-Request`, by default also on history restoration | `HX-Request`, except on history restoration | `Request` | | Boosted navigation | `HX-Boosted` | `HX-Boosted` | `Boosted` | | Current browser URL | `HX-Current-URL` | `HX-Current-URL` | `CurrentUrl` | | History cache miss | `HX-History-Restore-Request` | `HX-History-Restore-Request` | `HistoryRestoreRequest` | @@ -16,6 +16,8 @@ and response events use the behavior expected by that version. | Prompt result | `HX-Prompt` | `HX-Prompt` from the `hx-prompt` extension, URI-encoded | `Prompt` | | Expected response | `HX-Request-Type`, added by the Toolkit script | `HX-Request-Type`: `partial` or `full` | `RequestType` | +`IsHtmxRequest()` returns `false` for history restoration in every version, +even when HTMX 1.9.x or 2.x sends `HX-Request` with it; see [Handle history restoration](requests.md#handle-history-restoration). In HTMX 4.x, a target value such as `div#results` includes the tag name as well as the ID. Code shared across versions should tolerate null for version-specific string properties. `RequestType` is `HtmxRequestType.Unspecified` for HTMX 1.9.x and 2.x requests from a page without the Toolkit script. diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs b/src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs index 9181cac..0702778 100644 --- a/src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs +++ b/src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs @@ -5,6 +5,12 @@ namespace Ramstack.HtmxToolkit; /// /// Restricts an action to HTMX requests. /// +/// +/// The attribute accepts the requests for which +/// returns +/// . History restoration requests are not among them, because HTMX treats them +/// as full page loads, so MVC selects an action without the attribute for the same route. +/// [AttributeUsage(AttributeTargets.Method)] public sealed class HtmxRequestAttribute : Attribute, IActionConstraint { @@ -22,8 +28,7 @@ public sealed class HtmxRequestAttribute : Attribute, IActionConstraint /// /// /// HTMX 4.x sends the HX-Request-Type header itself; for HTMX 1.9.x and 2.x, - /// the Toolkit script (htmx-toolkit.js) adds it. A history restoration request - /// without the header matches . + /// the Toolkit script (htmx-toolkit.js) adds it. /// /// public HtmxRequestType RequestType { get; set; } diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs index 9a6c089..570de74 100644 --- a/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs +++ b/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs @@ -69,10 +69,15 @@ internal HtmxRequestHeaders(HttpRequest request) => public string? Prompt => GetString(_headers, HtmxRequestHeaderNames.Prompt); /// - /// Gets a value indicating whether the current request is an HTMX request. + /// Gets a value indicating whether the request has the HX-Request header. /// /// - /// The header name is . + /// The header name is . + /// + /// HTMX 1.9.x and 2.x send this header with history restoration requests by default, + /// although HTMX treats them as full page loads. + /// excludes such requests. + /// /// public bool Request => _headers.ContainsKey(HtmxRequestHeaderNames.Request); diff --git a/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs b/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs index f372eed..9a7374e 100644 --- a/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs +++ b/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs @@ -8,20 +8,27 @@ namespace Ramstack.HtmxToolkit; public static class HttpRequestExtensions { /// - /// Determines whether the specified HTTP request is an HTMX request - /// by checking for the HX-Request header. + /// Determines whether the specified HTTP request is an HTMX request. /// /// The HTTP request. /// - /// if the specified HTTP request is an HTMX request; - /// otherwise, . + /// if the request has the HX-Request header + /// and is not a history restoration request; otherwise, . /// + /// + /// + /// HTMX treats history restoration as a full page load rather than an HTMX request. + /// HTMX 4.x sends it without HX-Request, but HTMX 1.9.x and 2.x send that header by default, + /// so the method also checks . + /// See issue #4139. + /// + /// reports the HX-Request header alone. + /// public static bool IsHtmxRequest(this HttpRequest request) => - request.GetHtmxHeaders().Request; + request.IsHtmxRequest(out _); /// - /// Determines whether the specified HTTP request is an HTMX request - /// by checking for the HX-Request header. + /// Determines whether the specified HTTP request is an HTMX request. /// /// The HTTP request. /// @@ -29,13 +36,16 @@ public static bool IsHtmxRequest(this HttpRequest request) => /// The value is set even when the method returns . /// /// - /// if the specified HTTP request is an HTMX request; - /// otherwise, . + /// if the request has the HX-Request header + /// and is not a history restoration request; otherwise, . /// + /// + /// See . + /// public static bool IsHtmxRequest(this HttpRequest request, out HtmxRequestHeaders headers) { headers = new HtmxRequestHeaders(request); - return headers.Request; + return headers is { Request: true, HistoryRestoreRequest: false }; } /// diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs index 95fbde1..3dc1607 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs @@ -76,15 +76,29 @@ public void Accept_UsesRequestType(HtmxRequestType requestType, string? requestT Assert.That(req.Accept(CreateContext(ctx)), Is.EqualTo(expected)); } - [TestCase(HtmxRequestType.Full, true)] - [TestCase(HtmxRequestType.Partial, false)] - public void Accept_TreatsHistoryRestoreRequestAsFull(HtmxRequestType requestType, bool expected) + [TestCase(HtmxRequestType.Unspecified, true, null)] + [TestCase(HtmxRequestType.Full, true, null)] + [TestCase(HtmxRequestType.Partial, true, null)] + [TestCase(HtmxRequestType.Unspecified, false, null)] + [TestCase(HtmxRequestType.Full, false, null)] + [TestCase(HtmxRequestType.Partial, false, null)] + [TestCase(HtmxRequestType.Unspecified, false, "full")] + [TestCase(HtmxRequestType.Full, false, "full")] + [TestCase(HtmxRequestType.Partial, false, "full")] + public void Accept_ReturnsFalse_ForHistoryRestoreRequest(HtmxRequestType requestType, bool hxRequest, string? requestTypeHeader) { + // HTMX 1.9.x and 2.x send HX-Request by default, HTMX 2.x with historyRestoreAsHxRequest: false + // sends neither HX-Request nor HX-Request-Type, and HTMX 4.x sends only HX-Request-Type: full. var req = new HtmxRequestAttribute { RequestType = requestType }; - var ctx = TestHelper.CreateHtmxRequestContext(); - ctx.Request.Headers[HtmxRequestHeaderNames.HistoryRestoreRequest] = "true"; + var ctx = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.HistoryRestoreRequest, "true")); - Assert.That(req.Accept(CreateContext(ctx)), Is.EqualTo(expected)); + if (hxRequest) + ctx.Request.Headers[HtmxRequestHeaderNames.Request] = "true"; + + if (requestTypeHeader is not null) + ctx.Request.Headers[HtmxRequestHeaderNames.RequestType] = requestTypeHeader; + + Assert.That(req.Accept(CreateContext(ctx)), Is.False); } [TestCase(HtmxRequestType.Full, HtmxBoostedFilter.Boosted, "full", true, true)] diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs index 1e29013..46de7d6 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs @@ -40,6 +40,19 @@ public void OnResultExecuting_SetsNoHeaders_ForNonHtmxRequest() Assert.That(ctx.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False); } + [Test] + public void OnResultExecuting_SetsNoHeaders_ForHistoryRestoreRequest() + { + var ctx = TestHelper.CreateHttpContext( + (HtmxRequestHeaderNames.Request, "true"), + (HtmxRequestHeaderNames.HistoryRestoreRequest, "true")); + var res = new HtmxResponseAttribute { Retarget = "#content" }; + + res.OnResultExecuting(CreateContext(ctx)); + + Assert.That(ctx.Response.Headers.ContainsKey(HtmxResponseHeaderNames.Retarget), Is.False); + } + [Test] public void Properties_RoundTrips() { diff --git a/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs index 19bf119..a96df16 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs @@ -28,6 +28,19 @@ public void IsHtmxRequest_WithOutParameter_ReturnsHeaders() Assert.That(headers.Target, Is.EqualTo("foo")); } + [TestCase("true", false)] + [TestCase("false", true)] + public void IsHtmxRequest_ExcludesHistoryRestoreRequest(string historyRestoreRequest, bool expected) + { + var context = TestHelper.CreateHttpContext( + (HtmxRequestHeaderNames.Request, "true"), + (HtmxRequestHeaderNames.HistoryRestoreRequest, historyRestoreRequest)); + + Assert.That(context.Request.IsHtmxRequest(), Is.EqualTo(expected)); + Assert.That(context.Request.IsHtmxRequest(out var headers), Is.EqualTo(expected)); + Assert.That(headers.Request, Is.True); + } + [Test] public void IsHtmxBoosted_ReturnsExpectedValue() { diff --git a/tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs index 617653b..6a2d15d 100644 --- a/tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs +++ b/tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs @@ -54,6 +54,20 @@ public void Htmx_WithState_DoesNotRunConfigure_ForNonHtmxRequest() Assert.That(invoked, Is.False); } + [Test] + public void Htmx_DoesNotRunConfigure_ForHistoryRestoreRequest() + { + var invoked = false; + var context = TestHelper.CreateHttpContext( + (HtmxRequestHeaderNames.Request, "true"), + (HtmxRequestHeaderNames.HistoryRestoreRequest, "true")); + + context.Response.Htmx(_ => invoked = true); + context.Response.Htmx((_, _) => invoked = true, 0); + + Assert.That(invoked, Is.False); + } + [Test] public void GetHtmxHeaders_ReturnsHeadersForResponse() {