diff --git a/src/Ramstack.HtmxToolkit/HtmxResponse.cs b/src/Ramstack.HtmxToolkit/HtmxResponse.cs
index 88fe4a0..6c705c0 100644
--- a/src/Ramstack.HtmxToolkit/HtmxResponse.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxResponse.cs
@@ -73,24 +73,65 @@ static HtmxResponse LocationImpl(HtmxResponse response, string path, HtmxLocatio
///
/// Sets the HX-Push-Url header to push a new URL onto the browser's history stack.
///
- /// The header value to set.
+ ///
+ /// A relative or same-origin absolute URL to push into the location bar,
+ /// as supported by history.pushState().
+ ///
///
/// The current instance.
///
///
- /// The possible values for this header are:
- ///
- /// -
- /// A relative or same-origin absolute URL to be pushed into the location bar,
- /// as supported by history.pushState().
- ///
- /// -
- /// "false", which prevents the browser's history from being updated.
- ///
- ///
+ ///
+ /// Also removes the HX-Replace-Url header and the legacy HX-Push header,
+ /// which HTMX 1.9.x and 2.x would otherwise prefer. When ,
+ /// , and are combined,
+ /// the last call takes effect.
+ ///
+ ///
+ /// HTMX treats "false" and "true" as control values. Use
+ /// instead of "false". HTMX 4.x replaces "true" with the response URL, while HTMX 1.9.x
+ /// and 2.x push it as the relative URL true.
+ ///
///
- public HtmxResponse PushUrl(string value) =>
- SetHeader(this, HtmxResponseHeaderNames.PushUrl, value);
+ public HtmxResponse PushUrl(string url)
+ {
+ if (string.IsNullOrEmpty(url))
+ throw new ArgumentException("URL cannot be null or empty.", nameof(url));
+
+ return SetHistoryHeader(this, HtmxResponseHeaderNames.PushUrl, url, HtmxResponseHeaderNames.ReplaceUrl);
+ }
+
+ ///
+ /// Sets the HX-Replace-Url header to replace the current URL.
+ ///
+ ///
+ /// A URL to replace the current URL in the location bar. This may be relative or absolute,
+ /// as supported by history.replaceState(),
+ /// but must have the same origin as the current URL.
+ ///
+ ///
+ /// The current instance.
+ ///
+ ///
+ ///
+ /// Also removes the HX-Push-Url header and the legacy HX-Push header,
+ /// which HTMX 1.9.x and 2.x would otherwise prefer. When ,
+ /// , and are combined,
+ /// the last call takes effect.
+ ///
+ ///
+ /// HTMX treats "false" and "true" as control values. Use
+ /// instead of "false". HTMX 4.x replaces "true" with the response URL, while HTMX 1.9.x
+ /// and 2.x use it as the relative URL true.
+ ///
+ ///
+ public HtmxResponse ReplaceUrl(string url)
+ {
+ if (string.IsNullOrEmpty(url))
+ throw new ArgumentException("URL cannot be null or empty.", nameof(url));
+
+ return SetHistoryHeader(this, HtmxResponseHeaderNames.ReplaceUrl, url, HtmxResponseHeaderNames.PushUrl);
+ }
///
/// Sets the HX-Push-Url header to "false" to prevent the browser's
@@ -99,8 +140,20 @@ public HtmxResponse PushUrl(string value) =>
///
/// The current instance.
///
- public HtmxResponse PreventPushUrl() =>
- SetHeader(this, HtmxResponseHeaderNames.PushUrl, "false");
+ ///
+ ///
+ /// Also removes the HX-Replace-Url header and the legacy HX-Push header,
+ /// which HTMX 1.9.x and 2.x would otherwise prefer. When ,
+ /// , and are combined,
+ /// the last call takes effect.
+ ///
+ ///
+ /// The history is not updated even when the hx-push-url or hx-replace-url attribute
+ /// or boosted navigation requests it.
+ ///
+ ///
+ public HtmxResponse PreventHistoryUpdate() =>
+ SetHistoryHeader(this, HtmxResponseHeaderNames.PushUrl, "false", HtmxResponseHeaderNames.ReplaceUrl);
///
/// Sets the HX-Redirect header to perform a client-side redirect with a full-page reload.
@@ -121,40 +174,6 @@ public HtmxResponse Redirect(string value) =>
public HtmxResponse Refresh() =>
SetHeader(this, HtmxResponseHeaderNames.Refresh, "true");
- ///
- /// Sets the HX-Replace-Url header to replace the current URL.
- ///
- /// The header value to set.
- ///
- /// The current instance.
- ///
- ///
- /// The possible values for this header are:
- ///
- /// -
- /// A URL to replace the current URL in the location bar. This may be relative
- /// or absolute, as supported by
- /// history.replaceState(),
- /// but must have the same origin as the current URL.
- ///
- /// -
- /// "false", which prevents the browser's current URL from being updated.
- ///
- ///
- ///
- public HtmxResponse ReplaceUrl(string value) =>
- SetHeader(this, HtmxResponseHeaderNames.ReplaceUrl, value);
-
- ///
- /// Sets the HX-Replace-Url header to "false" to prevent the browser's
- /// current URL from being updated.
- ///
- ///
- /// The current instance.
- ///
- public HtmxResponse PreventReplaceUrl() =>
- SetHeader(this, HtmxResponseHeaderNames.ReplaceUrl, "false");
-
///
/// Sets the HX-Reswap header to specify how the response will be swapped.
///
@@ -272,6 +291,28 @@ private static HtmxResponse SetHeader(HtmxResponse response, string key, string
return response;
}
+ ///
+ /// Sets a browser history header and removes the other history headers, including the legacy
+ /// HX-Push header, so that the last history call takes effect.
+ ///
+ /// The response wrapper to update.
+ /// The name of the history header to set.
+ /// The header value.
+ /// The name of the history header to remove.
+ ///
+ /// The updated response wrapper.
+ ///
+ private static HtmxResponse SetHistoryHeader(HtmxResponse response, string key, string value, string conflictingKey)
+ {
+ // The name of the legacy "HX-Push" header.
+ // - HTMX 1.9.x and 2.x check it before "HX-Push-Url" and "HX-Replace-Url";
+ // - HTMX 4.x ignores it.
+ response._response.Headers.Remove("HX-Push");
+
+ response._response.Headers.Remove(conflictingKey);
+ return SetHeader(response, key, value);
+ }
+
[RequiresDynamicCode("Event details are serialized using reflection.")]
[RequiresUnreferencedCode("Event details are serialized using reflection.")]
private static HtmxResponse TriggerEventCore(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing) =>
diff --git a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
index a46093e..7632d03 100644
--- a/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
+++ b/src/Ramstack.HtmxToolkit/HtmxResponseHeaders.cs
@@ -36,6 +36,10 @@ public string Location
/// Gets or sets the value of the HX-Push-Url header, which pushes a new URL
/// onto the browser's history stack.
///
+ ///
+ /// Unlike , setting this property
+ /// does not remove the HX-Replace-Url header.
+ ///
[MaybeNull]
public string PushUrl
{
@@ -43,6 +47,21 @@ public string PushUrl
set => SetHeader(_response.Headers, HtmxResponseHeaderNames.PushUrl, value);
}
+ ///
+ /// Gets or sets the value of the HX-Replace-Url header, which replaces the current URL
+ /// without pushing a new entry to the browser's history stack.
+ ///
+ ///
+ /// Unlike , setting this property
+ /// does not remove the HX-Push-Url header.
+ ///
+ [MaybeNull]
+ public string ReplaceUrl
+ {
+ get => GetHeader(_response.Headers, HtmxResponseHeaderNames.ReplaceUrl);
+ set => SetHeader(_response.Headers, HtmxResponseHeaderNames.ReplaceUrl, value);
+ }
+
///
/// Gets or sets the value of the HX-Redirect header, which performs
/// a client-side redirect with a full-page reload.
@@ -63,17 +82,6 @@ public bool Refresh
set => SetHeader(_response.Headers, HtmxResponseHeaderNames.Refresh, value ? "true" : null);
}
- ///
- /// Gets or sets the value of the HX-Replace-Url header, which replaces the current URL
- /// without pushing a new entry to the browser's history stack.
- ///
- [MaybeNull]
- public string ReplaceUrl
- {
- get => GetHeader(_response.Headers, HtmxResponseHeaderNames.ReplaceUrl);
- set => SetHeader(_response.Headers, HtmxResponseHeaderNames.ReplaceUrl, value);
- }
-
///
/// Gets or sets the swap style specified by the HX-Reswap header.
///
diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
index d6b1aea..4382d19 100644
--- a/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
+++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
@@ -221,12 +221,72 @@ public void PushUrl_SetsHeader()
}
[Test]
- public void PreventPushUrl_SetsFalse()
+ public void PushUrl_RemovesReplaceUrl()
{
var context = TestHelper.CreateHtmxRequestContext();
- context.Response.Htmx(r => r.PreventPushUrl());
+ context.Response.Htmx(r => r.ReplaceUrl("/foo").PushUrl("/bar"));
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("/bar"));
+ Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.ReplaceUrl), Is.False);
+ }
+
+ [Test]
+ public void PushUrl_AfterPreventHistoryUpdate_SetsUrl()
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+ context.Response.Htmx(r => r.PreventHistoryUpdate().PushUrl("/foo"));
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("/foo"));
+ }
+
+ [Test]
+ public void PushUrl_RemovesLegacyPush()
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+ context.Response.Headers["HX-Push"] = "/legacy";
+ context.Response.Htmx(r => r.PushUrl("/foo"));
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("/foo"));
+ Assert.That(context.Response.Headers.ContainsKey("HX-Push"), Is.False);
+ }
+
+ [Test]
+ public void PreventHistoryUpdate_SetsPushUrlFalse()
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+ context.Response.Htmx(r => r.PreventHistoryUpdate());
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("false"));
+ }
+
+ [Test]
+ public void PreventHistoryUpdate_RemovesReplaceUrl()
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+ context.Response.Htmx(r => r.ReplaceUrl("/foo").PreventHistoryUpdate());
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("false"));
+ Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.ReplaceUrl), Is.False);
+ }
+
+ [Test]
+ public void PreventHistoryUpdate_AfterPushUrl_SetsFalse()
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+ context.Response.Htmx(r => r.PushUrl("/foo").PreventHistoryUpdate());
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("false"));
+ }
+
+ [Test]
+ public void PreventHistoryUpdate_RemovesLegacyPush()
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+ context.Response.Headers["HX-Push"] = "/legacy";
+ context.Response.Htmx(r => r.PreventHistoryUpdate());
Assert.That(context.Response.Headers[HtmxResponseHeaderNames.PushUrl], Is.EqualTo("false"));
+ Assert.That(context.Response.Headers.ContainsKey("HX-Push"), Is.False);
}
[Test]
@@ -257,12 +317,46 @@ public void ReplaceUrl_SetsHeader()
}
[Test]
- public void PreventReplaceUrl_SetsFalse()
+ public void ReplaceUrl_RemovesPushUrl()
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+ context.Response.Htmx(r => r.PushUrl("/foo").ReplaceUrl("/bar"));
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.ReplaceUrl], Is.EqualTo("/bar"));
+ Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.PushUrl), Is.False);
+ }
+
+ [Test]
+ public void ReplaceUrl_RemovesLegacyPush()
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+ context.Response.Headers["HX-Push"] = "/legacy";
+ context.Response.Htmx(r => r.ReplaceUrl("/foo"));
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.ReplaceUrl], Is.EqualTo("/foo"));
+ Assert.That(context.Response.Headers.ContainsKey("HX-Push"), Is.False);
+ }
+
+ [Test]
+ public void ReplaceUrl_AfterPreventHistoryUpdate_RemovesPushUrl()
{
var context = TestHelper.CreateHtmxRequestContext();
- context.Response.Htmx(r => r.PreventReplaceUrl());
+ context.Response.Htmx(r => r.PreventHistoryUpdate().ReplaceUrl("/foo"));
+
+ Assert.That(context.Response.Headers[HtmxResponseHeaderNames.ReplaceUrl], Is.EqualTo("/foo"));
+ Assert.That(context.Response.Headers.ContainsKey(HtmxResponseHeaderNames.PushUrl), Is.False);
+ }
+
+ [TestCase(null)]
+ [TestCase("")]
+ public void ReplaceUrl_NullOrEmpty_ThrowsArgumentException(string? url)
+ {
+ var context = TestHelper.CreateHtmxRequestContext();
+
+ var exception = Assert.Throws(() =>
+ context.Response.Htmx(r => r.ReplaceUrl(url!)));
- Assert.That(context.Response.Headers[HtmxResponseHeaderNames.ReplaceUrl], Is.EqualTo("false"));
+ Assert.That(exception?.ParamName, Is.EqualTo("url"));
}
[Test]
@@ -369,7 +463,7 @@ public void TriggerEvent_WithIndentedJsonTypeInfo_SerializesImmediatelyAsCompact
public void TriggerEvent_NullObjectDetail_SerializesAsNull()
{
var context = TestHelper.CreateHtmxRequestContext();
- context.Response.Htmx(r => r.TriggerEvent("e", (object?)null!));
+ context.Response.Htmx(r => r.TriggerEvent("e", null!));
PendingEvents.GetOrCreate(context.Response).Flush();