fix(autoscroll): re-anchor on composer resize #147

Merged
nrupard merged 3 commits from fix/mention-autocomplete-breaks-autoscroll into main 2026-05-18 21:35:27 +02:00
Owner

Reported: after using an @mention (autocomplete pick or hand-typed token), the room/DM autoscroll appears to stop working. New messages arrive in the database and over WS but the view does not move to them. Same symptom for any input that grows the composer textarea past one line.

Root cause: the autoscroll hook only inspects scroll geometry on htmx swap events. When the composer textarea grows (mention insert calls ta.dispatchEvent(new Event('input')) which runs the oninput handler and bumps ta.style.height), the #messages flex child shrinks from the bottom. scrollTop is unchanged by the browser, so scrollTop + clientHeight is now below scrollHeight - slack and isAtBottom() returns false. The next message swap fires htmx:beforeSwap, snapshots wasAtBottom = false, and the htmx:afterSwap restore is a no-op. The user perceives autoscroll as broken even though they never scrolled.

Fix: add a ResizeObserver on #messages in partials/auto_scroll.html. On every container resize, if the pre-resize wasAtBottom flag is still true (no real scroll event has fired to flip it), re-anchor to the bottom. Teardown disconnects the observer alongside the existing event-listener cleanup so the WS reconnect soft-refresh does not leak observers. ResizeObserver is feature-detected for safety; all supported browsers have it.

The partial is included by both room/page.html and dm/page.html, so the fix lands in both surfaces at once. No backend changes; just check clean.

Manual repro:

  1. Open a room, scroll to bottom.
  2. Type @ + a letter, pick a user from the popover (textarea grows by one line).
  3. Have a second user post a message in the same room.
  4. Before this fix: the new message lands in the DOM but stays off-screen below the visible area. After this fix: view re-anchors to the bottom and the new message is visible.
Reported: after using an `@mention` (autocomplete pick or hand-typed token), the room/DM autoscroll appears to stop working. New messages arrive in the database and over WS but the view does not move to them. Same symptom for any input that grows the composer textarea past one line. Root cause: the autoscroll hook only inspects scroll geometry on htmx swap events. When the composer textarea grows (mention insert calls `ta.dispatchEvent(new Event('input'))` which runs the `oninput` handler and bumps `ta.style.height`), the `#messages` flex child shrinks from the bottom. `scrollTop` is unchanged by the browser, so `scrollTop + clientHeight` is now below `scrollHeight - slack` and `isAtBottom()` returns false. The next message swap fires `htmx:beforeSwap`, snapshots `wasAtBottom = false`, and the `htmx:afterSwap` restore is a no-op. The user perceives autoscroll as broken even though they never scrolled. Fix: add a `ResizeObserver` on `#messages` in `partials/auto_scroll.html`. On every container resize, if the pre-resize `wasAtBottom` flag is still true (no real `scroll` event has fired to flip it), re-anchor to the bottom. Teardown disconnects the observer alongside the existing event-listener cleanup so the WS reconnect soft-refresh does not leak observers. `ResizeObserver` is feature-detected for safety; all supported browsers have it. The partial is included by both `room/page.html` and `dm/page.html`, so the fix lands in both surfaces at once. No backend changes; `just check` clean. Manual repro: 1. Open a room, scroll to bottom. 2. Type `@` + a letter, pick a user from the popover (textarea grows by one line). 3. Have a second user post a message in the same room. 4. Before this fix: the new message lands in the DOM but stays off-screen below the visible area. After this fix: view re-anchors to the bottom and the new message is visible.
fix(autoscroll): re-anchor on composer resize, not just on htmx swap
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 54s
572419309c
Reported: after using an @mention (autocomplete pick or hand-typed token), the room/DM autoscroll appears to stop working - new messages arrive but the view does not move to them. Same symptom for any input that grows the composer textarea past one line.

