Skip to content
61 changes: 57 additions & 4 deletions src/components/MessageList/VirtualizedMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
useUnreadMessagesNotificationVirtualized,
} from './hooks/VirtualizedMessageList';
import { useMarkRead } from './hooks/useMarkRead';
import { useScrollerViewport } from './hooks/VirtualizedMessageList/useScrollerViewport';
import { NewMessageNotification as DefaultNewMessageNotification } from './NewMessageNotification';
import { MessageListMainPanel as DefaultMessageListMainPanel } from './MessageListMainPanel';
import type { GroupStyle, ProcessMessagesParams, RenderedMessage } from './utils';
Expand Down Expand Up @@ -188,7 +189,10 @@ function calculateInitialTopMostItemIndex(
return { align: 'center', index } as const;
}
}
return messages.length - 1;
// A bare index aligns the row's top to the viewport top, which leaves the last message
// hanging below the fold whenever it is taller than the list. Anchoring its end keeps the
// list opening at true bottom regardless of how short the viewport is.
return { align: 'end', index: messages.length - 1 } as const;
}

const VirtualizedMessageListWithContext = (
Expand Down Expand Up @@ -381,7 +385,49 @@ const VirtualizedMessageListWithContext = (
setNewMessagesNotification,
} = useNewMessageNotification(processedMessages, client.userID, hasMoreNewer);

const { handleScrollerRef, isAtTrueBottom, remeasure, threshold } =
useScrollerViewport();

// Composed with whatever the integrator passes, and applied after the `additionalVirtuosoProps`
// spread: these two callbacks are what keep the thresholds and the true-bottom check honest, so
// an override must not be able to silently switch the measurement off.
const {
customScrollParent,
scrollerRef: scrollerRefFromProps,
totalListHeightChanged: totalListHeightChangedFromProps,
useWindowScroll,
} = overridingVirtuosoProps;

// Virtuoso's window-scroll path never calls the supplied `scrollerRef` -- it passes its own
// no-op in that slot -- so for these two modes the hook has to be told what to measure, or the
// thresholds stay at their full-height defaults and the true-bottom check always answers "yes".
useEffect(() => {
if (!useWindowScroll && !customScrollParent) return;
handleScrollerRef(customScrollParent ?? window);
}, [customScrollParent, handleScrollerRef, useWindowScroll]);

const scrollerRef = useCallback(
(element: HTMLElement | Window | null) => {
handleScrollerRef(element);
scrollerRefFromProps?.(element);
},
[handleScrollerRef, scrollerRefFromProps],
);

// Content can grow without the scroller resizing or scrolling -- a bubble whose avatar or image
// lands late -- which moves the true bottom without firing either event the hook observes.
const totalListHeightChanged = useCallback(
(height: number) => {
remeasure();
totalListHeightChangedFromProps?.(height);
},
[remeasure, totalListHeightChangedFromProps],
);

useMarkRead({
// Virtuoso's at-bottom flag carries a threshold, so on a short list it reads true while part
// of the last message is still clipped. Marking a channel read needs the stricter signal.
isAtTrueBottom,
isMessageListScrolledToBottom,
messageListIsThread: !!threadList,
wasMarkedUnread: !!channelUnreadUiState?.first_unread_message_id,
Expand All @@ -394,7 +440,12 @@ const VirtualizedMessageListWithContext = (
}

if (virtuoso.current) {
virtuoso.current.scrollToIndex(processedMessages.length - 1);
// `align: 'end'` for the same reason as `calculateInitialTopMostItemIndex`: without it a
// last message taller than the viewport lands with its bottom clipped.
virtuoso.current.scrollToIndex({
align: 'end',
index: processedMessages.length - 1,
});
}

setNewMessagesNotification(false);
Expand Down Expand Up @@ -523,9 +574,9 @@ const VirtualizedMessageListWithContext = (
/>
<Virtuoso<UnknownType, VirtuosoContext>
atBottomStateChange={atBottomStateChange}
atBottomThreshold={100}
atBottomThreshold={threshold}
atTopStateChange={atTopStateChange}
atTopThreshold={100}
atTopThreshold={threshold}
className='str-chat__message-list-scroll'
components={{
EmptyPlaceholder,
Expand Down Expand Up @@ -594,6 +645,8 @@ const VirtualizedMessageListWithContext = (
style={{ overflowX: 'hidden' }}
totalCount={processedMessages.length}
{...overridingVirtuosoProps}
scrollerRef={scrollerRef}
totalListHeightChanged={totalListHeightChanged}
{...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})}
{...(defaultItemHeight ? { defaultItemHeight } : {})}
/>
Expand Down
201 changes: 189 additions & 12 deletions src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,48 @@ import { VirtualizedMessageList } from '../VirtualizedMessageList';

import { Chat } from '../../Chat';
import { Channel } from '../../Channel';
import { useVirtualizedMessageListContext, WithComponents } from '../../../context';

vi.mock('react-virtuoso', async () => {
const { Virtuoso } = await import('react-virtuoso');
const { forwardRef } = await import('react');
// Records the props VML hands to Virtuoso and routes the imperative handle through a spy, so the
// scroll target is assertable. The overrides keep the real Virtuoso renderable under jsdom, which
// reports every element as 0px.
const { scrollToIndexSpy, virtuosoProps } = vi.hoisted(() => ({
scrollToIndexSpy: vi.fn(),
virtuosoProps: [] as Record<string, any>[],
}));

vi.mock('react-virtuoso', async (importOriginal) => {
const { Virtuoso } = await importOriginal<typeof import('react-virtuoso')>();
const { forwardRef, useImperativeHandle, useRef } = await import('react');
return {
Virtuoso: forwardRef((props: any, ref: any) => (
<Virtuoso
ref={ref}
{...props}
fixedItemHeight={30}
initialTopMostItemIndex={0}
overscan={0}
/>
)),
Virtuoso: forwardRef((props: any, ref: any) => {
virtuosoProps.push(props);
const innerRef = useRef<any>(null);
useImperativeHandle(
ref,
() => ({
autoscrollToBottom: (...args: any[]) =>
innerRef.current?.autoscrollToBottom(...args),
scrollBy: (...args: any[]) => innerRef.current?.scrollBy(...args),
scrollIntoView: (...args: any[]) => innerRef.current?.scrollIntoView(...args),
scrollTo: (...args: any[]) => innerRef.current?.scrollTo(...args),
scrollToIndex: (...args: any[]) => {
scrollToIndexSpy(...args);
return innerRef.current?.scrollToIndex(...args);
},
}),
[],
);
return (
<Virtuoso
ref={innerRef}
{...props}
fixedItemHeight={30}
initialTopMostItemIndex={0}
overscan={0}
/>
);
}),
};
});

Expand Down Expand Up @@ -100,6 +128,155 @@ describe('VirtualizedMessageList', () => {
expect(virtualList).toBeInTheDocument();
expect(virtualList).toContainElement(emptyStateText);
});

// A bare index means align: 'start', which parks the last row's top at the viewport top and
// clips its bottom whenever that row is taller than the list -- visible in a short panel.
// jsdom has no layout, so these assert the scroll target rather than the clipping.
describe('scroll anchoring', () => {
let scrollToBottom: (() => Promise<void>) | undefined;

// scrollToBottom is published on a context VML opens around its own subtree, so the probe
// has to sit inside it. MessageListMainPanel is the outermost overridable slot in there.
const MainPanelProbe = ({ children }: React.PropsWithChildren) => {
scrollToBottom = useVirtualizedMessageListContext().scrollToBottom;
return <div>{children}</div>;
};

const MESSAGE_COUNT = 20;

const renderList = async ({ listProps = {} } = {}) => {
const user1 = generateUser();
const user2 = generateUser();
const mockedChannel = generateChannel({
members: [generateMember({ user: user1 }), generateMember({ user: user2 })],
messages: Array.from({ length: MESSAGE_COUNT }, (_v, i) =>
generateMessage({ id: `anchor-msg-${i}`, user: i % 2 ? user1 : user2 }),
),
});
const client = await getTestClientWithUser({ id: 'id' });
useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); // eslint-disable-line react-hooks/rules-of-hooks
const channel = client.channel('messaging', mockedChannel.channel.id);
await channel.watch();

await act(() => {
render(
<Chat client={client}>
<Channel channel={channel}>
<WithComponents overrides={{ MessageListMainPanel: MainPanelProbe }}>
<VirtualizedMessageList {...listProps} />
</WithComponents>
</Channel>
</Chat>,
);
});
return virtuosoProps[virtuosoProps.length - 1];
};

beforeEach(() => {
virtuosoProps.length = 0;
scrollToBottom = undefined;
});

it('anchors the initial scroll to the end of the last item', async () => {
const props = await renderList();
expect(props.initialTopMostItemIndex).toEqual({
align: 'end',
index: props.totalCount - 1,
});
});

// The thresholds and the true-bottom check are only honest while these callbacks reach the
// viewport hook, so an integrator override has to compose with them rather than replace them.
it('still measures the viewport when a scrollerRef is supplied', async () => {
const scrollerRefFromProps = vi.fn();
const props = await renderList({
listProps: { additionalVirtuosoProps: { scrollerRef: scrollerRefFromProps } },
});

expect(props.scrollerRef).not.toBe(scrollerRefFromProps);

const scroller = document.createElement('div');
act(() => {
props.scrollerRef(scroller);
});

expect(scrollerRefFromProps).toHaveBeenCalledWith(scroller);
const measured = virtuosoProps[virtuosoProps.length - 1];
expect(measured.atBottomThreshold).not.toBe(100);
expect(measured.atBottomThreshold).toBe(measured.atTopThreshold);

// Virtuoso passes null on teardown; the caller has to see that too.
act(() => {
props.scrollerRef(null);
});
expect(scrollerRefFromProps).toHaveBeenCalledWith(null);
});

it('still remeasures when a totalListHeightChanged is supplied', async () => {
const totalListHeightChangedFromProps = vi.fn();
const props = await renderList({
listProps: {
additionalVirtuosoProps: {
totalListHeightChanged: totalListHeightChangedFromProps,
},
},
});

expect(props.totalListHeightChanged).not.toBe(totalListHeightChangedFromProps);

act(() => {
props.totalListHeightChanged(1234);
});

expect(totalListHeightChangedFromProps).toHaveBeenCalledWith(1234);
});

// Virtuoso's window-scroll path never calls the supplied scrollerRef, so without feeding the
// hook directly the list is never measured and the true-bottom check answers "yes" forever.
describe('window scrolling', () => {
// Virtuoso's own window-viewport hook needs a ResizeObserver, which jsdom does not
// provide. mock-builders' ResizeObserverMock has no `unobserve`, which Virtuoso calls.
const OriginalResizeObserver = globalThis.ResizeObserver;

beforeEach(() => {
globalThis.ResizeObserver = class implements ResizeObserver {
disconnect = vi.fn();
observe = vi.fn();
unobserve = vi.fn();
};
});

afterEach(() => {
globalThis.ResizeObserver = OriginalResizeObserver;
});

it.each([
['useWindowScroll', { useWindowScroll: true }],
['customScrollParent', { customScrollParent: document.createElement('div') }],
])('measures the viewport with %s', async (_label, virtuosoProp) => {
const props = await renderList({
listProps: { additionalVirtuosoProps: virtuosoProp },
});

expect(props.atBottomThreshold).not.toBe(100);
expect(props.atBottomThreshold).toBe(props.atTopThreshold);
});
});

it('anchors scrollToBottom to the end of the last item', async () => {
const props = await renderList();

await act(async () => {
await scrollToBottom?.();
});

expect(props.totalCount).toBe(MESSAGE_COUNT);
expect(scrollToIndexSpy).toHaveBeenCalledWith({
align: 'end',
index: MESSAGE_COUNT - 1,
});
});
});
});

describe('usePrependedMessagesCount', () => {
Expand Down
Loading
Loading