Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions docs/articles/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public IActionResult Details(int id)
Everything else gets the page: direct visits, boosted navigation that replaces the `<body>`, `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
Expand Down Expand Up @@ -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.
Expand All @@ -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).
Expand Down Expand Up @@ -135,7 +150,8 @@ HTMX requests for a fragment go to `ProfileFragment`. Everything else, including
boosted navigation that replaces the `<body>`, 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.

Expand Down
2 changes: 1 addition & 1 deletion docs/articles/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")!;
Expand Down
5 changes: 3 additions & 2 deletions docs/articles/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/articles/version-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ namespace Ramstack.HtmxToolkit;
/// <summary>
/// Restricts an action to HTMX requests.
/// </summary>
/// <remarks>
/// The attribute accepts the requests for which
/// <see cref="HttpRequestExtensions.IsHtmxRequest(Microsoft.AspNetCore.Http.HttpRequest)" /> returns
/// <see langword="true" />. 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.
/// </remarks>
[AttributeUsage(AttributeTargets.Method)]
public sealed class HtmxRequestAttribute : Attribute, IActionConstraint
{
Expand All @@ -22,8 +28,7 @@ public sealed class HtmxRequestAttribute : Attribute, IActionConstraint
/// </para>
/// <para>
/// HTMX 4.x sends the <c>HX-Request-Type</c> header itself; for HTMX 1.9.x and 2.x,
/// the Toolkit script (<c>htmx-toolkit.js</c>) adds it. A history restoration request
/// without the header matches <see cref="HtmxRequestType.Full" />.
/// the Toolkit script (<c>htmx-toolkit.js</c>) adds it.
/// </para>
/// </remarks>
public HtmxRequestType RequestType { get; set; }
Expand Down
9 changes: 7 additions & 2 deletions src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,15 @@ internal HtmxRequestHeaders(HttpRequest request) =>
public string? Prompt => GetString(_headers, HtmxRequestHeaderNames.Prompt);

/// <summary>
/// Gets a value indicating whether the current request is an HTMX request.
/// Gets a value indicating whether the request has the <c>HX-Request</c> header.
/// </summary>
/// <remarks>
/// The header name is <see cref="HtmxRequestHeaderNames.Request" />.
/// <para>The header name is <see cref="HtmxRequestHeaderNames.Request" />.</para>
/// <para>
/// HTMX 1.9.x and 2.x send this header with history restoration requests by default,
/// although HTMX treats them as full page loads.
/// <see cref="HttpRequestExtensions.IsHtmxRequest(HttpRequest)" /> excludes such requests.
/// </para>
/// </remarks>
public bool Request => _headers.ContainsKey(HtmxRequestHeaderNames.Request);

Expand Down
30 changes: 20 additions & 10 deletions src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,44 @@ namespace Ramstack.HtmxToolkit;
public static class HttpRequestExtensions
{
/// <summary>
/// Determines whether the specified HTTP request is an HTMX request
/// by checking for the <c>HX-Request</c> header.
/// Determines whether the specified HTTP request is an HTMX request.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <returns>
/// <see langword="true" /> if the specified HTTP request is an HTMX request;
/// otherwise, <see langword="false" />.
/// <see langword="true" /> if the request has the <c>HX-Request</c> header
/// and is not a history restoration request; otherwise, <see langword="false" />.
/// </returns>
/// <remarks>
/// <para>
/// HTMX treats history restoration as a full page load rather than an HTMX request.
/// HTMX 4.x sends it without <c>HX-Request</c>, but HTMX 1.9.x and 2.x send that header by default,
/// so the method also checks <see cref="HtmxRequestHeaders.HistoryRestoreRequest" />.
/// See <see href="https://github.com/bigskysoftware/htmx/issues/4139#issuecomment-6135080415">issue #4139</see>.
/// </para>
/// <para><see cref="HtmxRequestHeaders.Request" /> reports the <c>HX-Request</c> header alone.</para>
/// </remarks>
public static bool IsHtmxRequest(this HttpRequest request) =>
request.GetHtmxHeaders().Request;
request.IsHtmxRequest(out _);

/// <summary>
/// Determines whether the specified HTTP request is an HTMX request
/// by checking for the <c>HX-Request</c> header.
/// Determines whether the specified HTTP request is an HTMX request.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <param name="headers">
/// When this method returns, contains a strongly typed view of the HTMX request headers.
/// The value is set even when the method returns <see langword="false" />.
/// </param>
/// <returns>
/// <see langword="true" /> if the specified HTTP request is an HTMX request;
/// otherwise, <see langword="false" />.
/// <see langword="true" /> if the request has the <c>HX-Request</c> header
/// and is not a history restoration request; otherwise, <see langword="false" />.
/// </returns>
/// <remarks>
/// See <see cref="IsHtmxRequest(HttpRequest)" />.
/// </remarks>
public static bool IsHtmxRequest(this HttpRequest request, out HtmxRequestHeaders headers)
{
headers = new HtmxRequestHeaders(request);
return headers.Request;
return headers is { Request: true, HistoryRestoreRequest: false };
}

/// <summary>
Expand Down
26 changes: 20 additions & 6 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
13 changes: 13 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseAttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
13 changes: 13 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
14 changes: 14 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HttpResponseExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading