Skip to content

#220 fix: the replication poll yields to the operator's click on both screens - #225

Merged
gregoryfoster merged 8 commits into
mainfrom
220-poll-yields-to-click
Sep 13, 2026
Merged

gregoryfoster merged 8 commits into
mainfrom
220-poll-yields-to-click

Conversation

@gregoryfoster

Copy link
Copy Markdown
Contributor

Closes #220.

What was wrong

The issue had the ordering inverted. A poll landing after Replicate now is harmless: its target was already swapped out, so htmx drops it. The bug is the poll landing first. The action's target (resolved at click time) is then detached, and its swap, focus script and HX-Trigger toast are all discarded. If that poll read before the commit and no other command was open, its render stopped polling, and the section stayed on pre-issuance state until reload - with a fresh, enabled Replicate now button and no toast, inviting a second occasion. hx-confirm makes that ordering the likely one: the poll timer runs overdue behind the dialog and fires just after the POST. The RepSpec detail page had the identical defect.

The fix (both screens)

  • Wrapper, inside the poll block: hx-sync="this:abort" hx-disinherit="hx-sync".
  • Replicate now and Deactivate: hx-sync="closest #<section>:drop".

htmx takes the lock before confirm(), so an in-flight poll is aborted at the click, and a tick during the action is dropped; every needs no re-arming. hx-disinherit because hx-sync inherits: without it, anything inside the section - boosted links included - takes this:abort and is dropped mid-poll.

Tests

  • poll_sync_violations (tests/dashboard/conftest.py) checks the rule on each screen's poll fragment. test_poll_sync.py holds the checker to the markups the issue weighed, so an empty list means safe, not unexamined. Red at 1d64ec5, green at ab4584c.
  • tests/js/htmx-poll-sync.test.js drives the vendored htmx 2.0.8 through a stubbed XHR held in flight. The drop-aborts-an-abortable-poll rule is htmx's implementation, not its documented contract, so an htmx upgrade could undo Replication poll and Replicate now race for the same swap target #220 with every Python test green. Setting the actions to abort or the wrapper to drop fails it.

