From f87219be8d9e88350243aea43498a3a4fe7de3bf Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 13 Oct 2026 20:44:18 +0500 Subject: [PATCH 1/2] http: stop treating history restoration as an HTMX request HTMX treats history restoration as a full page load, not as an HTMX request: HTMX 4.x omits HX-Request on it, and HTMX 2.x does the same when historyRestoreAsHxRequest is false. HTMX 1.9.x, and HTMX 2.x by default, still send the header, so IsHtmxRequest() returned true for the same request in some versions and false in others. With the common IsHtmxRequest() ? Partial(...) : View() pattern, HTMX 1.9.x and 2.x got a fragment in place of the page after a history cache miss. IsHtmxRequest() now returns false when HX-History-Restore-Request is true, so history restoration is not an HTMX request in any version. [HtmxRequest], Response.Htmx(...), and [HtmxResponse] follow it. HTMX 1.9.x and 2.x do not process response headers when they restore history, so skipping them loses nothing. HtmxRequestHeaders.Request still reports the HX-Request header alone. https://github.com/bigskysoftware/htmx/issues/4139#issuecomment-6135080415 --- .../HttpRequestExtensions.cs | 4 +-- .../HtmxRequestAttributeTests.cs | 26 ++++++++++++++----- .../HtmxResponseAttributeTests.cs | 13 ++++++++++ .../HttpRequestExtensionsTests.cs | 13 ++++++++++ .../HttpResponseExtensionsTests.cs | 14 ++++++++++ 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs b/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs index f372eed..fe18066 100644 --- a/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs +++ b/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs @@ -17,7 +17,7 @@ public static class HttpRequestExtensions /// otherwise, . /// public static bool IsHtmxRequest(this HttpRequest request) => - request.GetHtmxHeaders().Request; + request.IsHtmxRequest(out _); /// /// Determines whether the specified HTTP request is an HTMX request @@ -35,7 +35,7 @@ public static bool IsHtmxRequest(this HttpRequest request) => 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() { From 88a30773758416c2efe442e386fdcc3c8fde1195 Mon Sep 17 00:00:00 2001 From: rameel Date: Tue, 13 Oct 2026 21:32:00 +0500 Subject: [PATCH 2/2] http: document that history restoration is not an HTMX request --- docs/articles/requests.md | 22 +++++++++++++--- docs/articles/responses.md | 2 +- docs/articles/troubleshooting.md | 5 ++-- docs/articles/version-compatibility.md | 4 ++- .../HtmxRequestAttribute.cs | 9 +++++-- .../HtmxRequestHeaders.cs | 9 +++++-- .../HttpRequestExtensions.cs | 26 +++++++++++++------ 7 files changed, 58 insertions(+), 19 deletions(-) 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 fe18066..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.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,9 +36,12 @@ 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);