diff --git a/src/scripts/upgrade-check.py b/src/scripts/upgrade-check.py index 67570ae78..fa84a07f6 100755 --- a/src/scripts/upgrade-check.py +++ b/src/scripts/upgrade-check.py @@ -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 @@ -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" diff --git a/src/skills/htmx-upgrade-from-htmx2.md b/src/skills/htmx-upgrade-from-htmx2.md index c59a99c1d..8990def56 100644 --- a/src/skills/htmx-upgrade-from-htmx2.md +++ b/src/skills/htmx-upgrade-from-htmx2.md @@ -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 | diff --git a/test/manual/upgrade/polling-286.html b/test/manual/upgrade/polling-286.html new file mode 100644 index 000000000..e1d9cf0e8 --- /dev/null +++ b/test/manual/upgrade/polling-286.html @@ -0,0 +1,7 @@ + + + +
Waiting
+ + + diff --git a/test/tests/attributes/hx-trigger-every.js b/test/tests/attributes/hx-trigger-every.js new file mode 100644 index 000000000..b03f938e1 --- /dev/null +++ b/test/tests/attributes/hx-trigger-every.js @@ -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('
0
') + 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('
0
') + 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', '
done
') + createProcessedHTML('
go
') + 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', '
complete
', {headers: {'HX-Reswap': 'outerHTML'}}) + createProcessedHTML('
go
') + 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) + }) +}) diff --git a/www/src/content/docs.md b/www/src/content/docs.md index 92af522a7..485d5bc54 100644 --- a/www/src/content/docs.md +++ b/www/src/content/docs.md @@ -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 diff --git a/www/src/content/docs/whats-new-in-htmx-4.md b/www/src/content/docs/whats-new-in-htmx-4.md index 1b6f6f666..6b822408b 100644 --- a/www/src/content/docs/whats-new-in-htmx-4.md +++ b/www/src/content/docs/whats-new-in-htmx-4.md @@ -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 diff --git a/www/src/content/patterns/04-polling.md b/www/src/content/patterns/04-polling.md index ffb386c0c..e04c29a74 100644 --- a/www/src/content/patterns/04-polling.md +++ b/www/src/content/patterns/04-polling.md @@ -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 @@ -99,7 +104,12 @@ htmx clears the interval when the element leaves the DOM. To stop the poll, retu ``` -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 diff --git a/www/src/content/reference/01-attributes/07-hx-trigger.md b/www/src/content/reference/01-attributes/07-hx-trigger.md index 878986d3b..aaa942a37 100644 --- a/www/src/content/reference/01-attributes/07-hx-trigger.md +++ b/www/src/content/reference/01-attributes/07-hx-trigger.md @@ -93,18 +93,30 @@ Modifiers: ### `every