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
225 changes: 224 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,212 @@ 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 ({
inlineOverflowY,
startScrollTop,
}: {
inlineOverflowY?: string;
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>
</>
);
};

const { unmount } = 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 },
});
if (inlineOverflowY) listElement.style.overflowY = inlineOverflowY;
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, unmount };
};

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

describe('momentum scrolling', () => {
const originalCSS = Object.getOwnPropertyDescriptor(globalThis, 'CSS');
afterEach(() => {
if (originalCSS) Object.defineProperty(globalThis, 'CSS', originalCSS);
else delete (globalThis as Record<string, unknown>).CSS;
});
const setIosWebKit = (isIos: boolean) =>
Object.defineProperty(globalThis, 'CSS', {
configurable: true,
value: {
supports: (property: string) =>
isIos && property === '-webkit-touch-callout',
},
});

it('ends the fling on iOS WebKit while restoring, then hands scrolling back', async () => {
setIosWebKit(true);
const seen: string[] = [];
const { listElement } = await renderPrependHarness({ startScrollTop: 50 });
seen.push(listElement.style.overflowY);
await new Promise((resolve) => setTimeout(resolve, 300));

// iOS keeps animating a fling over programmatic positions until its overflow is hidden.
expect(seen[0]).toBe('hidden');
expect(listElement.style.overflowY).toBe('');
expect(listElement.scrollTop).toBe(520);
});

it('hands scrolling back when the list unmounts mid-restore', async () => {
setIosWebKit(true);
const { listElement, unmount } = await renderPrependHarness({
startScrollTop: 50,
});
expect(listElement.style.overflowY).toBe('hidden');

unmount();

expect(listElement.style.overflowY).toBe('');
});

it('restores an inline overflow-y set by the app instead of clearing it', async () => {
setIosWebKit(true);
const { listElement } = await renderPrependHarness({
inlineOverflowY: 'scroll',
startScrollTop: 50,
});
expect(listElement.style.overflowY).toBe('hidden');
await new Promise((resolve) => setTimeout(resolve, 300));

expect(listElement.style.overflowY).toBe('scroll');
});

it('leaves the overflow alone outside iOS WebKit', async () => {
setIosWebKit(false);
const { listElement } = await renderPrependHarness({ startScrollTop: 50 });

expect(listElement.style.overflowY).toBe('');
await new Promise((resolve) => setTimeout(resolve, 300));
expect(listElement.style.overflowY).toBe('');
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,41 @@ 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;

// iOS WebKit keeps a touch fling ("momentum scroll") animating after the finger lifts, and that
// animation overrides programmatic scroll positions every frame, pulling the list away from the
// position we just restored. Hiding the overflow ends the fling. The scrollbar is an overlay on
// iOS, so toggling it does not shift the layout. `-webkit-touch-callout` is only supported by iOS
// WebKit, which keeps this off desktop browsers, where the toggle could change the layout width.
const supportsMomentumScrolling = () =>
typeof CSS !== 'undefined' && !!CSS.supports?.('-webkit-touch-callout', 'none');

const stopMomentumScrolling = (element: HTMLElement) => {
if (!supportsMomentumScrolling()) return undefined;

const previousOverflowY = element.style.overflowY;
element.style.overflowY = 'hidden';

return () => {
element.style.overflowY = previousOverflowY;
};
};

export type UseScrollLocationLogicParams = {
/** Disables automatic scroll-to-bottom updates after message changes. */
disableAutoScrollToBottom?: boolean;
Expand Down Expand Up @@ -145,6 +180,10 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>

isRestoringOlderAnchorRef.current = true;

// Held until the restore settles, because the fling would otherwise reclaim the position on
// every frame we do not write it.
let resumeScrolling: (() => void) | undefined;

const applyAnchor = () => {
if (cancelled) return true;

Expand All @@ -153,12 +192,16 @@ 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) {
resumeScrolling ??= stopMomentumScrolling(listElement);
listElement.scrollTop = targetScrollTop;
return false;
}

Expand All @@ -176,6 +219,8 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) =>
clearTimeout(settleTimeoutId);
}
resizeObserver?.disconnect();
resumeScrolling?.();
resumeScrolling = undefined;
};

// Keep correcting against the same anchor until the DOM stops shifting.
Expand Down
Loading