Root cause: the autoscroll hook only inspects scroll geometry on htmx swap events. When the composer textarea grows (mention insert calls `ta.dispatchEvent(new Event('input'))` which runs the `oninput` handler and bumps `ta.style.height`), the `#messages` flex child shrinks from the bottom. `scrollTop` is unchanged, so `scrollTop + clientHeight` now sits below `scrollHeight - slack` and `isAtBottom()` returns false. The next message swap fires `htmx:beforeSwap`, snapshots `wasAtBottom = false`, and the `htmx:afterSwap` restore is a no-op. The user perceives autoscroll as broken even though they never scrolled.

Fix: add a `ResizeObserver` on `#messages` in `partials/auto_scroll.html`. On every container resize, if the pre-resize `wasAtBottom` flag is still true (no real `scroll` event has fired to flip it), re-anchor to the bottom. Teardown disconnects the observer alongside the existing event-listener cleanup so the WS reconnect soft-refresh does not leak observers. `ResizeObserver` is feature-detected for safety; all supported browsers have it.

The partial is included by both `room/page.html` and `dm/page.html`, so the fix lands in both surfaces at once.
fix(autoscroll): drive wasAtBottom from scroll events only
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 53s
8e157a843f
First-attempt fix on this branch was incomplete: it added a `ResizeObserver` re-anchor, but kept `htmx:beforeSwap` overwriting `wasAtBottom = isAtBottom()`. After the composer textarea grew, the next swap (popover refresh on next keystroke, typing-indicator OOB, anything) called the beforeSwap handler, snapshotted the post-resize geometry as "not at bottom", and the RO callback that fired afterwards saw `wasAtBottom = false` and skipped the scroll. WS messages then landed off-screen as before.

Stop overwriting `wasAtBottom` inside htmx hooks. Real `scroll` events on `#messages` are the only thing that should change the flag, because they are the only signal that reflects user intent (programmatic `scrollToBottom()` also fires a scroll event whose `isAtBottom()` reads true, so the flag re-pins itself). The afterSwap and oobAfterSwap handlers, and the RO callback, all read the same `wasAtBottom` without mutating it. Layout-driven `isAtBottom()` drift no longer poisons the flag.

This also catches the typing-indicator case (when "X is typing" appears between `#messages` and the composer, the indicator div grows from 0 to ~20px and shrinks `#messages` the same way the textarea does).
fix(autoscroll): scope teardown to #messages, not bubbling ancestor
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m2s
8911207f88
Real root cause: `htmx:beforeCleanupElement` BUBBLES. The previous teardown was attached to the auto_scroll script's parent (`<main>` via the `data-lc-cleanup-root` fallback) with `{ once: true }`. The mention popover refresh swaps `innerHTML` on `#lc-mention-popover`, which makes htmx clean up the previous `<ul>`. The `beforeCleanupElement` event for that cleaned `<ul>` bubbles up through the composer form, the layout wrappers, and into `<main>`, where it fires the autoscroll teardown ONCE. After that, the scroll listener, both htmx swap listeners, and the ResizeObserver are gone. From the user's perspective, autoscroll appears to start working, then dies after the first or second mention-related popover interaction.

Fix: attach the teardown listener to `#messages` itself and filter on `evt.target === messages`. Descendant cleanups (message edits via OOB outerHTML, etc.) still bubble through but the filter drops them. Real teardown still fires when `#messages` itself is detached as part of a page swap (the cleanup walk fires `beforeCleanupElement` on every removed element, including `#messages`). Remove the `{ once: true }` flag and gate on an explicit `torndown` boolean so a descendant bubble cannot consume the listener before the real teardown event reaches it.

This is a class of bug: any other IIFE in the codebase that uses the same `root.addEventListener('htmx:beforeCleanupElement', teardown, { once: true })` pattern has the same problem and will tear itself down on the first descendant cleanup. Out of scope for this PR; the composer and room/page WS-subscribe scripts both use this pattern and should be revisited.
nrupard deleted branch fix/mention-autocomplete-breaks-autoscroll 2026-05-18 21:35:27 +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!147
No description provided.