fix(ui): debounce composer "Could not send" banner (LC-216) #258

Merged
nrupard merged 2 commits from fix/lc-216-composer-error-debounce into main 2026-05-29 17:24:18 +02:00
Owner

Summary

Rapid successive message submits showed the "Could not send." banner for ~a second even when the post ultimately succeeded. Reported on staging: typing several messages back-to-back at ~200 ms apart flashes the banner on one of them while the message itself lands.

Root cause: when the user submits a second message before the first's response lands, htmx cancels the first request (or it times out for other reasons) and fires htmx:afterRequest with event.detail.successful === false. Both composers' hx-on::after-request handlers immediately revealed the failure banner, then the actual-successful response hid it again ~a moment later. Reads as flaky chat.

Wrap the failure-reveal in a 2 s setTimeout whose handle is stashed on window. Each handler clears the matching timer at the top, then either runs the success branch (which already hides the banner) or schedules the failure-reveal. A rapid follow-up success arrives within 2 s, clears the timer, and the false flash never paints. A real failure leaves the timer to fire on schedule.

Distinct timer keys (__lcComposerErrTimer for the main composer, __lcThreadErrTimer for the thread panel composer) so the two surfaces do not fight when both are active simultaneously.

Test plan

  • cargo check -p lets-chat-server clean.
  • Rapid-fire submit test on staging: post 5 messages back-to-back at ~200 ms apart on a healthy network. "Could not send." never appears.
  • Forced-failure test: block the POST in DevTools so it 500s. Banner appears within ~2 s, stays visible, retry button works.
  • Slow-success test: server-side delay 1 500 ms. Banner does not appear; message lands.
  • Thread panel composer behaves the same way independently of the main composer.
  • Screen reader smoke (NVDA / VO): when the banner does reveal on a real failure, the role="alert" is announced.

LC-217 (deferred follow-up the user flagged): per-enclave admin-controllable message send rate-limit ("anti-spam by policy"). Separate ticket; not in this PR.

## Summary Rapid successive message submits showed the "Could not send." banner for ~a second even when the post ultimately succeeded. Reported on staging: typing several messages back-to-back at ~200 ms apart flashes the banner on one of them while the message itself lands. Root cause: when the user submits a second message before the first's response lands, htmx cancels the first request (or it times out for other reasons) and fires `htmx:afterRequest` with `event.detail.successful === false`. Both composers' `hx-on::after-request` handlers immediately revealed the failure banner, then the actual-successful response hid it again ~a moment later. Reads as flaky chat. Wrap the failure-reveal in a 2 s `setTimeout` whose handle is stashed on `window`. Each handler clears the matching timer at the top, then either runs the success branch (which already hides the banner) or schedules the failure-reveal. A rapid follow-up success arrives within 2 s, clears the timer, and the false flash never paints. A real failure leaves the timer to fire on schedule. Distinct timer keys (`__lcComposerErrTimer` for the main composer, `__lcThreadErrTimer` for the thread panel composer) so the two surfaces do not fight when both are active simultaneously. ## Test plan - [x] `cargo check -p lets-chat-server` clean. - [ ] Rapid-fire submit test on staging: post 5 messages back-to-back at ~200 ms apart on a healthy network. "Could not send." never appears. - [ ] Forced-failure test: block the POST in DevTools so it 500s. Banner appears within ~2 s, stays visible, retry button works. - [ ] Slow-success test: server-side delay 1 500 ms. Banner does not appear; message lands. - [ ] Thread panel composer behaves the same way independently of the main composer. - [ ] Screen reader smoke (NVDA / VO): when the banner does reveal on a real failure, the `role="alert"` is announced. ## Related LC-217 (deferred follow-up the user flagged): per-enclave admin-controllable message send rate-limit ("anti-spam by policy"). Separate ticket; not in this PR.
fix(ui): debounce composer "Could not send" banner (LC-216)
All checks were successful
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 2m6s
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 6s
436bd5a689
Rapid successive message submits flashed the "Could not send." banner for ~a second even when the post ultimately succeeded. Reproduced on staging: typing several messages back-to-back at ~200 ms apart shows the banner appear and disappear on one of them while the message itself lands.

Root cause: when the user submits a second message before the first's response lands, htmx cancels the first request (or it times out for other reasons), firing `htmx:afterRequest` with `event.detail.successful === false`. The `hx-on::after-request` handler on both composers immediately revealed the `.composer-error` / `.thread-error` div, then the actually-successful response a moment later hid it again. Net effect was a false-failure flash that survived long enough to read as "the chat is flaky."

Wrap the failure branch in a 2 s `setTimeout` whose handle is stashed on `window.__lcComposerErrTimer` (main composer) and `window.__lcThreadErrTimer` (thread panel composer). Each handler clears the matching timer at the top, then either runs the success branch (which already hides the banner) or schedules the failure-reveal. A real failure leaves the timer to fire on schedule and the banner reveals. A rapid follow-up success arrives within 2 s, clears the timer, and the false flash never paints.

Distinct timer keys on the two composers so they do not fight when both surfaces are active simultaneously (the thread panel resolving a success while the main composer's failure is still pending).

Banner has `role="alert"` and is announced when revealed; delaying the insertion by 2 s preserves the SR semantics on real failures (still announced, just later by the debounce interval) without announcing on transient false ones.

Picked 2 000 ms as the grace window: most successful posts return under 200 ms; a slow one taking 1 500 ms still gets the banner suppressed if a follow-up arrives. A truly dead network shows the banner only 2 s later, which still reads as live feedback. Not load-bearing; easy to tune.

Operator-visible follow-up tracked in LC-217: per-enclave admin-controllable message send rate-limit (the "later issue" the user flagged when reporting this bug).

#LC-216
fix(ui): cancel pending composer error timer on form teardown (LC-216)
All checks were successful
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / Nosey parker (push) Successful in 6s
check-secrets / Kingfisher (push) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 2m4s
check-secrets / TruffleHog (push) Successful in 3s
Create release / Create release from merged PR (pull_request) Has been skipped
4551adf9d3
Code-review follow-up: when the composer form is swapped/removed via htmx (room navigation, thread panel close), a pending `window.__lcComposerErrTimer` from the LC-216 debounce was not cleared. The timer would fire on the detached `.composer-error` element 2 s later. No visible effect (DOM mutation on a detached node is a no-op) and the next composer's first afterRequest handler defensively clears the global timer key, but the latent state was untidy.

Add the timer cleanup to the existing `teardown()` function registered on `htmx:beforeCleanupElement` against the form's `data-lc-cleanup-root`. Mirrors the sibling `bcastTimer` cleanup already there.

Thread composer at `room/thread_panel.html` does NOT have a `data-lc-cleanup-root` / `htmx:beforeCleanupElement` hook at all (pre-existing). Its `__lcThreadErrTimer` stays scheduled if the thread panel is closed mid-window, with the same no-op posture. Wider thread-composer teardown is out of scope for this ticket.

#LC-216
nrupard deleted branch fix/lc-216-composer-error-debounce 2026-05-29 17:24:18 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!258
No description provided.