Skip to content
Draft
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
10 changes: 10 additions & 0 deletions src/scripts/upgrade-check.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,15 @@ def check_text(raw_text, filepath, issues):
issues.append(Issue(filepath, lineno, "removed-header",
f'"{header}" is removed → {fix}'))

# HTTP 286 (Intercooler / htmx 2 poll cancel) is a no-op in v4
if re.search(r"\b286\b", line) and re.search(
r"poll|hx-trigger|CancelPolling", line, re.I):
issues.append(Issue(
filepath, lineno, "polling-286",
'HTTP 286 no longer stops polling → swap the poller for markup '
'without hx-trigger="every …" (outerHTML / outerMorph / '
'HX-Reswap: outerHTML)'))


# ---------------------------------------------------------------------------
# File processing
Expand Down Expand Up @@ -570,6 +579,7 @@ def collect_files(paths, extensions):
"renamed-config": "\033[33m", # yellow
"removed-config": "\033[31m", # red
"removed-header": "\033[31m", # red
"polling-286": "\033[31m", # red
}
RESET = "\033[0m"

Expand Down
5 changes: 5 additions & 0 deletions src/skills/htmx-upgrade-from-htmx2.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ New request header: `HX-Request-Type` (`"full"` or `"partial"`).
Still supported: `HX-Trigger`, `HX-Push-Url`, `HX-Replace-Url`, `HX-Redirect`, `HX-Location`,
`HX-Refresh`, `HX-Retarget`, `HX-Reswap`, `HX-Reselect`.

**Polling:** HTTP **286** no longer stops `hx-trigger="every …"`. The polling
element is the poll. Stop by swapping **that element** for markup without
`every` (`outerHTML` / `outerMorph`, or `HX-Reswap: outerHTML` if the request
would otherwise `innerHTML`). Default `innerHTML` cannot self-stop.

## Step 9: Update JavaScript API Calls

| htmx 2 | htmx 4 |
Expand Down
7 changes: 7 additions & 0 deletions test/manual/upgrade/polling-286.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html>
<body>
<div hx-get="/job/progress" hx-trigger="every 1s">Waiting</div>
<!-- 2.x: return HTTP 286 to cancel this poll -->
</body>
</html>
49 changes: 49 additions & 0 deletions test/tests/attributes/hx-trigger-every.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
describe('hx-trigger every polling lifetime', function() {

beforeEach(() => {
setupTest()
})

afterEach(() => {
cleanupTest()
})

it('HTTP 286 does not stop every polling', async function () {
mockResponse('GET', '/poll-286', 'tick', {status: 286})
createProcessedHTML('<div id="p286" hx-get="/poll-286" hx-trigger="every 40ms" hx-swap="innerHTML">0</div>')
await htmx.timeout(130)
fetchMock.calls.length.should.be.above(1)
})

it('innerHTML swap leaves the poller in the DOM so every continues', async function () {
mockResponse('GET', '/poll-inner', 'tick')
createProcessedHTML('<div id="pinner" hx-get="/poll-inner" hx-trigger="every 40ms" hx-swap="innerHTML">0</div>')
await htmx.timeout(130)
fetchMock.calls.length.should.be.above(1)
find('#pinner').getAttribute('hx-trigger').should.equal('every 40ms')
})

it('outerHTML response without every stops polling', async function () {
mockResponse('GET', '/poll-outer', '<div id="pouter">done</div>')
createProcessedHTML('<div id="pouter" hx-get="/poll-outer" hx-trigger="every 40ms" hx-swap="outerHTML">go</div>')
await forRequest(200)
let afterFirst = fetchMock.calls.length
afterFirst.should.equal(1)
await htmx.timeout(120)
fetchMock.calls.length.should.equal(afterFirst)
find('#pouter').textContent.should.equal('done')
should.equal(find('#pouter').getAttribute('hx-trigger'), null)
})

it('HX-Reswap outerHTML of a triggerless copy stops an innerHTML poller', async function () {
mockResponse('GET', '/poll-reswap', '<div id="preswap">complete</div>', {headers: {'HX-Reswap': 'outerHTML'}})
createProcessedHTML('<div id="preswap" hx-get="/poll-reswap" hx-trigger="every 40ms">go</div>')
await forRequest(200)
let afterFirst = fetchMock.calls.length
afterFirst.should.equal(1)
await htmx.timeout(120)
fetchMock.calls.length.should.equal(afterFirst)
find('#preswap').textContent.should.equal('complete')
should.equal(find('#preswap').getAttribute('hx-trigger'), null)
})
})
16 changes: 16 additions & 0 deletions www/src/content/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,22 @@ This tells htmx:

