Skip to content

Commit 12e282f

Browse files
authored
fix(MessageList): prevent message pagination too early on mount (#3143)
1 parent 917b7f5 commit 12e282f

7 files changed

Lines changed: 58 additions & 4 deletions

File tree

examples/vite/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<title>Stream Chat React</title>
8+
<link rel="preconnect" href="https://fonts.googleapis.com" />
9+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10+
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" />
11+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap" />
12+
<link rel="preload" as="font" type="font/woff2" crossorigin href="https://fonts.gstatic.com/s/geist/v4/gyByhwUxId8gMEwcGFU.woff2" />
813
</head>
914
<body>
1015
<div id="root"></div>

examples/vite/src/index.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
@layer stream-new, stream-new-plugins, stream-overrides, stream-app-overrides;
22

3-
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap');
3+
// Geist font is preloaded in index.html to avoid layout shift on mount.
4+
//@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap');
45

56
// v3 CSS import
67
@import url('stream-chat-react/dist/css/index.css') layer(stream-new);

src/components/InfiniteScrollPaginator/InfiniteScroll.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,18 @@ export const InfiniteScroll = (props: PropsWithChildren<InfiniteScrollProps>) =>
109109

110110
scrollElement.addEventListener('scroll', scrollListener, useCapture);
111111
scrollElement.addEventListener('resize', scrollListener, useCapture);
112-
scrollListener();
112+
113+
// Defer the initial proximity check so that any pending scroll-to-bottom
114+
// from useLayoutEffect (e.g. the MessageList settle pass) has been applied
115+
// to the DOM before we evaluate whether more pages should be loaded.
116+
// Without this, scrollTop is still 0 on mount which falsely triggers
117+
// loadPreviousPage and breaks the initial scroll position.
118+
const rafId = requestAnimationFrame(() => {
119+
scrollListener();
120+
});
113121

114122
return () => {
123+
cancelAnimationFrame(rafId);
115124
scrollElement.removeEventListener('scroll', scrollListener, useCapture);
116125
scrollElement.removeEventListener('resize', scrollListener, useCapture);
117126
};

src/components/InfiniteScrollPaginator/__tests__/InfiniteScroll.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,25 @@ describe('InfiniteScroll', () => {
9090
},
9191
);
9292

93+
it('should not call loadPreviousPage synchronously on mount', () => {
94+
renderComponent({
95+
hasPreviousPage: true,
96+
});
97+
98+
expect(loadPreviousPage).not.toHaveBeenCalled();
99+
});
100+
101+
it('should defer the initial scroll check to a requestAnimationFrame', () => {
102+
const rafSpy = vi.spyOn(window, 'requestAnimationFrame');
103+
104+
renderComponent({
105+
hasPreviousPage: true,
106+
});
107+
108+
expect(rafSpy).toHaveBeenCalledWith(expect.any(Function));
109+
rafSpy.mockRestore();
110+
});
111+
93112
describe('Rendering loader', () => {
94113
const getRenderResult = () => {
95114
const props = fromPartial<InfiniteScrollProps>({

src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { LocalMessage } from 'stream-chat';
66
export type ContainerMeasures = {
77
offsetHeight: number;
88
scrollHeight: number;
9+
scrollTop: number;
910
};
1011

1112
export type UseMessageListScrollManagerParams = {
@@ -101,6 +102,7 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP
101102
const measures = useRef<ContainerMeasures>({
102103
offsetHeight: 0,
103104
scrollHeight: 0,
105+
scrollTop: 0,
104106
});
105107
const messages = useRef<LocalMessage[]>(undefined);
106108
const olderPaginationState = useRef<OlderPaginationState>({
@@ -130,16 +132,32 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP
130132
const finishedLoadingOlder = !loadingMore && previousLoadingMoreRef.current;
131133

132134
if (startedLoadingOlder) {
135+
// Read the live DOM scroll position instead of the cached ref so we get
136+
// the correct value even when scrollToBottom() has been called but the
137+
// async scroll event hasn't updated the ref yet (common on initial mount).
138+
const liveMeasures = scrollContainerMeasures();
139+
const hasOverflow = liveMeasures.scrollHeight > liveMeasures.offsetHeight;
140+
const liveScrollTop = liveMeasures.scrollTop;
141+
133142
// Older-page pagination uses one of three modes:
134143
// - `stick-to-top`: user hit the absolute top and wants to keep reading upward
135144
// - `preserve-anchor`: user was only near the top, so keep the same message in view
136145
// - `idle`: no restoration needed for this load cycle
137-
if (scrollTop.current <= 1) {
146+
//
147+
// When the container doesn't overflow yet (e.g. content hasn't reached its
148+
// final height due to font loading) the scroll position is meaningless, so
149+
// default to idle to avoid a false stick-to-top that would jump the list.
150+
if (!hasOverflow) {
151+
olderPaginationState.current = {
152+
anchor: null,
153+
mode: 'idle',
154+
};
155+
} else if (liveScrollTop <= 1) {
138156
olderPaginationState.current = {
139157
anchor: null,
140158
mode: 'stick-to-top',
141159
};
142-
} else if (scrollTop.current < loadMoreScrollThreshold) {
160+
} else if (liveScrollTop < loadMoreScrollThreshold) {
143161
const capturedAnchor = captureAnchor();
144162
if (capturedAnchor) {
145163
olderPaginationState.current = {

src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
393393
scrollContainerMeasures: () => ({
394394
offsetHeight: listElement?.offsetHeight || 0,
395395
scrollHeight: listElement?.scrollHeight || 0,
396+
scrollTop: listElement?.scrollTop || 0,
396397
}),
397398
scrolledUpThreshold,
398399
scrollToBottom,

src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const defaultInputs: any = {
2424
scrollContainerMeasures: () => ({
2525
offsetHeight: 200,
2626
scrollHeight: 2000,
27+
scrollTop: 0,
2728
}),
2829
scrolledUpThreshold: 200,
2930
scrollToBottom: () => {},

0 commit comments

Comments
 (0)