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 });