diff --git a/.changeset/hidden-room-history-drain.md b/.changeset/hidden-room-history-drain.md
new file mode 100644
index 0000000000000..b5a9f326fdfca
--- /dev/null
+++ b/.changeset/hidden-room-history-drain.md
@@ -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
diff --git a/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx b/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx
index 1a388da54b55b..e0584adfc5616 100644
--- a/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx
+++ b/apps/meteor/client/views/room/body/hooks/useGetMore.spec.tsx
@@ -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 (
+
+ );
+ };
+ (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(, {
+ 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);
diff --git a/apps/meteor/client/views/room/body/hooks/useGetMore.ts b/apps/meteor/client/views/room/body/hooks/useGetMore.ts
index a32eb95d8972f..c1bb58c8f8073 100644
--- a/apps/meteor/client/views/room/body/hooks/useGetMore.ts
+++ b/apps/meteor/client/views/room/body/hooks/useGetMore.ts
@@ -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);
diff --git a/apps/meteor/tests/e2e/threads.spec.ts b/apps/meteor/tests/e2e/threads.spec.ts
index b010c79899a52..b91ac19aacf16 100644
--- a/apps/meteor/tests/e2e/threads.spec.ts
+++ b/apps/meteor/tests/e2e/threads.spec.ts
@@ -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 });
@@ -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();
+ // 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);
+ }
+ });
+});
diff --git a/apps/meteor/tests/e2e/utils/create-target-channel.ts b/apps/meteor/tests/e2e/utils/create-target-channel.ts
index 25a590c768805..ca7009fb1b14a 100644
--- a/apps/meteor/tests/e2e/utils/create-target-channel.ts
+++ b/apps/meteor/tests/e2e/utils/create-target-channel.ts
@@ -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 {
+ await api.post('/subscriptions.read', { rid: roomId });
+}
+
export async function createTargetPrivateChannel(api: BaseTest['api'], options?: Omit): Promise {
const name = faker.string.uuid();
await api.post('/groups.create', { name, ...options });