Browser verification (#220 acceptance)

Run in Chromium against the dev server's real templates (8001, dev DB, bus off). Instead of shortening the poll interval, latency was injected in the browser, so each ordering happens every time rather than by chance.

Scenario Before After
Click mid-poll, poll lands first POST render, toast, focus lost Poll aborted; POST render, toast, heading focus
2.6s on the confirm, open command closes meanwhile Stale and idle until reload Correct, still polling
Confirm cancelled Ticks continue, no re-arming
Link inside the section mid-poll Navigates Navigates

Review rounds

Two CR rounds, six findings, all in their own commits. One correction worth reading: the #220 comment called queue all "deferred and hidden". Measured, queue parks the second action on the wrapper, where the first action's swap wipes it; only when the first fails without swapping does the queued one fire, late, behind a second confirm. replace aborts the first action's request: the server commits it, but its render and toast are lost. drop stands.

Also in this PR

No contract-visible paths change, so no CHANGELOG entry. After merge: sudo systemctl restart archiver.

🤖 Generated with Claude Code

gregoryfoster and others added 8 commits September 12, 2026 19:52
… actions

The poll and both row actions swap one wrapper, so the response that lands
second finds its target detached and htmx discards it - swap, focus script
and HX-Trigger toast alike. Unsynced, the discarded one was often the
operator's.

`poll_sync_violations` (tests/dashboard/conftest.py) states the invariant
against a poll fragment: the wrapper's poll is `this:abort` and disinherits
hx-sync; every request-issuing element inside syncs on the wrapper with
`drop`. test_poll_sync.py holds the checker to the markups #220 weighed, so
an empty list means safe rather than unexamined. Both screens assert it -
the RepSpec twin carries the identical defect, which the issue did not name.

Red at this commit on both screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… screens

The issue had the ordering inverted. A poll landing after the action is
harmless: its target was already swapped out, so htmx drops it. The bug is
the poll landing first. The action's target is then detached, and its swap,
focus script and HX-Trigger toast are all discarded. If the poll read before
the commit and no other command was open, its render stopped polling, and the
section stayed on pre-issuance state until reload - with a fresh, enabled
Replicate now button and no toast, inviting a second occasion.

hx-confirm makes that ordering the likely one: it blocks the page, the poll
timer runs overdue behind the dialog, and it fires just after the POST.

Wrapper: hx-sync="this:abort" + hx-disinherit="hx-sync", inside the poll
block. Actions: hx-sync="closest #<section>:drop". htmx takes the lock
before confirm(), so an in-flight poll is aborted at the click and a tick
during the action is dropped; `every` needs no re-arming. hx-disinherit
because hx-sync inherits: without it, anything inside - boosted links
included - takes `this:abort` and is dropped mid-poll. Applied to the
RepSpec twin too.

Verified in Chromium against the dev server's real templates, with latency
injected at the browser's network layer rather than a shortened interval, so
each ordering is deterministic. Before: both screens lost the POST render,
toast and focus, and ended stale and idle when the open command closed during
the dialog. After: poll aborted, POST render shown, toast delivered, focus on
the heading, ticks continue after a cancelled confirm, boosted links still
navigate.

Docs: UI.md gains the shared pattern; INFO_ITEM_DETAIL.md and PAGES.md point
at it. Also corrects the section header's claim that Replicate now swaps
only its row, untrue since #171 CR #37.

Closes #220

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
poll_sync_violations holds the templates to the attributes, not htmx to
what it does with them. The load-bearing rule is htmx 2.0.8's
implementation rather than its documented contract: the docs say `drop`
ignores a request while one is in flight, which read literally drops the
click behind a poll. 2.0.8 exempts an in-flight request issued `abort` and
aborts it instead - so a vendor bump could undo #220 with every Python test
green.

tests/js/htmx-poll-sync.test.js drives the vendored library through a
stubbed XHR held in flight, as htmx-error-trigger.test.js does for #178:
an action aborts an in-flight poll and is sent; a tick during an action is
dropped; an element inheriting `this:abort` is dropped mid-poll unless the
wrapper disinherits; a response whose target was swapped out is discarded,
HX-Trigger toast included. Mutating the shipped strategies (action `abort`,
wrapper `drop`) fails it. UI.md names the pin.

happy-dom's document is not `instanceof Document`, which htmx's plain-
selector hx-target lookup requires; the file shims that for its own run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three places said `queue` "re-issues from a detached element that never
fires", and test_poll_sync said both strategies "lose a second action's
response". Measured in Chromium:

- `queue` parks the second action on the wrapper's htmx data, and the first
  action's swap wipes that data, queue included. When the first action
  fails without swapping, the queued one does fire - behind a second
  confirm. A maintainer "fixing" the stated reason would get that surprise
  dialog.
- `replace` aborts the *first* action's in-flight request: the server
  commits it, but its render and toast are lost.

The conclusion - `drop` - stands; only the reasons were wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…othing

It said the server refreshes a sub-section by sending HX-Reswap /
HX-Retarget headers, citing the rep-spec assignment row. No first-party
route or script sends either header, and the per-row swap was retired by
#171 CR #37 - the same claim this branch corrected in the section's template
header, left standing in a doc the branch edits. Nothing links to the
heading. The swaps that do exist are documented by the action-swaps-card and
polling-section patterns below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t a control

The discard test pins two negatives - no toast, and the action's render
never lands - but nothing in the file showed either positive through this
stub XHR. The only answered response carried no HX-Trigger, so "no toast"
would also have held had htmx never read the header here.

The abort test now answers the action too: its render swaps in and its
showFlash reaches document. That is #220's first acceptance bullet - the
click's response is what the operator ends up looking at - and the control
that makes the discard test's absences mean something. Its first draft
compared the whole event detail and failed on the `elt` htmx adds, so it
asserts the fields that matter, as htmx-error-trigger.test.js does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CR 2 rewrote this docstring to say `queue` "never sends the second". Measured,
it does when the first action fails without swapping - later, behind a
second confirm - as UI.md says. The docstring now claims only what holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregoryfoster
gregoryfoster merged commit 43f5474 into main Sep 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replication poll and Replicate now race for the same swap target

1 participant