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()
{