From a96487a85fa3fc8d06cffb626c31db25c2f942e5 Mon Sep 17 00:00:00 2001 From: rameel Date: Sat, 10 Oct 2026 22:47:19 +0500 Subject: [PATCH] http: make the last history call take effect PushUrl, ReplaceUrl, PreventPushUrl and PreventReplaceUrl set their headers independently, so a response could carry both HX-Push-Url and HX-Replace-Url. HTMX 1.9 and 2 use the first header found, while HTMX 4 reads both, treats "false" as null and prefers push. The same chain behaved differently across versions, and the call order had no effect. Make each history method set its header and remove the other one, along with the legacy HX-Push header that HTMX 1.9 and 2 check first. With a single header, HTMX 1.9, 2 and 4 apply the same update. Replace PreventPushUrl and PreventReplaceUrl with PreventHistoryUpdate: with a single header, "false" in either one cancels any history update, so the two methods were equivalent. Reject null and empty URLs in PushUrl and ReplaceUrl and rename their parameter from value to url. The HtmxResponseHeaders properties still set each header independently. --- src/Ramstack.HtmxToolkit/HtmxResponse.cs | 139 ++++++++++++------ .../HtmxResponseHeaders.cs | 30 ++-- .../HtmxResponseTests.cs | 106 ++++++++++++- 3 files changed, 209 insertions(+), 66 deletions(-) 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();