Compare commits

..

4 Commits

Author SHA1 Message Date
byGalax c81a036c4e chore(desktop): release v0.21.9 2026-06-02 21:48:43 +02:00
byGalax bdc017e609 fix(desktop): MessageList sticks to bottom via ResizeObserver + scroll guard
Data from the on-screen overlay showed SCROLLABLE=YES but scrollTop=84/0 and atBottom=false: the initial pin happened before rows finished measuring, then a measurement reflow fired onScroll with the stale (top) scrollTop, flipping atBottom=false and disabling re-pinning, so the list never reached the bottom. Fix: a ResizeObserver re-pins to the true bottom as the content measures/grows; a programmatic-scroll guard makes onScroll ignore the scrolls we cause (so measurement reflows no longer flip the stick intent); overflow-anchor:none so the browser doesn't fight us; reveal waits for the height to settle. Overlay kept for one more verification pass.
2026-06-02 21:47:15 +02:00
byGalax 27160145f9 chore(desktop): release v0.21.8 2026-06-02 21:37:29 +02:00
byGalax 8ea2cb48e9 debug(desktop): on-screen scroll-metrics overlay (temporary) 2026-06-02 21:34:04 +02:00
2 changed files with 143 additions and 46 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.21.7", "version": "0.21.9",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
+134 -37
View File
@@ -2,11 +2,14 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
useEffect,
useImperativeHandle, useImperativeHandle,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
useState, useState,
type MutableRefObject,
type ReactNode, type ReactNode,
type RefObject,
} from 'react'; } from 'react';
import { isNearBottom, isNearTop } from '../lib/scrollController'; import { isNearBottom, isNearTop } from '../lib/scrollController';
@@ -51,6 +54,13 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
const scrollElRef = useRef<HTMLDivElement>(null); const scrollElRef = useRef<HTMLDivElement>(null);
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
const atBottomRef = useRef(true); const atBottomRef = useRef(true);
// Intent: keep the view pinned to the bottom? Only a genuine user scroll flips
// this — measurement reflows must not.
const stickRef = useRef(true);
// Guard: scroll events we cause (pin / measure re-pin) fire a tick after we set
// scrollTop. handleScroll ignores events within this window so a measurement
// reflow is never mistaken for the user scrolling up.
const programmaticRef = useRef(0);
// Load-older preservation: remember the first row key + scrollHeight so a // Load-older preservation: remember the first row key + scrollHeight so a
// prepend can be detected and the viewport restored. // prepend can be detected and the viewport restored.
const prevFirstKeyRef = useRef<string | null>(null); const prevFirstKeyRef = useRef<string | null>(null);
@@ -73,58 +83,79 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
const pinToBottom = useCallback(() => { const pinToBottom = useCallback(() => {
const el = scrollElRef.current; const el = scrollElRef.current;
if (el) el.scrollTop = el.scrollHeight; if (!el) return;
programmaticRef.current = performance.now();
el.scrollTop = el.scrollHeight;
}, []); }, []);
// Deferred reveal: when ready, position at the anchor (before paint), let one // Re-pin to the true bottom whenever the content (or viewport) resizes while
// measure cycle settle (just-rendered rows get their real heights), re-pin, // sticking. ResizeObserver fires after layout / before paint, so as rows measure
// then reveal — so what appears is already final. // and the list grows the bottom stays pinned with no stale frame — THIS is what
// // carries the list to the bottom through the measure settle (the old
// For the bottom case we drive scrollTop = scrollHeight DIRECTLY rather than // getTotalSize effect lagged a frame, letting onScroll wrongly flip atBottom).
// virtualizer.scrollToIndex: scrollToIndex depends on the virtualizer's own useEffect(() => {
// layout effect having run first (effect ordering is not guaranteed) and on its const el = scrollElRef.current;
// size estimates — when it lost that race the list stayed pinned at the TOP. if (!el) return;
// Driving the DOM scrollTop is order-independent and always lands at the true const ro = new ResizeObserver(() => {
// bottom; the stick-to-bottom effect re-pins as the heights settle. const e = scrollElRef.current;
if (stickRef.current && e) {
programmaticRef.current = performance.now();
e.scrollTop = e.scrollHeight;
}
});
ro.observe(el);
const inner = el.firstElementChild;
if (inner) ro.observe(inner);
return () => ro.disconnect();
}, []);
// Deferred reveal: when ready, pin to the anchor and keep pinning each frame
// until the list height has SETTLED (rows measure over 1-2 frames and grow it),
// THEN reveal — so what appears is already at the final bottom, with no
// top-then-jump flicker. The ResizeObserver above keeps it pinned afterwards.
useLayoutEffect(() => { useLayoutEffect(() => {
if (!ready || revealed || rows.length === 0) return; if (!ready || revealed || rows.length === 0) return;
const el = scrollElRef.current; const el = scrollElRef.current;
if (!el) return; if (!el) return;
const rowIdx = Math.max(
0,
Math.min(initialAnchor.type === 'row' ? initialAnchor.index : 0, rows.length - 1),
);
if (initialAnchor.type === 'bottom') { if (initialAnchor.type === 'bottom') {
pinToBottom(); stickRef.current = true;
atBottomRef.current = true; atBottomRef.current = true;
pinToBottom();
} else { } else {
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), { stickRef.current = false;
align: 'start',
});
atBottomRef.current = false; atBottomRef.current = false;
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
} }
onAtBottomChange?.(atBottomRef.current); onAtBottomChange?.(atBottomRef.current);
requestAnimationFrame(() => { let prevSH = -1;
if (atBottomRef.current) { const settle = (attempts: number): void => {
pinToBottom(); const e = scrollElRef.current;
} else if (initialAnchor.type === 'row') { if (!e) {
// Re-apply after the virtualizer's own layout effect has run + measured, setRevealed(true);
// so the saved scrolled-up row lands accurately (same ordering caveat as return;
// the bottom case, handled here by deferring a frame).
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
align: 'start',
});
} }
requestAnimationFrame(() => setRevealed(true)); if (stickRef.current) {
}); programmaticRef.current = performance.now();
e.scrollTop = e.scrollHeight;
} else {
virtualizer.scrollToIndex(rowIdx, { align: 'start' });
}
const sh = e.scrollHeight;
if (sh === prevSH || attempts <= 0) {
setRevealed(true);
} else {
prevSH = sh;
requestAnimationFrame(() => settle(attempts - 1));
}
};
requestAnimationFrame(() => settle(10));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, rows.length]); }, [ready, rows.length]);
// Stick-to-bottom: whenever content grows (new row OR a measured row got
// taller) and we were at the bottom, re-pin to the true bottom. Runs while
// hidden too, so the list stays pinned through the initial measure settle.
useLayoutEffect(() => {
if (!atBottomRef.current) return;
pinToBottom();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows.length, virtualizer.getTotalSize()]);
// Load-older preservation: if rows were prepended (first key changed and the // Load-older preservation: if rows were prepended (first key changed and the
// user is near the top), restore scrollTop by the height delta so the viewport // user is near the top), restore scrollTop by the height delta so the viewport
// stays put instead of jumping. // stays put instead of jumping.
@@ -143,8 +174,13 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
}, [rows]); }, [rows]);
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
// Ignore scroll events we triggered (pin / measure re-pin); they fire a tick
// after we set scrollTop. Only a genuine user scroll updates the stick intent —
// otherwise a measurement reflow wrongly flips atBottom and stops the pinning.
if (performance.now() - programmaticRef.current < 120) return;
const m = readMetrics(); const m = readMetrics();
const atBottom = isNearBottom(m, atBottomThreshold); const atBottom = isNearBottom(m, atBottomThreshold);
stickRef.current = atBottom;
if (atBottom !== atBottomRef.current) { if (atBottom !== atBottomRef.current) {
atBottomRef.current = atBottom; atBottomRef.current = atBottom;
onAtBottomChange?.(atBottom); onAtBottomChange?.(atBottom);
@@ -158,6 +194,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
ref, ref,
() => ({ () => ({
scrollToBottom: () => { scrollToBottom: () => {
stickRef.current = true;
atBottomRef.current = true; atBottomRef.current = true;
pinToBottom(); pinToBottom();
}, },
@@ -172,11 +209,19 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
const items = virtualizer.getVirtualItems(); const items = virtualizer.getVirtualItems();
return ( return (
<>
<ScrollDebugOverlay
scrollElRef={scrollElRef}
getTotal={() => virtualizer.getTotalSize()}
atBottomRef={atBottomRef}
revealed={revealed}
ready={ready}
/>
<div <div
ref={scrollElRef} ref={scrollElRef}
onScroll={handleScroll} onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto" className="min-h-0 flex-1 overflow-y-auto"
style={{ opacity: revealed ? 1 : 0, position: 'relative' }} style={{ opacity: revealed ? 1 : 0, position: 'relative', overflowAnchor: 'none' }}
> >
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{items.map((vi) => ( {items.map((vi) => (
@@ -199,5 +244,57 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */} {/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
<div style={{ height: 12 }} /> <div style={{ height: 12 }} />
</div> </div>
</>
); );
}); });
interface ScrollDebugOverlayProps {
scrollElRef: RefObject<HTMLDivElement>;
getTotal: () => number;
atBottomRef: MutableRefObject<boolean>;
revealed: boolean;
ready: boolean;
}
// TEMP on-screen debug readout — remove after the chat-switch scroll bug is fixed.
function ScrollDebugOverlay({
scrollElRef,
getTotal,
atBottomRef,
revealed,
ready,
}: ScrollDebugOverlayProps) {
const [, force] = useState(0);
useEffect(() => {
let raf = 0;
const loop = () => {
force((n) => (n + 1) % 1_000_000);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
const el = scrollElRef.current;
const sh = el?.scrollHeight ?? 0;
const ch = el?.clientHeight ?? 0;
const st = el ? Math.round(el.scrollTop) : 0;
return (
<div
style={{
position: 'fixed',
top: 8,
right: 8,
zIndex: 99999,
background: 'rgba(0,0,0,0.82)',
color: '#19ff8a',
font: '11px/1.4 monospace',
padding: '6px 9px',
borderRadius: 5,
pointerEvents: 'none',
whiteSpace: 'pre',
}}
>
{`scrollHeight=${sh} clientHeight=${ch}\nscrollTop=${st}\ntotalSize=${Math.round(getTotal())}\nSCROLLABLE=${sh > ch + 4 ? 'YES' : 'NO'}\natBottom=${atBottomRef.current} revealed=${revealed} ready=${ready}`}
</div>
);
}