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
49 changes: 43 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Provides HTMX integration for ASP.NET Core applications.
* [HtmxRequestAttribute](#htmxrequestattribute)
* [HttpResponse](#httpresponse)
* [The declarative way of setting response headers](#the-declarative-way-of-setting-response-headers)
* [Polling](#polling)
* [Tag Helpers](#tag-helpers)
* [HtmxUrlTagHelper](#htmxurltaghelper)
* [HtmxHeaderTagHelper](#htmxheadertaghelper)
Expand Down Expand Up @@ -360,8 +361,7 @@ Its callback receives an `HtmxResponse`, allowing you to configure response head
Response.Htmx(h => h
.TriggerEvent(
eventName: "process",
detail: new { Value = ... })
.StopPolling(ShouldStopPolling));
detail: new { Value = ... }));
```

`TriggerEvent` and `TriggerEvents` accept an optional `HtmxTriggerTiming` value.
Expand All @@ -377,12 +377,11 @@ the upstream timing change.

```csharp
Response.Htmx(
static (h, stop) => h
static (h, value) => h
.TriggerEvent(
eventName: "process",
detail: new { Value = ... })
.StopPolling(stop),
ShouldStopPolling);
detail: new { Value = value }),
ProcessValue);
```

:bulb: The same API works in Minimal API handlers by binding `HttpResponse`:
Expand Down Expand Up @@ -457,6 +456,44 @@ public HtmxSwap Reswap { get; set; }

Use `ReswapExpression` when the strongly typed `Reswap` property is not flexible enough.

## Polling

For server-controlled polling that works in every supported HTMX version, return
the polling element itself and replace it with `outerHTML`:

```html
<div id="poll-status"
hx-get="/poll"
hx-trigger="load delay:1s"
hx-swap="outerHTML">
Polling...
</div>
```

While polling should continue, return the same element with its request
attributes. To stop, return the element without `hx-get` and `hx-trigger`:

```html
<div id="poll-status">
Polling stopped!
</div>
```

This load-polling pattern gives the server control over every next request and
works with HTMX 1.9.x, 2.x, and 4.x. For an indefinitely updated status, use
`hx-trigger="every 1s"` instead.

HTMX 1.9.x and 2.x also recognize HTTP status code `286` as a fixed-rate polling
stop signal. Applications that target only those versions can opt into that legacy
behavior directly:

```csharp
Response.StatusCode = 286;
```

HTMX 4.x treats `286` as a regular successful response, so it is not exposed as a
toolkit API.

## Tag Helpers

The library provides five tag helpers:
Expand Down
18 changes: 4 additions & 14 deletions samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,15 @@
<header class="page-heading">
<p class="eyebrow">Example 05</p>
<h1>Polling</h1>
<p>Stop a repeating HTMX request from the server when a condition is reached</p>
<p>Let the server decide whether to schedule the next HTMX request</p>
</header>

<section class="demo-card">
<div class="demo-card__header">
<h2><code>HtmxResponse.StopPolling()</code></h2>
<p>The request is evaluated every second. The server randomly asks HTMX to stop polling.</p>
</div>
<div class="poll-status"
hx-get hx-page="/Examples/Polling"
hx-page-handler="StopPolling"
hx-target="#poll-result"
hx-trigger="every 1s"
hx-swap="innerHTML">
<span id="poll-status">Polling is active</span>
<h2>Server-controlled polling</h2>
<p>The server returns the next polling element every second until it randomly stops.</p>
</div>

<div id="poll-result" class="result">
Waiting for the first response.
</div>
<partial name="_PollingStatus" model="Model.State" />
</section>
</article>
18 changes: 10 additions & 8 deletions samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/Polling.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ namespace Ramstack.HtmxToolkit.Demo.Pages.Examples;

public class PollingModel : PageModel
{
public IActionResult OnGetStopPolling()
{
var stop = Random.Shared.Next(0, 20) == 10;
Response.Htmx((h, f) => h.StopPolling(f), stop);
public PollingState State { get; } = new(false, "Polling is active");

var content = $"Polling... {DateTime.Now:HH:mm:ss}";
if (stop)
content += "<span id='poll-status' hx-swap-oob='true' style='color: orange'>Polling stopped!</span>";
public IActionResult OnGetPoll()
{
var stopped = Random.Shared.Next(0, 20) == 10;
var message = stopped
? "Polling stopped!"
: $"Polling... {DateTime.Now:HH:mm:ss}";

return Content(content);
return Partial("_PollingStatus", new PollingState(stopped, message));
}

public sealed record PollingState(bool Stopped, string Message);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@model PollingModel.PollingState

@if (Model.Stopped)
{
<div id="poll-status" class="poll-status poll-status--stopped">
@Model.Message
</div>
}
else
{
<div id="poll-status" class="poll-status"
hx-get hx-page="/Examples/Polling"
hx-page-handler="Poll"
hx-trigger="load delay:1s"
hx-swap="outerHTML">
@Model.Message
</div>
}
2 changes: 1 addition & 1 deletion samples/Ramstack.HtmxToolkit.Demo/Pages/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<a class="overview-card" asp-page="/Examples/RouteData"><h2>02. Route data</h2><p>Pass route values without query-string assembly.</p></a>
<a class="overview-card" asp-page="/Examples/Headers"><h2>03. Request headers</h2><p>Attach custom HTMX headers in Razor.</p></a>
<a class="overview-card" asp-page="/Examples/FluentResponse"><h2>04. Fluent responses</h2><p>Configure response directives on the server.</p></a>
<a class="overview-card" asp-page="/Examples/Polling"><h2>05. Polling</h2><p>Stop repeated requests from a handler.</p></a>
<a class="overview-card" asp-page="/Examples/Polling"><h2>05. Polling</h2><p>Let the server control repeated requests.</p></a>
<a class="overview-card" asp-page="/Examples/HtmxRequest"><h2>06. HTMX requests</h2><p>Detect asynchronous requests.</p></a>
<a class="overview-card" asp-page="/Examples/ResponseHeaders"><h2>07. Response headers</h2><p>Override the requested swap strategy.</p></a>
<a class="overview-card" asp-page="/Examples/Boosted"><h2>08. Boosted navigation</h2><p>Detect links enhanced by HTMX.</p></a>
Expand Down
5 changes: 5 additions & 0 deletions samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ code {
font-size: 0.85rem;
}

.poll-status--stopped {
background: rgb(255 165 0 / 0.15);
color: orange;
}

.demo-form {
display: grid;
gap: 1rem;
Expand Down
30 changes: 0 additions & 30 deletions src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ public readonly struct HtmxResponse
{
private readonly HttpResponse _response;

/// <summary>
/// The HTTP status code used by HTMX to stop polling.
/// </summary>
public const int StopPollingStatusCode = 286;

/// <summary>
/// Gets the strongly typed HTMX response headers.
/// </summary>
Expand Down Expand Up @@ -252,31 +247,6 @@ static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, ob
public HtmxResponse TriggerEvents(IReadOnlyDictionary<string, object> events, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
AddEvents(this, events, timing);

/// <summary>
/// Sets HTTP status code <c>286</c> to stop polling.
/// </summary>
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
public HtmxResponse StopPolling() =>
StopPolling(true);

/// <summary>
/// Sets HTTP status code <c>286</c> when <paramref name="condition" /> is <see langword="true" />.
/// </summary>
/// <param name="condition"><see langword="true" /> to stop polling;
/// otherwise, <see langword="false" />.</param>
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
public HtmxResponse StopPolling(bool condition)
{
if (condition)
_response.StatusCode = StopPollingStatusCode;

return this;
}

/// <summary>
/// Sets a response header and returns the response wrapper for fluent chaining.
/// </summary>
Expand Down
27 changes: 0 additions & 27 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,33 +184,6 @@ public void Reselect_SetsHeader()
Assert.That(context.Response.Headers[HtmxResponseHeaderNames.Reselect], Is.EqualTo("#list"));
}

[Test]
public void StopPolling_SetsStatusCode286()
{
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.StopPolling());

Assert.That(context.Response.StatusCode, Is.EqualTo(HtmxResponse.StopPollingStatusCode));
}

[Test]
public void StopPolling_WithFalseCondition_DoesNotChangeStatusCode()
{
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.StopPolling(false));

Assert.That(context.Response.StatusCode, Is.Not.EqualTo(HtmxResponse.StopPollingStatusCode));
}

[Test]
public void StopPolling_WithTrueCondition_SetsStatusCode286()
{
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.StopPolling(true));

Assert.That(context.Response.StatusCode, Is.EqualTo(HtmxResponse.StopPollingStatusCode));
}

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