diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 9d745193c67..b3f1543ae14 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -387,7 +387,11 @@ export function ChannelGroupSection({ items.length > 0 ? ( {items.map((channel) => ( - + // modal={false}: menu items (e.g. Leave channel) open a modal + // AlertDialog. A modal ContextMenu would leave `pointer-events: none` + // stuck on when it closes as the dialog mounts, freezing the + // whole app. Non-modal avoids installing that body guard entirely. + {draggable ? ( @@ -572,7 +576,10 @@ export function CustomChannelSection({ isDragging && "opacity-30", )} > - + {/* modal={false}: Rename/Delete section open a modal dialog; + a modal ContextMenu would leave `pointer-events: none` stuck on + after it closes, freezing the app. */} +
@@ -673,7 +680,10 @@ export function CustomChannelSection({ {channels.length > 0 ? ( {channels.map((channel) => ( - + // modal={false}: see note on the other channel ContextMenu + // above — avoids the pointer-events lockup when Leave + // channel's AlertDialog opens. + diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 60c70ae1664..68ceef9ab4b 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -22,6 +22,20 @@ async function storedSidebarWidth(page: Page) { ); } +// Regression guard for the "Leave channel" lockup: opening a modal AlertDialog +// from a modal Radix ContextMenu leaves `pointer-events: none` stuck on +// after the dialog closes, freezing the whole app. The fix makes the sidebar +// context menus non-modal. This asserts the app is still interactive. +async function expectAppClickable(page: Page) { + await expect + .poll(() => + page.evaluate(() => getComputedStyle(document.body).pointerEvents), + ) + .not.toBe("none"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +} + async function dragSidebarRail(page: Page, deltaX: number) { const sidebarRail = page.locator('[data-sidebar="rail"]'); await expect(sidebarRail).toBeVisible(); @@ -41,6 +55,29 @@ async function dragSidebarRail(page: Page, deltaX: number) { await page.mouse.up(); } +test("leaving a channel from the context menu never freezes the app", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + + // Cancel path: dialog opens from the context menu, then is dismissed. + await page.getByTestId("channel-random").click({ button: "right" }); + await page.getByRole("menuitem", { name: "Leave channel" }).click(); + await expect(page.getByRole("alertdialog")).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expectAppClickable(page); + + // Confirm path: same overlay lifecycle, plus the leave mutation. + await page.getByTestId("channel-random").click({ button: "right" }); + await page.getByRole("menuitem", { name: "Leave channel" }).click(); + await expect(page.getByRole("alertdialog")).toBeVisible(); + await page.getByRole("button", { name: "Leave" }).click(); + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expectAppClickable(page); +}); + test("fades the pinned sidebar chrome edges", async ({ page }) => { await page.goto("/");