merge: fix(chat): pagination skeleton no layout shift into main

Eliminates the visible push-down/snap-back when the pagination loading
skeleton appears at the top of the message list. Achieved by rendering a
constant-height (~200px) top-of-list slot above messages whenever
hasMore===true; the skeleton's grey-bar contents toggle inside that slot
rather than the slot itself mounting/unmounting. Companion changes:
load-more trigger raised to fire before the slot enters the viewport with
an iOS Safari rubber-band guard, prepend scroll-restore math corrected to
handle non-zero prevScrollTop, useDelayedLoading threshold lowered to
50ms for pagination only.
This commit is contained in:
Jannis Braun
2026-05-15 12:57:38 +02:00
3 changed files with 86 additions and 13 deletions
@@ -26,6 +26,13 @@ import { SystemMessage } from './SystemMessage';
const EMPTY_MESSAGES: MessageWithUser[] = [];
const EMPTY_PENDING_BUBBLES: PendingBubble[] = [];
// Constant-height slot rendered above messages whenever hasMore === true.
// Value derived from the pagination skeleton's analytical rendered height
// (pt-4 + 3 × (h-10 row) + 2 × mb-5 = 176px after stripping the last row's
// mb-5), rounded UP to the nearest 4-pixel step for a buffer. See
// docs/systems/message-list.md "Top-of-list reservation slot".
const PAGINATION_SLOT_HEIGHT_PX = 200;
interface MessageListProps {
channelId: string;
jumpToMessageId?: string | null;
@@ -92,7 +99,13 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
const SMOOTH_SCROLL_DEADLINE_MS = 800;
const [isLoadingMore, setIsLoadingMore] = useState(false);
const showInitialSkeleton = useDelayedLoading(isLoading && messages.length === 0);
const showPaginationSkeleton = useDelayedLoading(isLoadingMore);
// 50 ms threshold (vs the 200 ms default on showInitialSkeleton above) is
// safe here because Task 2's constant-height slot eliminated the layout
// shift the 200 ms originally hid. 50 ms is below the ~100 ms visual
// perception threshold so near-instant cache hits still complete without
// ever rendering the skeleton, while slow loads see the skeleton appear
// before the user's eye can register the slot as empty.
const showPaginationSkeleton = useDelayedLoading(isLoadingMore, { threshold: 50 });
const prevMessagesLength = useRef(0);
const prevChannelIdRef = useRef<string>(channelId);
const visibleMsgIdRef = useRef<string | null>(null);
@@ -597,10 +610,24 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
suppressLoadMore = true;
}
if (!suppressLoadMore && container.scrollTop < 50 && hasMore && !isLoadingMore) {
// scrollTop >= 0 guards against iOS Safari rubber-band overscroll producing
// briefly-negative scrollTop values, which would otherwise satisfy the
// upper bound and fire a spurious load during a rubber-band gesture.
if (
!suppressLoadMore &&
container.scrollTop >= 0 &&
container.scrollTop < PAGINATION_SLOT_HEIGHT_PX + 50 &&
hasMore &&
!isLoadingMore
) {
const requestChannelId = channelId;
setIsLoadingMore(true);
// Capture BOTH synchronously, before the await — `prevScrollTop` must
// be the pre-await value for the anchor-from-bottom formula in the
// rAF callback below to hold. Moving this capture inside the rAF or
// after the await silently breaks the math.
const prevScrollHeight = container.scrollHeight;
const prevScrollTop = container.scrollTop;
try {
const loaded = await loadMoreMessages(requestChannelId);
if (!loaded) return;
@@ -615,7 +642,12 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
if (currentChannelIdRef.current !== requestChannelId) return;
const c = containerRef.current;
if (!c) return;
c.scrollTop = c.scrollHeight - prevScrollHeight;
// Anchor-from-bottom: keep the user's viewport at the same distance
// from the new bottom of content as it was from the old bottom.
// `(c.scrollHeight - prevScrollHeight)` is the height of freshly
// prepended messages; adding it to `prevScrollTop` keeps the visible
// content stationary across the prepend.
c.scrollTop = prevScrollTop + (c.scrollHeight - prevScrollHeight);
});
} finally {
setIsLoadingMore(false);
@@ -649,17 +681,25 @@ export function MessageList({ channelId, jumpToMessageId, onJumpComplete }: Mess
className="h-full overflow-y-auto overflow-x-hidden no-scrollbar"
onScroll={handleScroll}
>
{showPaginationSkeleton && (
<div className="px-4 pt-4" role="status" aria-label="Loading older messages">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="flex gap-3 mb-5" style={{ animationDelay: `${i * 0.15}s` }}>
<div className="skeleton skeleton-circle w-10 h-10 flex-shrink-0" style={{ animationDelay: `${i * 0.15}s` }} />
<div className="flex-1 space-y-2 pt-1">
<div className="skeleton skeleton-bar" style={{ width: `${22 + (i * 9) % 18}%`, animationDelay: `${i * 0.15}s` }} />
<div className="skeleton skeleton-bar h-2.5" style={{ width: `${55 + (i * 11) % 35}%`, animationDelay: `${i * 0.15}s` }} />
</div>
{hasMore && (
<div style={{ height: PAGINATION_SLOT_HEIGHT_PX }}>
{showPaginationSkeleton && (
<div className="px-4 pt-4" role="status" aria-label="Loading older messages">
{Array.from({ length: 3 }, (_, i) => (
<div
key={i}
className={`flex gap-3 ${i < 2 ? 'mb-5' : ''}`}
style={{ animationDelay: `${i * 0.15}s` }}
>
<div className="skeleton skeleton-circle w-10 h-10 flex-shrink-0" style={{ animationDelay: `${i * 0.15}s` }} />
<div className="flex-1 space-y-2 pt-1">
<div className="skeleton skeleton-bar" style={{ width: `${22 + (i * 9) % 18}%`, animationDelay: `${i * 0.15}s` }} />
<div className="skeleton skeleton-bar h-2.5" style={{ width: `${55 + (i * 11) % 35}%`, animationDelay: `${i * 0.15}s` }} />
</div>
</div>
))}
</div>
))}
)}
</div>
)}
@@ -139,4 +139,26 @@ describe('useDelayedLoading', () => {
// the 300 ms minDisplay floor. The skeleton should be hidden by now.
expect(result.current).toBe(false);
});
it('honors a custom threshold passed via options', () => {
const { result } = renderHook(
({ loading }) => useDelayedLoading(loading, { threshold: 50 }),
{ initialProps: { loading: true } },
);
expect(result.current).toBe(false);
act(() => { vi.advanceTimersByTime(49); });
expect(result.current).toBe(false);
act(() => { vi.advanceTimersByTime(1); });
expect(result.current).toBe(true);
});
it('default 200ms threshold does not fire at t=50ms (negative control for the custom-threshold test above)', () => {
// Negative control: same conditions with the default would still be false at t=50.
const { result: defaultThresholdResult } = renderHook(
({ loading }) => useDelayedLoading(loading),
{ initialProps: { loading: true } },
);
act(() => { vi.advanceTimersByTime(50); });
expect(defaultThresholdResult.current).toBe(false);
});
});