diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs b/src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs
index f52b658..0bb5adf 100644
--- a/src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxRequestAttribute.cs
@@ -17,10 +17,11 @@ public sealed class HtmxRequestAttribute : Attribute, IActionConstraint
///
///
/// The default, , does not restrict the request type.
- /// Other values require a matching HX-Request-Type header.
+ /// Other values require a matching .
///
///
- /// 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 HX-Request-Type header only when the page includes
+ /// the companion script htmx-toolkit.js.
/// When is also set, both conditions must be satisfied.
///
///
diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestHeaderNames.cs b/src/Ramstack.HtmxToolkit/HtmxRequestHeaderNames.cs
index 3483de5..556f811 100644
--- a/src/Ramstack.HtmxToolkit/HtmxRequestHeaderNames.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxRequestHeaderNames.cs
@@ -50,8 +50,9 @@ public static class HtmxRequestHeaderNames
/// or a full page response.
///
///
- /// Supported only in HTMX 4.x. The value is "partial" for targeted swaps
- /// and "full" for body-level or hx-select requests.
+ /// The value is "partial" for targeted swaps and "full" for body-level
+ /// or hx-select requests. HTMX 4.x sends the header itself; for HTMX 1.x and 2.x,
+ /// the companion script htmx-toolkit.js adds it.
///
public const string RequestType = "HX-Request-Type";
diff --git a/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs
index 6e32f9d..520a305 100644
--- a/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs
@@ -79,11 +79,13 @@ internal HtmxRequestHeaders(HttpRequest request) =>
/// The header name is .
///
///
- /// Supported only in HTMX 4.x. The value is "partial" for targeted swaps
- /// and "full" for body-level or hx-select requests.
+ /// The value is "partial" for targeted swaps and "full" for body-level
+ /// or hx-select requests. HTMX 4.x sends the header itself; for HTMX 1.x and 2.x,
+ /// the companion script htmx-toolkit.js adds it.
///
///
- /// The request type is not inferred from boosted navigation.
+ /// A history restoration request without the header is reported as
+ /// , because HTMX restores the whole page from it.
///
///
public HtmxRequestType RequestType
@@ -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;
}
diff --git a/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs b/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs
index 371510f..71c6414 100644
--- a/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs
+++ b/src/Ramstack.HtmxToolkit/HttpRequestExtensions.cs
@@ -46,34 +46,41 @@ public static bool IsHtmxBoosted(this HttpRequest request) =>
request.GetHtmxHeaders().Boosted;
///
- /// Determines whether the specified HTTP request has an HX-Request-Type value of "full".
+ /// Determines whether the specified HTTP request expects a full page.
///
/// The HTTP request.
///
- /// if the header value is "full";
+ /// if the HX-Request-Type header value is "full",
+ /// or the header is absent and the request restores history;
/// otherwise, .
///
///
- /// Returns 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 htmx-toolkit.js adds it. See .
///
- 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"];
+ }
///
- /// Determines whether the specified HTTP request has an HX-Request-Type value of "partial".
+ /// Determines whether the specified HTTP request expects an HTML fragment.
///
/// The HTTP request.
///
- /// if the header value is "partial";
+ /// if the HX-Request-Type header value is "partial";
/// otherwise, .
///
///
- /// Returns 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 htmx-toolkit.js adds it. Without the header, the method returns .
///
public static bool IsHtmxPartialRequest(this HttpRequest request) =>
// PERF: See IsHtmxFullRequest.
diff --git a/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js
index ee5d796..393e748 100644
--- a/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js
+++ b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.js
@@ -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";
},
@@ -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;
diff --git a/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js
index b2d8c1b..85e2e8b 100644
--- a/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js
+++ b/src/Ramstack.HtmxToolkit/wwwroot/htmx-toolkit.min.js
@@ -1 +1 @@
-document._r_htmx||=((e,t)=>{const r=e=>console.warn(`ramstack.htmxtoolkit: ${e}`);t.defineExtension&&t.defineExtension("ramstack-morph",{isInlineSwap:e=>"outerMorph"===e||"outerSync"===e,handleSwap(e,t,o){if("textContent"===e)return t.textContent=o.textContent,[t];let n="innerMorph"===e?"innerHTML":"outerMorph"===e?"outerHTML":null;if(n){let a=globalThis.Idiomorph;if(a?.morph)return a.morph(t,o.children,{morphStyle:n});r(`Idiomorph is unavailable; falling back from ${e} to ${n}`);let i=[...o.childNodes];return"innerHTML"===n?t.replaceChildren(...i):t.replaceWith(...i),i}if("outerSync"===e){r("outerSync requires HTMX 4.x; falling back to attribute sync and innerHTML");let e=o.firstElementChild;if(e){for(let r of[...t.attributes])e.hasAttribute(r.name)||t.removeAttribute(r.name);for(let r of e.attributes)t.setAttribute(r.name,r.value);let r=e.childNodes;return t.replaceChildren(...r),r}}}});const o=(t,r)=>{e.addEventListener(t,r)},n=e=>{let t=e.querySelector("meta[name='htmx-config']")?.dataset||{};return{headerName:t.antiforgeryHeaderName,formFieldName:t.antiforgeryFormFieldName,requestToken:t.antiforgeryRequestToken}};let a=n(e);const i=(e,t,r)=>{if(!/^get$/i.test(e)){const{headerName:e,formFieldName:o,requestToken:n}=a;n&&(r.has?.(o)||r[o]||(e?t[e]=n:r.set?r.set(o,n):r[o]=n))}},l=e=>{let t=(new DOMParser).parseFromString(e||"","text/html"),r=n(t);r.requestToken&&(a=r)};o("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&l(t.xhr.responseText)}),o("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&l(t.text)}),o("htmx:configRequest",e=>{let t=e.detail;i(t.verb,t.headers,t.parameters)}),o("htmx:config:request",e=>{let t=e.detail.ctx.request;i(t.method,t.headers,t.body)});const s=parseInt(t.version);return o("rs:event",e=>{let r=e.target;for(let{key:o,value:n}of e.detail.value||e.detail){let e=r;4>s?"[object Object]"===Object.prototype.toString.call(n)?2===s&&void 0!==n.target&&(r=e=n.target):n={value:n}:(n?.target&&(e=t.find(n.target)),"object"!=typeof n&&(n={value:n})),t.trigger(e,o,n)}}),!0})(document,htmx);
\ No newline at end of file
+document._r_htmx||=((e,t)=>{const r=e=>console.warn(`ramstack.htmxtoolkit: ${e}`),a=parseInt(t.version);let n;t.defineExtension&&t.defineExtension("ramstack-morph",{init(e){n=e},isInlineSwap:e=>"outerMorph"===e||"outerSync"===e,handleSwap(e,t,a){if("textContent"===e)return t.textContent=a.textContent,[t];let n="innerMorph"===e?"innerHTML":"outerMorph"===e?"outerHTML":null;if(n){let o=globalThis.Idiomorph;if(o?.morph)return o.morph(t,a.children,{morphStyle:n});r(`Idiomorph is unavailable; falling back from ${e} to ${n}`);let l=[...a.childNodes];return"innerHTML"===n?t.replaceChildren(...l):t.replaceWith(...l),l}if("outerSync"===e){r("outerSync requires HTMX 4.x; falling back to attribute sync and innerHTML");let e=a.firstElementChild;if(e){for(let r of[...t.attributes])e.hasAttribute(r.name)||t.removeAttribute(r.name);for(let r of e.attributes)t.setAttribute(r.name,r.value);let r=e.childNodes;return t.replaceChildren(...r),r}}}});const o=(t,r)=>{e.addEventListener(t,r)},l=e=>{let t=e.querySelector("meta[name='htmx-config']")?.dataset||{};return{headerName:t.antiforgeryHeaderName,formFieldName:t.antiforgeryFormFieldName,requestToken:t.antiforgeryRequestToken}};let i=l(e);const s=(e,t,r)=>{if(!/^get$/i.test(e)){const{headerName:e,formFieldName:a,requestToken:n}=i;n&&(r.has?.(a)||r[a]||(e?t[e]=n:r.set?r.set(a,n):r[a]=n))}},u=e=>{let t=(new DOMParser).parseFromString(e||"","text/html"),r=l(t);r.requestToken&&(i=r)};return o("htmx:afterOnLoad",e=>{let t=e.detail;t.boosted&&u(t.xhr.responseText)}),o("htmx:after:request",e=>{let t=e.detail.ctx;t.boosted&&u(t.text)}),o("htmx:configRequest",e=>{let t=e.detail;s(t.verb,t.headers,t.parameters)}),o("htmx:config:request",e=>{let t=e.detail.ctx.request;s(t.method,t.headers,t.body)}),o("htmx:beforeRequest",t=>{if(4>a){let r=t.target,{xhr:a,target:o,select:l,requestConfig:i}=t.detail;null!=i.headers["HX-Request-Type"]||(e=>{let t=!1;return n.getClosestMatch(e,e=>{let r=n.getAttributeValue(e,"hx-request")?.trim();if(r&&!/^(js|javascript):/.test(r))try{let{noHeaders:e}=JSON.parse("{"===r[0]?r:`{${r}}`);if(null!=e)return t=!!e,!0}catch{}}),t})(r)||(l||=n.getClosestAttributeValue(r,"hx-select"),a.setRequestHeader("HX-Request-Type",o===e.body||l&&"unset"!==l?"full":"partial"))}}),o("rs:event",e=>{let r=e.target;for(let{key:n,value:o}of e.detail.value||e.detail){let e=r;4>a?"[object Object]"===Object.prototype.toString.call(o)?2===a&&void 0!==o.target&&(r=e=o.target):o={value:o}:(o?.target&&(e=t.find(o.target)),"object"!=typeof o&&(o={value:o})),t.trigger(e,n,o)}}),!0})(document,htmx);
\ No newline at end of file
diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs
index 6bf7b6f..95fbde1 100644
--- a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs
+++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestAttributeTests.cs
@@ -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)]
diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs
index d863fe7..acdff59 100644
--- a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs
+++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs
@@ -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()
{
diff --git a/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs
index 1aa9bd9..19bf119 100644
--- a/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs
+++ b/tests/Ramstack.HtmxToolkit.Tests/HttpRequestExtensionsTests.cs
@@ -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()
{
@@ -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);
+ }
}