From e2796d4a8907586b457a65675c2c88c818973173 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 20:42:04 -0600 Subject: [PATCH] fix(desktop): virtualize channel member lists (#4991) ## Summary - virtualize the unfiltered channel member roster instead of eagerly mounting every member card - retain the existing member search/add flow and archived-member behavior - cover a 500-member roster, bounded mounted rows, and scrolling to the final member in E2E ## Cause The members sidebar rendered every active member card at once. On large channels this mounted hundreds or thousands of avatars, profile/presence consumers, menus, and DOM rows, blocking the renderer even though fetching the roster itself is fast. ## Testing - `pnpm typecheck` - `pnpm exec biome check src/features/channels/ui/MembersSidebar.tsx tests/e2e/channels.spec.ts` - `pnpm build:e2e` - `pnpm exec playwright test tests/e2e/channels.spec.ts --grep 'members sidebar (virtualizes large channel rosters|can invite relay-authorized agents|can invite and remove managed agents|collapses same-persona managed agents)'` (4 passed) - pre-push: `desktop-check`, full `desktop-test` (4,371 passed), branch-skew Implemented by Carl on Wes's behalf. Signed-off-by: Wes Co-authored-by: Carl --- .../features/channels/ui/MembersSidebar.tsx | 17 +++++----- desktop/tests/e2e/channels.spec.ts | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 2659a9c1d4..459e8f7776 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -30,6 +30,7 @@ import { import { formatOwnerLabel } from "@/features/profile/lib/identity"; import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { usePresenceQuery } from "@/features/presence/hooks"; +import { VirtualizedList } from "@/shared/ui/VirtualizedList"; import { useIdentityQuery } from "@/shared/api/hooks"; import { changeChannelMemberRole } from "@/shared/api/tauri"; import type { @@ -199,7 +200,6 @@ export function MembersSidebar({ ), [bots, currentPubkey, people], ); - const allMemberPubkeys = React.useMemo( () => rawMembers.map((member) => member.pubkey), [rawMembers], @@ -217,9 +217,7 @@ export function MembersSidebar({ if (!normalizedSearchQuery) { return activeMembers; } - const profiles = memberProfilesQuery.data?.profiles ?? {}; - return activeMembers.filter((member) => { const normalizedPubkey = normalizePubkey(member.pubkey); const profile = profiles[normalizedPubkey] ?? null; @@ -816,11 +814,14 @@ export function MembersSidebar({ ) : null} ) : filteredActiveMembers.length > 0 ? ( -
- {filteredActiveMembers.map((member) => - renderMemberCard(member, isBot(member)), - )} -
+ member.pubkey} + items={filteredActiveMembers} + renderItem={(member) => + renderMemberCard(member, isBot(member)) + } + /> ) : (

{membersQuery.isLoading diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index ac9d5318e5..d58c3303df 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3394,6 +3394,40 @@ test("home inbox manage affordance opens management without leaving home", async await expect(page).not.toHaveURL(/#\/channels\//); }); +test("members sidebar virtualizes large channel rosters", async ({ page }) => { + await page.goto("/"); + const channelId = await page + .getByTestId("channel-random") + .getAttribute("data-channel-id"); + if (!channelId) { + throw new Error("Random channel id missing."); + } + + const pubkeys = Array.from({ length: 500 }, (_, index) => + (index + 1).toString(16).padStart(64, "0"), + ); + await invokeMockCommand(page, "add_channel_members", { + channelId, + pubkeys, + role: "member", + }); + + await openMembersSidebar(page, "random"); + const memberList = page.getByTestId("members-sidebar-people"); + const memberRows = memberList.locator('[data-testid^="sidebar-member-"]'); + await expect(memberRows.first()).toBeVisible(); + expect(await memberRows.count()).toBeLessThan(50); + + const virtualizedList = memberList.locator(".overflow-y-auto"); + await virtualizedList.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll")); + }); + await expect( + memberList.getByTestId(`sidebar-member-${pubkeys.at(-1)}`), + ).toBeVisible(); +}); + test("members sidebar can invite relay-authorized agents", async ({ page }) => { await installMockBridge(page, { relayAgents: [