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
11 changes: 11 additions & 0 deletions docs/articles/migrate-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,17 @@ instead of the page after Back or Forward. In 2.0, the same code returns the ful
HTMX 1.9.x and 2.x ignore response headers when restoring history, so skipping them loses nothing.
To read the raw header, use `Request.GetHtmxHeaders().Request`. See [Handle history restoration](requests.md#handle-history-restoration).

### Encoded header values are decoded

> [!WARNING]
> `Prompt`, `Target`, `Trigger`, and `TriggerName` now return the decoded value when HTMX URI-encoded it.
> This is a silent behavior change.

HTMX 1.9.x and 2.x URI-encode a header value that the browser cannot send as is, such as a Cyrillic `hx-prompt` answer
or element ID, and mark it with an additional `*-URI-AutoEncoded` header. In 1.x, these properties returned the encoded value.
Remove any manual `Uri.UnescapeDataString` call for them: decoding twice corrupts a value that contains `%`.
HTMX 4.x does not mark encoded values, so they are still returned as sent; see [Request header properties](requests.md#request-header-properties).

### Boosted requests

| 1.x | 2.0 |
Expand Down
7 changes: 5 additions & 2 deletions docs/articles/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ Missing or version-inapplicable string headers return `null`.
`Request` is `true` whenever the `HX-Request` header is present, whatever its value;
unlike `IsHtmxRequest()`, it does not exclude history restoration.
`Boosted` and `HistoryRestoreRequest` are `true` only when their header is exactly `true`.
In HTMX 4.x, the `hx-prompt` extension sends the prompt answer URI-encoded;
decode it with `Uri.UnescapeDataString` before use.
HTMX 1.9.x and 2.x URI-encode a header value that the browser cannot send as is, such as a Cyrillic prompt answer
or element ID, and mark it with an additional `*-URI-AutoEncoded` header. `Prompt`, `Target`, `Trigger`, and `TriggerName`
return such a value decoded.
HTMX 4.x does not mark encoded values: the `hx-prompt` extension URI-encodes the prompt answer, and `Target` and `Source`
contain a URI-encoded element ID. Decode these values with `Uri.UnescapeDataString` before use.
`RequestType` returns `HtmxRequestType.Full` for a history restoration request without its header,
and `HtmxRequestType.Unspecified` for any other request without it.

Expand Down
36 changes: 31 additions & 5 deletions src/Ramstack.HtmxToolkit/HtmxRequestHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ namespace Ramstack.HtmxToolkit;
/// <summary>
/// Represents strongly typed HTMX request headers.
/// </summary>
/// <remarks>
/// HTMX 1.9.x and 2.x URI-encode a header value that the browser cannot send as is, such as Cyrillic text,
/// and mark it with an additional <c>*-URI-AutoEncoded</c> header. <see cref="Prompt" />, <see cref="Target" />,
/// <see cref="Trigger" />, and <see cref="TriggerName" /> return such a value decoded.
/// HTMX 4.x URI-encodes some values without marking them, so the properties return those values as sent.
/// </remarks>
[DebuggerTypeProxy(typeof(HtmxRequestHeadersDebugView))]
public readonly struct HtmxRequestHeaders
{
private const string AutoEncodedSuffix = "-URI-AutoEncoded";

private readonly IHeaderDictionary _headers;

/// <summary>
Expand Down Expand Up @@ -66,7 +74,7 @@ internal HtmxRequestHeaders(HttpRequest request) =>
/// restores this header and URI-encodes its value.
/// </para>
/// </remarks>
public string? Prompt => GetString(_headers, HtmxRequestHeaderNames.Prompt);
public string? Prompt => GetString(_headers, HtmxRequestHeaderNames.Prompt, HtmxRequestHeaderNames.Prompt + AutoEncodedSuffix);

/// <summary>
/// Gets a value indicating whether the request has the <c>HX-Request</c> header.
Expand Down Expand Up @@ -165,7 +173,7 @@ static HtmxRequestType GetRequestType(IHeaderDictionary dictionary)
/// Examples include <c>div#results</c> and <c>body</c>.
/// </para>
/// </remarks>
public string? Target => GetString(_headers, HtmxRequestHeaderNames.Target);
public string? Target => GetString(_headers, HtmxRequestHeaderNames.Target, HtmxRequestHeaderNames.Target + AutoEncodedSuffix);

/// <summary>
/// Gets the <c>name</c> attribute of the element that triggered the request.
Expand All @@ -181,7 +189,7 @@ static HtmxRequestType GetRequestType(IHeaderDictionary dictionary)
/// with <c>HX-Source</c> instead.
/// </para>
/// </remarks>
public string? TriggerName => GetString(_headers, HtmxRequestHeaderNames.TriggerName);
public string? TriggerName => GetString(_headers, HtmxRequestHeaderNames.TriggerName, HtmxRequestHeaderNames.TriggerName + AutoEncodedSuffix);

/// <summary>
/// Gets the ID of the element that triggered the request.
Expand All @@ -196,7 +204,7 @@ static HtmxRequestType GetRequestType(IHeaderDictionary dictionary)
/// has no ID; HTMX 4.x identifies the source element with <c>HX-Source</c> instead.
/// </para>
/// </remarks>
public string? Trigger => GetString(_headers, HtmxRequestHeaderNames.Trigger);
public string? Trigger => GetString(_headers, HtmxRequestHeaderNames.Trigger, HtmxRequestHeaderNames.Trigger + AutoEncodedSuffix);

/// <summary>
/// Determines whether the specified header has the value <c>"true"</c>.
Expand All @@ -211,7 +219,7 @@ private static bool GetBoolean(IHeaderDictionary dictionary, string key) =>
dictionary.TryGetValue(key, out var value) && value is ["true"];

/// <summary>
/// Gets the value of the specified header.
/// Returns the value of the specified header.
/// </summary>
/// <param name="dictionary">The header collection to inspect.</param>
/// <param name="key">The name of the header.</param>
Expand All @@ -224,6 +232,24 @@ private static bool GetBoolean(IHeaderDictionary dictionary, string key) =>
return value;
}

/// <summary>
/// Returns the value of the specified header, decoded if HTMX marked it as URI-encoded.
/// </summary>
/// <param name="dictionary">The header collection to inspect.</param>
/// <param name="key">The name of the header.</param>
/// <param name="autoEncodedKey">The name of the header that marks the value as URI-encoded.</param>
/// <returns>
/// The header value, or <see langword="null" /> if the header is not present.
/// </returns>
private static string? GetString(IHeaderDictionary dictionary, string key, string autoEncodedKey)
{
var value = GetString(dictionary, key);

return value is not null && GetBoolean(dictionary, autoEncodedKey)
? Uri.UnescapeDataString(value)
: value;
}

#region Inner type: HtmxRequestHeadersDebugView

/// <summary>
Expand Down
46 changes: 46 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxRequestHeadersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,52 @@ public void Prompt_ReturnsHeaderValue()
Assert.That(headers.Prompt, Is.EqualTo("yes"));
}

[Test]
public void StringProperties_AreDecoded_WhenHeaderIsAutoEncoded()
{
var headers = CreateHeaders(
(HtmxRequestHeaderNames.Prompt, "%D0%94%D0%B0%20100%2525"),
(HtmxRequestHeaderNames.Prompt + "-URI-AutoEncoded", "true"),
(HtmxRequestHeaderNames.Target, "%D1%86%D0%B5%D0%BB%D1%8C"),
(HtmxRequestHeaderNames.Target + "-URI-AutoEncoded", "true"),
(HtmxRequestHeaderNames.Trigger, "%D1%84%D0%BE%D1%80%D0%BC%D0%B0"),
(HtmxRequestHeaderNames.Trigger + "-URI-AutoEncoded", "true"),
(HtmxRequestHeaderNames.TriggerName, "%D0%B8%D0%BC%D1%8F"),
(HtmxRequestHeaderNames.TriggerName + "-URI-AutoEncoded", "true"));

Assert.That(headers.Prompt, Is.EqualTo("Да 100%25"));
Assert.That(headers.Target, Is.EqualTo("цель"));
Assert.That(headers.Trigger, Is.EqualTo("форма"));
Assert.That(headers.TriggerName, Is.EqualTo("имя"));
}

[Test]
public void Prompt_IsNotDecoded_WithoutAutoEncodedHeader()
{
var headers = CreateHeaders((HtmxRequestHeaderNames.Prompt, "100%25"));
Assert.That(headers.Prompt, Is.EqualTo("100%25"));
}

[Test]
public void Prompt_IsNotDecoded_WhenAutoEncodedHeaderIsNotTrue()
{
var headers = CreateHeaders(
(HtmxRequestHeaderNames.Prompt, "100%25"),
(HtmxRequestHeaderNames.Prompt + "-URI-AutoEncoded", "false"));

Assert.That(headers.Prompt, Is.EqualTo("100%25"));
}

[Test]
public void Prompt_IsNotDecoded_WhenOtherHeaderIsAutoEncoded()
{
var headers = CreateHeaders(
(HtmxRequestHeaderNames.Prompt, "100%25"),
(HtmxRequestHeaderNames.Trigger + "-URI-AutoEncoded", "true"));

Assert.That(headers.Prompt, Is.EqualTo("100%25"));
}

[Test]
public void Request_ReturnsHeaderValue()
{
Expand Down
Loading