Skip to content
Merged
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
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 {
NotificationList as DefaultNotificationList,
Expand Down Expand Up @@ -193,7 +194,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 unreadStateSnapshotSelector = (state: UnreadSnapshotState) => state;
Expand Down Expand Up @@ -410,8 +414,50 @@ const VirtualizedMessageListWithContext = (
setNewMessagesNotification,
} = useNewMessageNotification(processedMessages, client.userID);

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({
hasMoreNewer,
// 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: isThreadList,
});
Expand All @@ -427,7 +473,12 @@ const VirtualizedMessageListWithContext = (
if (messagePaginator.hasMoreHead) {
await messagePaginator.jumpToTheLatestMessage();
} else 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 @@ -556,9 +607,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 @@ -615,6 +666,8 @@ const VirtualizedMessageListWithContext = (
style={{ overflowX: 'hidden' }}
totalCount={processedMessages.length}
{...overridingVirtuosoProps}
scrollerRef={scrollerRef}
totalListHeightChanged={totalListHeightChanged}
{...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})}
{...(defaultItemHeight ? { defaultItemHeight } : {})}
/>
Expand Down
215 changes: 203 additions & 12 deletions src/components/MessageList/__tests__/VirtualizedMessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,46 @@ const threadWithParentMessage = () =>
),
});

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 @@ -195,6 +222,170 @@ describe('VirtualizedMessageList', () => {
expect(resolvedAt(container, 'outside')).toBe('CustomMessageUI');
});
});

// 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', () => {
const MESSAGE_COUNT = 20;

let scrollToBottom: (() => Promise<void>) | undefined;

// VML passes scrollToBottom to TypingIndicator, which it renders inside its own context
// provider -- the simplest overridable seam that receives the callback.
const TypingIndicatorProbe = (props: { scrollToBottom: () => Promise<void> }) => {
scrollToBottom = props.scrollToBottom;
return null;
};

const renderList = async ({ hasMoreHead = false, listProps = {} } = {}) => {
const user1 = generateUser();
const user2 = generateUser();
// The file-wide nanoid mock collapses generated ids, which would dedupe the messages down
// to one and let an off-by-one pass.
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.channelManager.ensure({
id: mockedChannel.channel.id,
type: 'messaging',
});
await channel.watch();
// With older pages still to load, scrollToBottom jumps to the latest message set instead of
// scrolling, so pin it to the branch that actually targets a Virtuoso index.
vi.spyOn(channel.messagePaginator, 'hasMoreHead', 'get').mockReturnValue(
hasMoreHead,
);

await act(() => {
render(
<Chat client={client}>
<Channel channel={channel}>
<WithComponents overrides={{ TypingIndicator: TypingIndicatorProbe }}>
<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.totalCount).toBe(MESSAGE_COUNT);
expect(props.initialTopMostItemIndex).toEqual({
align: 'end',
index: MESSAGE_COUNT - 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 } },
});

// Ours is what Virtuoso receives, not the caller's.
expect(props.scrollerRef).not.toBe(scrollerRefFromProps);

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

// The caller still gets the element...
expect(scrollerRefFromProps).toHaveBeenCalledWith(scroller);
// ...and the hook measured it, so the threshold is no longer the unmeasured default.
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