Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/hidden-room-history-drain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes the message list silently loading the entire room history — and downloading its attachments — in the background when a full-width contextual bar (such as the thread view on small screens) hides it
38 changes: 38 additions & 0 deletions apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,44 @@ describe('useGetMore', () => {
});
});

it('should not call getMore when the container has no size (hidden behind the contextual bar)', async () => {
const root = mockAppRoot();

const Test = () => {
const [atBottom] = useState(false);
const { innerRef } = useGetMore('room-id', atBottom);
return (
<div style={{ display: 'none' }}>
<div ref={innerRef as any} style={{ height: '100px', overflowY: 'scroll' }} data-testid='scrollable-element'>
<div style={{ height: '800px' }}></div>
</div>
</div>
);
};
(RoomHistoryManager.isLoading as jest.Mock).mockReturnValue(false);
(RoomHistoryManager.hasMore as jest.Mock).mockReturnValue(true);
(RoomHistoryManager.hasMoreNext as jest.Mock).mockReturnValue(false);
(RoomHistoryManager.getMore as jest.Mock).mockClear();

(getBoundingClientRect as jest.Mock).mockReturnValue({
scrollTop: 0,
clientHeight: 0,
scrollHeight: 0,
});

render(<Test />, {
wrapper: root.build(),
});

const scrollableElement = screen.getByTestId('scrollable-element');
scrollableElement.dispatchEvent(new Event('wheel'));
scrollableElement.dispatchEvent(new Event('scroll'));

await new Promise((resolve) => setTimeout(resolve, 150));

expect(RoomHistoryManager.getMore).not.toHaveBeenCalled();
});

it('should call getMoreNext when scrolling near bottom and hasMoreNext is true', () => {
const root = mockAppRoot();
(RoomHistoryManager.isLoading as jest.Mock).mockReturnValue(false);
Expand Down
4 changes: 4 additions & 0 deletions apps/meteor/client/views/room/body/hooks/useGetMore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export const useGetMore = (rid: string, isJumpingToMessage: boolean) => {

const { scrollTop, clientHeight, scrollHeight } = getBoundingClientRect(element);

if (clientHeight === 0) {
return;
}

const lastScrollTopRef = scrollTop;
const height = clientHeight;
const hasMore = RoomHistoryManager.hasMore(rid);
Expand Down
54 changes: 53 additions & 1 deletion apps/meteor/tests/e2e/threads.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Users } from './fixtures/userStates';
import { HomeChannel } from './page-objects';
import { createTargetChannel, deleteChannel } from './utils';
import { createTargetChannel, createTargetChannelAndReturnFullRoom, deleteChannel, markRoomAsRead, sendMessage } from './utils';
import { sendFillerMessages } from './utils/sendMessage';
import { expect, test } from './utils/test';

test.use({ storageState: Users.admin.state });
Expand Down Expand Up @@ -209,3 +210,54 @@ test.describe.serial('Threads', () => {
});
});
});

test.describe.serial('Threads - small screens', () => {
let poHomeChannel: HomeChannel;
let targetChannel: { name: string; _id: string };

test.beforeAll(async ({ api }) => {
const { channel } = await createTargetChannelAndReturnFullRoom(api);
targetChannel = { name: channel.name as string, _id: channel._id };

await sendFillerMessages(api, targetChannel._id, 120);
const parentId = await sendMessage(api, targetChannel._id, 'thread parent');
await sendMessage(api, targetChannel._id, 'thread reply', parentId);
// Without this the room opens at the first unread message and loads the whole history at
// once, leaving no older page for the hidden list to drain.
await markRoomAsRead(api, targetChannel._id);
});

test.afterAll(async ({ api }) => deleteChannel(api, targetChannel.name));

test('should not load older messages while the message list is hidden behind the full-width thread view', async ({ page }) => {
poHomeChannel = new HomeChannel(page);
await poHomeChannel.gotoChannel(targetChannel.name);
// Below the "sm" breakpoint (600px) the contextual bar takes the full room width and the
// message list is hidden behind it while staying mounted.
await page.setViewportSize({ width: 599, height: 700 });

await poHomeChannel.content.mainMessageListScroller.hover();
await page.mouse.wheel(0, -100);

// Role-based locators cannot see the list once it is display:none — count DOM nodes instead
// (thread messages carry a different aria-roledescription, so they never match).
const mainMessageListItems = page.locator('[role="listitem"][aria-roledescription="message"]');
const loadedMessages = await mainMessageListItems.count();
Comment thread
KevLehman marked this conversation as resolved.
// 121 user messages were seeded — an unloaded older page must remain or there is nothing to drain
expect(loadedMessages).toBeLessThan(121);

await poHomeChannel.content.lastUserMessage.getByRole('button', { name: 'View thread' }).click();
await expect(page).toHaveURL(/.*thread/);
await expect(poHomeChannel.content.lastUserThreadMessage).toContainText('thread reply');
await expect(poHomeChannel.content.mainMessageListScroller).toBeHidden();

// A hidden-history drain re-triggers ~every 100ms, so with 71 older messages unloaded it pushes
// the count past 100 within the first sample. Hide-transition jitter can at most re-render the
// single already-loaded 50-message page, which stays under the +50 margin — the two outcomes
// cannot overlap.
for (let i = 0; i < 5; i++) {
await page.waitForTimeout(500);
expect(await mainMessageListItems.count()).toBeLessThan(loadedMessages + 50);
Comment thread
KevLehman marked this conversation as resolved.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
});
});
4 changes: 4 additions & 0 deletions apps/meteor/tests/e2e/utils/create-target-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export async function deleteRoom(api: BaseTest['api'], roomId: string): Promise<
await api.post('/rooms.delete', { roomId });
}

export async function markRoomAsRead(api: BaseTest['api'], roomId: string): Promise<void> {
await api.post('/subscriptions.read', { rid: roomId });
}

export async function createTargetPrivateChannel(api: BaseTest['api'], options?: Omit<GroupsCreateProps, 'name'>): Promise<string> {
const name = faker.string.uuid();
await api.post('/groups.create', { name, ...options });
Expand Down
Loading