diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 3f972dffdc..9ff3e26339 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -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() { @@ -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, @@ -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)[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 ( + <> + + + + + + + + + ); + }; + + const { unmount } = render(); + 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).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); + }); + }); }); }); diff --git a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx index 8e840eec02..fa46733a91 100644 --- a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx +++ b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx @@ -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; @@ -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; @@ -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; } @@ -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.