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
5 changes: 3 additions & 2 deletions src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ public sealed class HtmxRequestAttribute : Attribute, IActionConstraint
/// <remarks>
/// <para>
/// The default, <see cref="HtmxRequestType.Unspecified" />, does not restrict the request type.
/// Other values require a matching <c>HX-Request-Type</c> header.
/// Other values require a matching <see cref="HtmxRequestHeaders.RequestType" />.
/// </para>
/// <para>
/// HTMX 1.x and 2.x do not send this header. The request type is not inferred from boosted navigation.
/// HTMX 1.x and 2.x send the <c>HX-Request-Type</c> header only when the page includes
/// the companion script <c>htmx-toolkit.js</c>.
/// When <see cref="BoostedFilter" /> is also set, both conditions must be satisfied.
/// </para>
/// </remarks>
Expand Down
5 changes: 3 additions & 2 deletions src/Ramstack.HtmxToolkit/HtmxRequestHeaderNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ public static class HtmxRequestHeaderNames
/// or a full page response.
/// </summary>
/// <remarks>
/// Supported only in HTMX 4.x. The value is <c>"partial"</c> for targeted swaps
/// and <c>"full"</c> for body-level or <c>hx-select</c> requests.
/// The value is <c>"partial"</c> for targeted swaps and <c>"full"</c> for body-level
/// or <c>hx-select</c> requests. HTMX 4.x sends the header itself; for HTMX 1.x and 2.x,
/// the companion script <c>htmx-toolkit.js</c> adds it.
/// </remarks>
public const string RequestType = "HX-Request-Type";

