Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default defineConfig({
"**/threadpane-ultrawide.spec.ts",
"**/animated-avatar.spec.ts",
"**/reminders.spec.ts",
"**/reminder-click-repro.spec.ts",
"**/virtualization.spec.ts",
"**/scroll-history.spec.ts",
"**/channel-dense-second-reach.spec.ts",
Expand Down
16 changes: 3 additions & 13 deletions desktop/src/features/sidebar/ui/CustomChannelSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,11 +411,7 @@ export function ChannelGroupSection({
items.length > 0 ? (
<SidebarMenu data-testid={listTestId}>
{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 <body> when it closes as the dialog mounts, freezing the
// whole app. Non-modal avoids installing that body guard entirely.
<ContextMenu key={channel.id} modal={false}>
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>
<SidebarMenuItem className="content-visibility-auto-row">
{draggable ? (
Expand Down Expand Up @@ -610,10 +606,7 @@ export function CustomChannelSection({
)}
data-section-actions-open={actionsMenuOpen || undefined}
>
{/* modal={false}: Rename/Delete section open a modal dialog;
a modal ContextMenu would leave `pointer-events: none` stuck on
<body> after it closes, freezing the app. */}
<ContextMenu modal={false}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="relative" {...dragHandleProps}>
<SidebarGroupLabel asChild>
Expand Down Expand Up @@ -702,10 +695,7 @@ export function CustomChannelSection({
{channels.length > 0 ? (
<SidebarMenu>
{channels.map((channel) => (
// modal={false}: see note on the other channel ContextMenu
// above — avoids the pointer-events lockup when Leave
// channel's AlertDialog opens.
<ContextMenu key={channel.id} modal={false}>
<ContextMenu key={channel.id}>
<ContextMenuTrigger asChild>
<SidebarMenuItem>
<DraggableChannelRow channelId={channel.id}>
Expand Down
144 changes: 144 additions & 0 deletions desktop/tests/e2e/reminder-click-repro.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { expect, test } from "@playwright/test";

import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";

/**
* Regression suite for the "app freezes after setting a reminder" bug.
*
* The app used to bundle two copies of @radix-ui/react-dismissable-layer
* (react-menu resolved 1.1.11 while react-dialog resolved 1.1.15). Each copy
* keeps its own module-level `originalBodyPointerEvents`, so when a *modal*
* menu opened a *modal* dialog in the same React commit, the dialog's copy
* saved the menu's `pointer-events: none` as the "original" body style and
* restored it when the dialog closed — leaving `pointer-events: none` stuck
* on <body> and deadening the whole app to the mouse.
*
* The inbox row context menu hit this: its "Remind me later" item opens
* RemindMeLaterDialog synchronously. Fixed by deduplicating the layer via
* the pnpm override in pnpm-workspace.yaml — a single copy coordinates
* nested modal layers correctly, so the menu keeps its default modal
* behavior.
*
* Each test drives one reminder entry point through a full set-reminder
* cycle, then asserts <body> pointer-events is not stuck and that a real
* click still lands. The inbox right-click test fails if the dismissable
* layer ever duplicates again; the message-menu tests pin the already-safe
* paths.
*/

const GENERAL_MESSAGE_ROW = "message-row";
// The default mock feed's mention row (from alice, in #general) has a
// channel target, so its "Remind me later" context-menu item is enabled.
const INBOX_MENTION_ROW = "home-inbox-item-mock-feed-mention";

/**
* Assert the app survived the menu → dialog → close cycle.
*
* The stuck state is inline `pointer-events: none` left on <body>. The buggy
* restore runs when the dialog's dismissable layer unmounts — after its exit
* animation — so first wait for the dialog to leave the DOM, then check the
* body style, then prove clicks still land by navigating via the sidebar.
*/
async function expectAppAliveAfterDialogClose(
page: import("@playwright/test").Page,
) {
await expect(page.getByRole("dialog")).toHaveCount(0);
await waitForAnimations(page);
expect(await page.evaluate(() => document.body.style.pointerEvents)).not.toBe(
"none",
);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
}

/** Open the Remind-me-later dialog from a message row's More-actions menu. */
async function openReminderDialogFromMessageMenu(
page: import("@playwright/test").Page,
) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");

const messageRow = page.getByTestId(GENERAL_MESSAGE_ROW).first();
await messageRow.hover();
await messageRow.getByRole("button", { name: "More actions" }).click();

const remindItem = page.getByRole("menuitem", { name: "Remind me later" });
await expect(remindItem).toBeVisible();
await waitForAnimations(page);
await remindItem.click();

const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await waitForAnimations(page);
return dialog;
}

async function expectReminderSetToast(page: import("@playwright/test").Page) {
await expect(
page.locator("[data-sonner-toast]").filter({ hasText: "Reminder set" }),
).toBeVisible();
}

test.describe("reminder set → app stays clickable", () => {
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});

test("01 — message menu → time preset", async ({ page }) => {
const dialog = await openReminderDialogFromMessageMenu(page);

await dialog.getByRole("button", { name: "In 30 minutes" }).click();

await expectReminderSetToast(page);
await expectAppAliveAfterDialogClose(page);
});

test("02 — message menu → custom time via Set reminder footer button", async ({
page,
}) => {
const dialog = await openReminderDialogFromMessageMenu(page);

// The custom timestamp must be strictly in the future or the Set
// reminder button stays disabled — pick tomorrow at 09:00 local time.
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
const date = [
tomorrow.getFullYear(),
String(tomorrow.getMonth() + 1).padStart(2, "0"),
String(tomorrow.getDate()).padStart(2, "0"),
].join("-");
await dialog.getByLabel("Reminder date").fill(date);
await dialog.getByLabel("Reminder time").fill("09:00");
await dialog.getByRole("button", { name: "Set reminder" }).click();

await expectReminderSetToast(page);
await expectAppAliveAfterDialogClose(page);
});

// The regression path: only the inbox right-click stacks a modal
// ContextMenu under the modal dialog. Fails if two copies of the
// dismissable layer are ever bundled again — body pointer-events stays
// "none" after the dialog closes and the sidebar click below times out.
test("03 — inbox row right-click → Remind me later", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("home-inbox")).toBeVisible();

const row = page.getByTestId(INBOX_MENTION_ROW);
await expect(row).toBeVisible();
await row.click({ button: "right" });

const remindItem = page.getByRole("menuitem", { name: "Remind me later" });
await expect(remindItem).toBeVisible();
await waitForAnimations(page);
await remindItem.click();

const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await waitForAnimations(page);
await dialog.getByRole("button", { name: "In 30 minutes" }).click();

await expectReminderSetToast(page);
await expectAppAliveAfterDialogClose(page);
});
});
10 changes: 6 additions & 4 deletions desktop/tests/e2e/sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ 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 <body>
// after the dialog closes, freezing the whole app. The fix makes the sidebar
// context menus non-modal. This asserts the app is still interactive.
// Regression guard for the "Leave channel" lockup: with two bundled copies of
// @radix-ui/react-dismissable-layer, opening a modal AlertDialog from a modal
// Radix ContextMenu left `pointer-events: none` stuck on <body> after the
// dialog closed, freezing the whole app. Fixed by the pnpm override in
// pnpm-workspace.yaml deduplicating the layer. This asserts the app is still
// interactive.
async function expectAppClickable(page: Page) {
await expect
.poll(() =>
Expand Down
Loading