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();