Hold the opening scroll while the conversation settles

Opening an already-read conversation stopped 39px short of the bottom,
every time (#89). The messages were all there and one scroll fixed it,
but the pane was not where it meant to be.

The scroll runs in an effect, which is too early. Message bodies go into
shadow roots from the child effects underneath it, and the images in
those load later still, so the pane goes on growing after the scroll has
already happened -- and `scrollIntoView` clamps to the scroll range as it
stands the moment it is called. The read-thread fallback aims at the last
message, which no thread has the room to lift to the top, so that clamp
*is* the whole of the range. Measuring it before the images landed
measured it short.

So the target is now held against the top of the pane while the thread
settles: a ResizeObserver over the children of the scroller re-aligns it
whenever one of them changes height.

The hold ends the instant the reader touches the pane -- wheel, pointer,
touch or any key -- and after two seconds regardless. A pane that
re-scrolls under someone who has started reading is far worse than one
that lands short, so it lets go on the first sign of them rather than
waiting for the content to stop changing.

Verified against the mock, on the same already-read seven-message thread,
eight opens each way: before, all eight landed at scrollTop 96 of a 135
range; after, all eight land at 135. The #87 cases are unchanged -- an
unread message mid-thread still comes to rest flush against the top of
the pane, and a thread whose first message is the unread one still stays
at 0 with the subject in view. Scrolling or pressing a key during the
hold leaves the pane exactly where it was put.

One correction to #89 while I am here: it reported the pane sometimes not
moving at all. That was an artifact of measuring in a background tab,
where Chrome suspends rendering and clamps timers -- the behaviour in a
visible tab is the deterministic 39px above. The issue is real; that one
observation in it was not.
This commit is contained in:
2026-08-27 07:11:16 -07:00
parent 32227722a7
commit 650ba0020b
+48 -4
View File
@@ -12,6 +12,9 @@ import { client } from "@/jmap/client";
import { LabelPicker } from "./LabelPicker";
import { threadScrollTarget } from "@/lib/threadScroll";
/** How long the opening scroll keeps its place while bodies and images land. */
const HOLD_MS = 2000;
interface Props {
threadId: Id;
mailboxId: Id | null;
@@ -116,13 +119,54 @@ export function ThreadView({ threadId, mailboxId, onBack, actions, onNavigate, h
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages.map((m) => m.id + (m.keywords.$seen ? "1" : "0")).join(","), settings.markReadDelay]);
// Open on the first unread message rather than the newest one (#87).
/*
* Open on the first unread message rather than the newest one (#87).
*
* Scrolling once is not enough. Message bodies are written into shadow roots
* by child effects, and the images in them load later still, so the pane goes
* on growing after the scroll -- and `scrollIntoView` clamps to the scroll
* range as it stands the moment it is called. The read-thread fallback always
* aims at the last message, which no thread has the room to lift to the top,
* so that clamp is the whole of the range: measuring it before the images
* landed stopped 39px short of the bottom, every time (#89).
*
* So the target is held against the top of the pane while the thread settles,
* and let go the moment the reader touches it. A pane that re-scrolls under
* someone who has started reading is worse than one that lands short, which
* is why the hold ends on the first sign of them rather than when the content
* stops changing.
*/
useEffect(() => {
if (!messages.length || !scrollRef.current) return;
const sc = scrollRef.current;
if (!messages.length || !sc) return;
const target = threadScrollTarget(messages, wasUnread);
if (!target) return;
const el = scrollRef.current.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(target)}"]`);
el?.scrollIntoView({ block: "start" });
let held = true;
const align = () => {
if (held) sc.querySelector<HTMLElement>(`[data-msg-id="${CSS.escape(target)}"]`)?.scrollIntoView({ block: "start" });
};
const release = () => {
held = false;
};
align();
// The messages and the reply box: what grows is one of their heights.
const ro = new ResizeObserver(align);
for (const child of sc.children) ro.observe(child);
// `scroll` is not in here: the aligning does that itself.
for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.addEventListener(ev, release, { passive: true });
window.addEventListener("keydown", release);
const settled = window.setTimeout(release, HOLD_MS);
return () => {
release();
ro.disconnect();
for (const ev of ["wheel", "pointerdown", "touchstart"]) sc.removeEventListener(ev, release);
window.removeEventListener("keydown", release);
window.clearTimeout(settled);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId, messages.length > 0]);