Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 158 additions & 1 deletion src/components/MessageList/__tests__/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,18 @@ describe('MessageList', () => {
configurable: true,
value: scrollByMock,
});
// `offsetTop` is layout-based and does not depend on the current scroll position.
const originalOffsetTop = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetTop',
);
Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
configurable: true,
get() {
const contentTopById = { 'current-1': 520, 'current-2': 680 };
return contentTopById[this.dataset?.messageId] ?? 0;
},
});
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
configurable: true,
value: function getBoundingClientRect() {
Expand Down Expand Up @@ -1553,9 +1565,14 @@ describe('MessageList', () => {
expect(screen.getByText('older-1')).toBeInTheDocument();
});

expect(scrollByMock).toHaveBeenCalledWith({ top: 300 });
// The restore assigns an absolute scrollTop; a relative `scrollBy` oscillates on iOS WebKit.
expect(scrollByMock).not.toHaveBeenCalled();
expect(listElement.scrollTop).toBe(520);

if (originalOffsetTop) {
Object.defineProperty(HTMLElement.prototype, 'offsetTop', originalOffsetTop);
}

if (originalScrollBy) {
Object.defineProperty(HTMLElement.prototype, 'scrollBy', {
configurable: true,
Expand All @@ -1569,6 +1586,146 @@ describe('MessageList', () => {
value: originalGetBoundingClientRect,
});
});

describe('older page prepended', () => {
const OFFSET_TOP_BY_ID = { 'current-1': 520, 'current-2': 680 };
let restores: Array<() => void> = [];
const patch = (target: object, key: string, descriptor: PropertyDescriptor) => {
const original = Object.getOwnPropertyDescriptor(target, key);
Object.defineProperty(target, key, { configurable: true, ...descriptor });
restores.push(() => {
if (original) Object.defineProperty(target, key, original);
else delete (target as Record<string, unknown>)[key];
});
};

afterEach(() => {
restores.forEach((restore) => restore());
restores = [];
});

const renderPrependHarness = async ({
startScrollTop,
}: {
startScrollTop: number;
}) => {
const currentMessages = ['current-1', 'current-2'].map((id) =>
generateMessage({ id, text: id, user: user1 }),
);
const prependedMessages = [
...['older-1', 'older-2'].map((id) =>
generateMessage({ id, text: id, user: user2 }),
),
...currentMessages,
];
const scrollByMock = vi.fn();
const scrollToMock = vi.fn(function scrollTo(this: HTMLElement, options) {
if (typeof options?.top === 'number') this.scrollTop = options.top;
});
patch(HTMLElement.prototype, 'scrollBy', { value: scrollByMock });
patch(HTMLElement.prototype, 'scrollTo', { value: scrollToMock });
patch(HTMLElement.prototype, 'offsetTop', {
get() {
return OFFSET_TOP_BY_ID[this.dataset?.messageId] ?? 0;
},
});
let rectCalls = 1;
// Reads before the older page lands are exact. Reads after it are unstable: every call
// differs, like iOS WebKit's stale reads in the frame after a scroll.
patch(HTMLElement.prototype, 'getBoundingClientRect', {
value() {
const top = screen.queryByText('older-1')
? (rectCalls++ % 2 ? 1 : -1) * 100 * rectCalls
: 100;
return {
bottom: top + 120,
height: 120,
left: 0,
right: 0,
top,
width: 0,
x: 0,
y: top,
};
},
});

const MessageListHarness = () => {
const [renderedMessages, setRenderedMessages] =
React.useState(currentMessages);
const [loadingMore, setLoadingMore] = React.useState(false);
return (
<>
<button onClick={() => setLoadingMore(true)} type='button'>
start load older
</button>
<button
onClick={() => {
setRenderedMessages(prependedMessages);
setLoadingMore(false);
}}
type='button'
>
finish load older
</button>
<Chat client={chatClient}>
<Channel channel={channel}>
<MessageList
loadingMore={loadingMore}
messages={renderedMessages}
scrolledUpThreshold={200}
/>
</Channel>
</Chat>
</>
);
};

render(<MessageListHarness />);
await waitFor(() => expect(screen.getByText('current-1')).toBeInTheDocument());

const listElement = document.querySelector(
'.str-chat__message-list',
) as HTMLElement;
Object.defineProperties(listElement, {
offsetHeight: { configurable: true, value: 250 },
scrollHeight: { configurable: true, value: 600, writable: true },
scrollTop: { configurable: true, value: startScrollTop, writable: true },
});
fireEvent.scroll(listElement, { target: { scrollTop: startScrollTop } });
fireEvent.click(screen.getByText('start load older'));
Object.defineProperty(listElement, 'scrollHeight', {
configurable: true,
value: 900,
writable: true,
});
fireEvent.click(screen.getByText('finish load older'));
await waitFor(() => expect(screen.getByText('older-1')).toBeInTheDocument());
return { listElement, scrollByMock, scrollToMock };
};

it('restores the anchor from layout offsets and stays put when geometry reads are unstable', async () => {
const { listElement, scrollByMock } = await renderPrependHarness({
startScrollTop: 50,
});
// let several animation frames run; a relative correction would keep moving the list
await new Promise((resolve) => setTimeout(resolve, 200));

expect(scrollByMock).not.toHaveBeenCalled();
expect(listElement.scrollTop).toBe(520);
});

it('keeps the new page pinned to the top when pagination started from the absolute top', async () => {
const { listElement, scrollByMock, scrollToMock } = await renderPrependHarness({
startScrollTop: 0,
});
await new Promise((resolve) => setTimeout(resolve, 200));

expect(scrollByMock).not.toHaveBeenCalled();
expect(scrollToMock).toHaveBeenCalledWith({ top: 0 });
expect(listElement.scrollTop).toBe(0);
});
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useMessageListScrollManager } from './useMessageListScrollManager';
import type { LocalMessage } from 'stream-chat';

const getOffsetFromDocument = (element: HTMLElement) => {
let top = 0;
let current: HTMLElement | null = element;
while (current) {
top += current.offsetTop;
current = current.offsetParent as HTMLElement | null;
}
return top;
};

// Distance of `element`'s top edge from the top of `container`'s scrollable content. Built from
// `offsetTop`, which is layout-based and unaffected by the container's current scroll position,
// so the result is the same whether or not the container has scrolled yet.
const getOffsetWithin = (element: HTMLElement, container: HTMLElement) =>
getOffsetFromDocument(element) - getOffsetFromDocument(container) - container.clientTop;

export type UseScrollLocationLogicParams = {
/** Disables automatic scroll-to-bottom updates after message changes. */
disableAutoScrollToBottom?: boolean;
Expand Down Expand Up @@ -153,12 +169,15 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
);
if (!anchorElement) return true;

const listTop = listElement.getBoundingClientRect().top;
const nextOffsetTop = anchorElement.getBoundingClientRect().top - listTop;
const offsetDelta = nextOffsetTop - anchor.offsetTop;
// The target is absolute and derived from layout (`offsetTop` ignores scroll), so
// re-applying it is idempotent. A relative `scrollBy` correction measured from
// `getBoundingClientRect` oscillates on iOS WebKit, where the geometry read in the
// frame after a scroll still reflects the pre-scroll position.
const targetScrollTop =
getOffsetWithin(anchorElement, listElement) - anchor.offsetTop;

if (Math.abs(offsetDelta) > 1) {
listElement.scrollBy({ top: offsetDelta });
if (Math.abs(listElement.scrollTop - targetScrollTop) > 1) {
listElement.scrollTop = targetScrollTop;
return false;
}

Expand Down
Loading