Expand Down
25 changes: 18 additions & 7 deletions src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,13 @@ internal HtmxRequestHeaders(HttpRequest request) =>
/// The header name is <see cref="HtmxRequestHeaderNames.RequestType" />.
/// </para>
/// <para>
/// Supported only in HTMX 4.x. The value is <c>"partial"</c> for targeted swaps
/// and <c>"full"</c> for body-level or <c>hx-select</c> requests.
/// The value is <c>"partial"</c> for targeted swaps and <c>"full"</c> for body-level
/// or <c>hx-select</c> requests. HTMX 4.x sends the header itself; for HTMX 1.x and 2.x,
/// the companion script <c>htmx-toolkit.js</c> adds it.
/// </para>
/// <para>
/// The request type is not inferred from boosted navigation.
/// A history restoration request without the header is reported as
/// <see cref="HtmxRequestType.Full" />, because HTMX restores the whole page from it.
/// </para>
/// </remarks>
public HtmxRequestType RequestType
Expand All @@ -92,15 +94,24 @@ public HtmxRequestType RequestType
{
static HtmxRequestType GetRequestType(IHeaderDictionary dictionary)
{
if (dictionary.TryGetValue(HtmxRequestHeaderNames.RequestType, out var value) && value.Count == 1)
if (dictionary.TryGetValue(HtmxRequestHeaderNames.RequestType, out var value))
{
switch (value[0])
if (value.Count == 1)
{
case "full": return HtmxRequestType.Full;
case "partial": return HtmxRequestType.Partial;
switch (value[0])
{
case "full": return HtmxRequestType.Full;
case "partial": return HtmxRequestType.Partial;
}
}

return HtmxRequestType.Unspecified;
}

// HTMX 1.x and 2.x send history restoration requests without HX-Request-Type.
if (GetBoolean(dictionary, HtmxRequestHeaderNames.HistoryRestoreRequest))
return HtmxRequestType.Full;

return HtmxRequestType.Unspecified;
}

Expand Down
27 changes: 17 additions & 10 deletions src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,34 +46,41 @@ public static bool IsHtmxBoosted(this HttpRequest request) =>
request.GetHtmxHeaders().Boosted;

/// <summary>
/// Determines whether the specified HTTP request has an <c>HX-Request-Type</c> value of <c>"full"</c>.
/// Determines whether the specified HTTP request expects a full page.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <returns>
/// <see langword="true" /> if the header value is <c>"full"</c>;
/// <see langword="true" /> if the <c>HX-Request-Type</c> header value is <c>"full"</c>,
/// or the header is absent and the request restores history;
/// otherwise, <see langword="false" />.
/// </returns>
/// <remarks>
/// Returns <see langword="false" /> when the header is absent,
/// including HTMX 1.x and 2.x requests without this header.
/// HTMX 4.x sends the header itself; for HTMX 1.x and 2.x, the companion
/// script <c>htmx-toolkit.js</c> adds it. See <see cref="HtmxRequestHeaders.RequestType" />.
/// </remarks>
public static bool IsHtmxFullRequest(this HttpRequest request) =>
public static bool IsHtmxFullRequest(this HttpRequest request)
{
// PERF: Compare the header directly instead of using GetHtmxHeaders().RequestType.
// The parser also recognizes "partial", and the JIT does not reliably eliminate
// that extra comparison even when the parser is inlined.
request.Headers.TryGetValue(HtmxRequestHeaderNames.RequestType, out var value) && value is ["full"];
var headers = request.Headers;
if (headers.TryGetValue(HtmxRequestHeaderNames.RequestType, out var value))
return value is ["full"];

return headers.TryGetValue(HtmxRequestHeaderNames.HistoryRestoreRequest, out value) && value is ["true"];
}

/// <summary>
/// Determines whether the specified HTTP request has an <c>HX-Request-Type</c> value of <c>"partial"</c>.
/// Determines whether the specified HTTP request expects an HTML fragment.
/// </summary>
/// <param name="request">The HTTP request.</param>
/// <returns>
/// <see langword="true" /> if the header value is <c>"partial"</c>;
/// <see langword="true" /> if the <c>HX-Request-Type</c> header value is <c>"partial"</c>;
/// otherwise, <see langword="false" />.
/// </returns>
/// <remarks>
/// Returns <see langword="false" /> when the header is absent,
/// including HTMX 1.x and 2.x requests without this header.
/// HTMX 4.x sends the header itself; for HTMX 1.x and 2.x, the companion
/// script <c>htmx-toolkit.js</c> adds it. Without the header, the method returns <see langword="false" />.
/// </remarks>
public static bool IsHtmxPartialRequest(this HttpRequest request) =>
// PERF: See IsHtmxFullRequest.
Expand Down
50 changes: 48 additions & 2 deletions src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
document._r_htmx ||= ((document, htmx) => {
const warn = message => console.warn(`ramstack.htmxtoolkit: ${message}`);
const htmx_major = parseInt(htmx.version);

// The internal API of HTMX 1.x and 2.x. HTMX passes it to init when the extension is defined,
// whether or not any element enables the extension with hx-ext.
let api;

if (htmx.defineExtension) {
htmx.defineExtension("ramstack-morph", {
init(internal_api) {
api = internal_api;
},
isInlineSwap(swap_style) {
return swap_style === "outerMorph" || swap_style === "outerSync";
},
Expand Down Expand Up @@ -122,11 +130,49 @@ document._r_htmx ||= ((document, htmx) => {
add_antiforgery(request.method, request.headers, request.body);
});

// HTMX reads hx-request from the element and its ancestors; the nearest value wins.
// Values computed with "js:" cannot be read without evaluating them, so they are skipped.
const no_headers = elt => {
let result = false;
api.getClosestMatch(elt, e => {
let value = api.getAttributeValue(e, "hx-request")?.trim();
if (value && !/^(js|javascript):/.test(value)) {
try {
let { noHeaders } = JSON.parse(value[0] === "{" ? value : `{${value}}`);
if (noHeaders != null) {
result = !!noHeaders;
return true;
}
}
catch {
// HTMX reports the invalid value itself.
}
}
});

return result;
};

// HTMX 4.x sends HX-Request-Type; HTMX 1.x and 2.x do not, so it is computed here
// the same way: "full" when the response replaces the body or is filtered with a selector.
// htmx:configRequest does not see the select of htmx.ajax and HX-Location requests;
// htmx:beforeRequest does, and its request is open but not yet sent.
listen("htmx:beforeRequest", e => {
if (htmx_major < 4) {
let elt = e.target;
let { xhr, target, select, requestConfig } = e.detail;

if (requestConfig.headers["HX-Request-Type"] == null && !no_headers(elt)) {
select ||= api.getClosestAttributeValue(elt, "hx-select");
let request_type = target === document.body || select && select !== "unset" ? "full" : "partial";
xhr.setRequestHeader("HX-Request-Type", request_type);
}
}
});

// A JSON object cannot hold repeated names, so when an event name repeats, the server
// sends all events of the header under "rs:event". Each event is prepared the way HTMX
// prepares an HX-Trigger entry before passing it to htmx.trigger.
const htmx_major = parseInt(htmx.version);

listen("rs:event", e => {
let elt = e.target;

Expand Down
2 changes: 1 addition & 1 deletion src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ 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)
{
var req = new HtmxRequestAttribute { RequestType = requestType };
var ctx = TestHelper.CreateHtmxRequestContext();
ctx.Request.Headers[HtmxRequestHeaderNames.HistoryRestoreRequest] = "true";

Assert.That(req.Accept(CreateContext(ctx)), Is.EqualTo(expected));
}

[TestCase(HtmxRequestType.Full, HtmxBoostedFilter.Boosted, "full", true, true)]
[TestCase(HtmxRequestType.Full, HtmxBoostedFilter.Boosted, "full", false, false)]
[TestCase(HtmxRequestType.Full, HtmxBoostedFilter.Boosted, "partial", true, false)]
Expand Down
20 changes: 20 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ public void RequestType_IsUnspecified_WhenHeaderAbsent()
Assert.That(headers.RequestType, Is.EqualTo(HtmxRequestType.Unspecified));
}

[TestCase(null, HtmxRequestType.Full)]
[TestCase("full", HtmxRequestType.Full)]
[TestCase("partial", HtmxRequestType.Partial)]
[TestCase("unknown", HtmxRequestType.Unspecified)]
public void RequestType_IsFull_ForHistoryRestoreRequestWithoutHeader(string? value, HtmxRequestType expected)
{
var headers = value is null
? CreateHeaders((HtmxRequestHeaderNames.HistoryRestoreRequest, "true"))
: CreateHeaders((HtmxRequestHeaderNames.HistoryRestoreRequest, "true"), (HtmxRequestHeaderNames.RequestType, value));

Assert.That(headers.RequestType, Is.EqualTo(expected));
}

[Test]
public void RequestType_IsUnspecified_WhenHistoryRestoreRequestIsFalse()
{
var headers = CreateHeaders((HtmxRequestHeaderNames.HistoryRestoreRequest, "false"));
Assert.That(headers.RequestType, Is.EqualTo(HtmxRequestType.Unspecified));
}

[Test]
public void Source_ReturnsHeaderValue()
{
Expand Down
33 changes: 33 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ public void IsHtmxFullRequest_ReturnsExpectedValue()
Assert.That(request.IsHtmxFullRequest(), Is.True);
}

[TestCase(null, true)]
[TestCase("full", true)]
[TestCase("partial", false)]
[TestCase("unknown", false)]
public void IsHtmxFullRequest_ForHistoryRestoreRequest(string? requestType, bool expected)
{
var context = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.HistoryRestoreRequest, "true"));
var request = context.Request;

if (requestType is not null)
request.Headers[HtmxRequestHeaderNames.RequestType] = requestType;

Assert.That(request.IsHtmxFullRequest(), Is.EqualTo(expected));
Assert.That(request.IsHtmxFullRequest(), Is.EqualTo(request.GetHtmxHeaders().RequestType == HtmxRequestType.Full));
}

[Test]
public void IsHtmxFullRequest_ReturnsFalse_WhenHistoryRestoreRequestIsFalse()
{
var context = TestHelper.CreateHttpContext((HtmxRequestHeaderNames.HistoryRestoreRequest, "false"));
Assert.That(context.Request.IsHtmxFullRequest(), Is.False);
}

[Test]
public void IsHtmxPartialRequest_ReturnsExpectedValue()
{
Expand All @@ -66,4 +89,14 @@ public void IsHtmxPartialRequest_ReturnsExpectedValue()
request.Headers[HtmxRequestHeaderNames.RequestType] = "partial";
Assert.That(request.IsHtmxPartialRequest(), Is.True);
}

[Test]
public void IsHtmxPartialRequest_ReturnsFalse_ForHistoryRestoreRequest()
{
var context = TestHelper.CreateHttpContext(
(HtmxRequestHeaderNames.Request, "true"),
(HtmxRequestHeaderNames.HistoryRestoreRequest, "true"));

Assert.That(context.Request.IsHtmxPartialRequest(), Is.False);
}
}
Loading