Compare commits

...

4 Commits

Author SHA1 Message Date
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
byGalax c3ef995404 chore(desktop): release v0.21.7 2026-06-02 21:04:37 +02:00
byGalax b4ed3aced0 fix(desktop): MessageList anchors via direct scrollTop + flex-1 height
scrollToIndex raced the virtualizer's own layout effect and depended on size estimates, leaving the list pinned at the top on open (and flickering as it settled). Drive scrollTop = scrollHeight directly for the bottom case (order-independent, true bottom) and re-pin on measure; switch the scroll root from h-full to flex-1 min-h-0 so it always has a bounded, scrollable height.
2026-06-02 21:00:19 +02:00
2 changed files with 132 additions and 34 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chat-app/desktop",
"version": "0.21.6",
"version": "0.21.8",
"private": true,
"description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module",
+121 -23
View File
@@ -2,14 +2,17 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type MutableRefObject,
type ReactNode,
type RefObject,
} from 'react';
import { isNearBottom, isNearTop, type Anchor } from '../lib/scrollController';
import { isNearBottom, isNearTop } from '../lib/scrollController';
import type { VirtuosoRow } from '../pages/ConversationPage';
export interface MessageListHandle {
@@ -71,36 +74,71 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
: { scrollTop: 0, scrollHeight: 0, clientHeight: 0 };
}, []);
const applyAnchor = useCallback(
(anchor: Anchor) => {
virtualizer.scrollToIndex(anchor.index, { align: anchor.align });
// Re-apply next frame: dynamic measurement settles after first paint, so a
// single scrollToIndex can land a few px off. Still hidden here → invisible.
requestAnimationFrame(() => virtualizer.scrollToIndex(anchor.index, { align: anchor.align }));
},
[virtualizer],
const pinToBottom = useCallback(() => {
const el = scrollElRef.current;
if (!el) return;
const before = el.scrollTop;
el.scrollTop = el.scrollHeight;
// eslint-disable-next-line no-console
console.log(
`[scroll] pin sh=${el.scrollHeight} ch=${el.clientHeight} top:${Math.round(before)}->${Math.round(el.scrollTop)}`,
);
}, []);
// Deferred reveal: when ready, anchor (before paint) then reveal.
// Deferred reveal: when ready, position at the anchor (before paint), let one
// measure cycle settle (just-rendered rows get their real heights), re-pin,
// then reveal — so what appears is already final.
//
// For the bottom case we drive scrollTop = scrollHeight DIRECTLY rather than
// virtualizer.scrollToIndex: scrollToIndex depends on the virtualizer's own
// layout effect having run first (effect ordering is not guaranteed) and on its
// size estimates — when it lost that race the list stayed pinned at the TOP.
// Driving the DOM scrollTop is order-independent and always lands at the true
// bottom; the stick-to-bottom effect re-pins as the heights settle.
useLayoutEffect(() => {
if (!ready || revealed || rows.length === 0) return;
const anchor: Anchor =
initialAnchor.type === 'bottom'
? { index: rows.length - 1, align: 'end' }
: { index: Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), align: 'start' };
applyAnchor(anchor);
atBottomRef.current = initialAnchor.type === 'bottom';
const el = scrollElRef.current;
if (!el) return;
// eslint-disable-next-line no-console
console.log(
`[scroll] reveal anchor=${initialAnchor.type} rows=${rows.length} total=${Math.round(virtualizer.getTotalSize())} sh=${el.scrollHeight} ch=${el.clientHeight}`,
);
if (initialAnchor.type === 'bottom') {
pinToBottom();
atBottomRef.current = true;
} else {
virtualizer.scrollToIndex(Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), {
align: 'start',
});
atBottomRef.current = false;
}
onAtBottomChange?.(atBottomRef.current);
requestAnimationFrame(() => {
if (atBottomRef.current) {
pinToBottom();
} else if (initialAnchor.type === 'row') {
// Re-apply after the virtualizer's own layout effect has run + measured,
// so the saved scrolled-up row lands accurately (same ordering caveat as
// 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));
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, rows.length]);
// Stick-to-bottom: when content grows and we were at the bottom, re-pin.
// 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 (!revealed) return;
if (atBottomRef.current) {
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
}
// eslint-disable-next-line no-console
console.log(
`[scroll] stick? atBottom=${atBottomRef.current} total=${Math.round(virtualizer.getTotalSize())} revealed=${revealed}`,
);
if (!atBottomRef.current) return;
pinToBottom();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows.length, virtualizer.getTotalSize()]);
@@ -138,7 +176,7 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
() => ({
scrollToBottom: () => {
atBottomRef.current = true;
virtualizer.scrollToIndex(rows.length - 1, { align: 'end' });
pinToBottom();
},
scrollToRow: (index, align = 'center') => {
virtualizer.scrollToIndex(index, { align });
@@ -151,10 +189,18 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
const items = virtualizer.getVirtualItems();
return (
<>
<ScrollDebugOverlay
scrollElRef={scrollElRef}
getTotal={() => virtualizer.getTotalSize()}
atBottomRef={atBottomRef}
revealed={revealed}
ready={ready}
/>
<div
ref={scrollElRef}
onScroll={handleScroll}
className="h-full overflow-y-auto"
className="min-h-0 flex-1 overflow-y-auto"
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
@@ -178,5 +224,57 @@ export const MessageList = forwardRef<MessageListHandle, MessageListProps>(funct
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
<div style={{ height: 12 }} />
</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>
);
}