From b35b29430eb4dc577c24fafaa7263651bab2db33 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 7 Oct 2026 12:57:14 +0200 Subject: [PATCH 1/4] fix(MessageList): make older-page anchor restore idempotent on iOS WebKit restoreAnchor corrected the scroll position with a relative scrollBy based on getBoundingClientRect. On iOS WebKit the geometry read in the frame after a scroll still reflects the pre-scroll position, so every correction overshot and the loop flipped between two scroll positions until the 1200 ms timeout, leaving the list at an arbitrary position. Assign an absolute scrollTop derived from the anchor's layout offset (offsetTop, independent of the current scroll) so re-applying the correction cannot compound. Closes #3311 Closes REACT-1213 Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/MessageList.test.tsx | 19 +++++++++++- .../MessageList/useScrollLocationLogic.tsx | 29 +++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 3f972dffdc..392d3e4044 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, diff --git a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx index 8e840eec02..ce52e1235d 100644 --- a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx +++ b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx @@ -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; @@ -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; } From 08732afff408c10d16fb8f5a42391fb64568915c Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 8 Oct 2026 12:14:14 +0200 Subject: [PATCH 2/4] test(MessageList): cover anchor restore with unstable geometry and stick-to-top Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/MessageList.test.tsx | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 392d3e4044..08a80f6cda 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -1586,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)[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 ( + <> + + + + + + + + + ); + }; + + 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 }, + }); + 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); + }); + }); }); }); From 202ed89e8e7e1f92b59b6b516c6982c1440067bf Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 8 Oct 2026 18:10:21 +0200 Subject: [PATCH 3/4] fix(MessageList): end iOS momentum scrolling while restoring the older-page anchor On iOS WebKit a touch fling keeps animating after the finger lifts, and that animation overrides programmatic scroll positions on every frame. When an older page arrived mid-fling, the restored position was pulled back towards the top (measured on a real channel: the anchor ended ~10,900 px away with the list at scrollTop 0), even though the restore itself ran and its assignment succeeded. Hide the list's overflow while the restore writes its position, which ends the fling, and hand scrolling back once the position has settled. This is limited to iOS WebKit (detected via -webkit-touch-callout), where the scrollbar is an overlay, so desktop browsers are untouched. Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/MessageList.test.tsx | 39 +++++++++++++++++++ .../MessageList/useScrollLocationLogic.tsx | 26 +++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 08a80f6cda..cc3ac3a448 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -1715,6 +1715,45 @@ describe('MessageList', () => { 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('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, diff --git a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx index ce52e1235d..fa46733a91 100644 --- a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx +++ b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx @@ -20,6 +20,25 @@ const getOffsetFromDocument = (element: HTMLElement) => { 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; @@ -161,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; @@ -177,6 +200,7 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => getOffsetWithin(anchorElement, listElement) - anchor.offsetTop; if (Math.abs(listElement.scrollTop - targetScrollTop) > 1) { + resumeScrolling ??= stopMomentumScrolling(listElement); listElement.scrollTop = targetScrollTop; return false; } @@ -195,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. From df32ed885dc4f25e04091055a2422621fdf35955 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 8 Oct 2026 18:32:08 +0200 Subject: [PATCH 4/4] test(MessageList): cover releasing the iOS momentum lock on unmount and keeping app inline overflow Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/MessageList.test.tsx | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index cc3ac3a448..9ff3e26339 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -1605,8 +1605,10 @@ describe('MessageList', () => { }); const renderPrependHarness = async ({ + inlineOverflowY, startScrollTop, }: { + inlineOverflowY?: string; startScrollTop: number; }) => { const currentMessages = ['current-1', 'current-2'].map((id) => @@ -1681,7 +1683,7 @@ describe('MessageList', () => { ); }; - render(); + const { unmount } = render(); await waitFor(() => expect(screen.getByText('current-1')).toBeInTheDocument()); const listElement = document.querySelector( @@ -1692,6 +1694,7 @@ describe('MessageList', () => { 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', { @@ -1701,7 +1704,7 @@ describe('MessageList', () => { }); fireEvent.click(screen.getByText('finish load older')); await waitFor(() => expect(screen.getByText('older-1')).toBeInTheDocument()); - return { listElement, scrollByMock, scrollToMock }; + return { listElement, scrollByMock, scrollToMock, unmount }; }; it('restores the anchor from layout offsets and stays put when geometry reads are unstable', async () => { @@ -1743,6 +1746,30 @@ describe('MessageList', () => { 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 });