> Every 2 seconds, issue a GET to /news and load the response into the div

The polling element **is** the poll. htmx arms a `setInterval` on that node and
clears it when the node leaves the DOM (or is cleaned up during a swap). There
is no cancelled flag, no `htmx.cancelPolling()`, and **HTTP 286 does not stop
polling** (that 2.x / Intercooler status is ignored).

To stop from the server, return a representation of the same element **without**
`hx-trigger="every …"`, using a swap that replaces the poller itself
([`outerHTML`](/reference/attributes/hx-swap) / [`outerMorph`](/reference/attributes/hx-swap),
or [`HX-Reswap`](/reference/headers/HX-Reswap)`: outerHTML` if the request would
otherwise innerHTML). Pause and resume are the same: markup without the trigger,
then markup with it.

Default [`innerHTML`](/reference/attributes/hx-swap) cannot self-stop: the poller
stays in the DOM, so the interval keeps firing. See [Polling](/patterns/polling)
and [Progress Bar](/patterns/progress-bar).

#### Load Polling

Another technique that can be used to achieve polling in htmx is "load polling", where an element specifies
Expand Down
9 changes: 9 additions & 0 deletions www/src/content/docs/whats-new-in-htmx-4.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ gets swapped into the target. Design your error responses to work as swap conten

Revert: [`htmx.config.noSwap`](/reference/config/htmx-config-noSwap) `= [204, 304, '4xx', '5xx']`

### HTTP 286 does not stop polling

htmx 2 honored status `286` (Intercooler's cancel-polling signal) to stop
`hx-trigger="every …"`. htmx 4 ignores it.

The polling element **is** the poll. Stop by swapping that element for markup
without `every` (`outerHTML` / `outerMorph`, or `HX-Reswap: outerHTML` when the
request would otherwise `innerHTML`). See [Polling](/patterns/polling).

### [`hx-delete`](/reference/attributes/hx-delete) excludes form data

Like [`hx-get`](/reference/attributes/hx-get), [`hx-delete`](/reference/attributes/hx-delete) no longer includes the
Expand Down
14 changes: 12 additions & 2 deletions www/src/content/patterns/04-polling.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ The interval unit is `ms`, `s`, or `m`. A bare number is milliseconds.

## Stopping the poll

htmx clears the interval when the element leaves the DOM. To stop the poll, return the element without the trigger attributes.
htmx clears the interval when the element leaves the DOM. The markup **is** the
state: a node with `hx-trigger="every …"` polls; a node without it does not.

To stop, return the **same element** without the trigger attributes, with a swap
that replaces the poller (`outerMorph` / `outerHTML`). HTTP 286 is a no-op in
htmx 4.

```html
<!-- server response when there is nothing more to watch -->
Expand All @@ -99,7 +104,12 @@ htmx clears the interval when the element leaves the DOM. To stop the poll, retu
</div>
```

The demo above uses this to pause. The Pause button posts to `/toggle`, and the server renders the card without `hx-get` and `hx-trigger`.
If the poll uses the default `innerHTML` swap, the poller stays in the DOM and
keeps firing. Override the swap for that response with
[`HX-Reswap: outerHTML`](/reference/headers/HX-Reswap) (and return the wrapper
without `every`), or poll with `hx-swap="outerMorph"` from the start.

The demo above uses this to pause. The Pause button posts to `/toggle`, and the server renders the card without `hx-get` and `hx-trigger`. Resume is the inverse: return the card *with* the trigger.

## Conditional polling

Expand Down
14 changes: 13 additions & 1 deletion www/src/content/reference/01-attributes/07-hx-trigger.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +93,30 @@ Modifiers:

### `every <time>`

Fires repeatedly on an interval.
Fires repeatedly on an interval. The element that carries `every` **is** the poll:
htmx arms a timer on that node and clears it when the node leaves the DOM.

```html
<div hx-trigger="every 1s" hx-get="/updates">...</div>
```

To stop from the server, swap **this element** for markup that omits `every`
(`outerHTML` / `outerMorph`, or `HX-Reswap: outerHTML`). HTTP 286 does not
cancel polling in htmx 4. Default `innerHTML` cannot self-stop, because the
poller stays in the DOM.

To add a filter to polling, add it after the interval:

```html
<div hx-trigger="every 1s [someConditional]" hx-get="/updates">...</div>
```

The interval still runs when the filter is false; the request is skipped. Use
this for tab visibility (`[document.visibilityState === 'visible']`), not as a
cancel protocol.

See [Polling](/patterns/polling).

## Event Modifiers

### `[filter]`
Expand Down
Loading