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
@@ -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);
});
});