fix(desktop): keep community actions stable across hover transitions

Cancel pending hover changes when community actions explicitly open or close. Cover stale open and close timers on the rendered component and use controlled time for pointer crossing coverage.

Keep composer markdown assertions focused on the latest signed message so later read-state events cannot replace the observed content.

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: seiler <seiler@squareup.com>
This commit is contained in:
seiler
2026-10-23 18:26:52 +00:00
parent 1a9d70f698
commit b577ac2650
4 changed files with 96 additions and 92 deletions

View File

@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { mock, test } from "node:test";
import React, { act } from "react";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CommunitySwitcher } from "./CommunitySwitcher.tsx";
const community = {
id: "alpha",
name: "Alpha",
relayUrl: "wss://alpha.example",
addedAt: "2026-01-01T00:00:00Z",
};
function renderSwitcher(t) {
const client = new QueryClient();
client.setQueryData(["communityIcon", community.relayUrl], null);
const view = render(
React.createElement(
QueryClientProvider,
{ client },
React.createElement(CommunitySwitcher, {
activeCommunity: community,
communities: [community],
variant: "profile-menu",
onSwitchCommunity() {},
onAddCommunity() {},
onUpdateCommunity() {},
async onLeaveCommunity() {},
async onRemoveCommunityFromDevice() {},
}),
),
);
t.after(() => {
cleanup();
client.clear();
mock.timers.reset();
});
mock.timers.enable({ apis: ["setTimeout"] });
return view;
}
test("explicitly opening community actions cancels a pending hover close", (t) => {
const view = renderSwitcher(t);
const trigger = view.getByTestId("community-switcher");
fireEvent.mouseEnter(trigger);
fireEvent.mouseLeave(trigger);
fireEvent.click(trigger);
act(() => mock.timers.tick(160));
assert.equal(trigger.getAttribute("aria-expanded"), "true");
assert.ok(view.getByRole("menu", { name: "Community actions" }));
});
test("selecting a community action cancels a pending hover open", (t) => {
const view = renderSwitcher(t);
const trigger = view.getByTestId("community-switcher");
fireEvent.click(trigger);
fireEvent.mouseEnter(view.getByRole("menu", { name: "Community actions" }));
fireEvent.click(view.getByRole("menuitem", { name: "Add a community" }));
act(() => mock.timers.tick(80));
assert.equal(trigger.getAttribute("aria-expanded"), "false");
assert.equal(view.queryByRole("menu", { name: "Community actions" }), null);
});

View File

@@ -156,13 +156,8 @@ export function CommunitySwitcher({
}
function handleProfileMenuOpenChange(nextOpen: boolean) {
if (variant !== "profile-menu") {
setDropdownOpen(nextOpen);
return;
}
if (!nextOpen) {
clearProfileMenuHoverTimer();
}
// Explicit actions take precedence over an earlier pointer transition.
clearProfileMenuHoverTimer();
setDropdownOpen(nextOpen);
}
@@ -318,7 +313,7 @@ export function CommunitySwitcher({
<button
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
onClick={() => {
setDropdownOpen(false);
handleProfileMenuOpenChange(false);
void writeTextToClipboard(activeCommunity.relayUrl);
}}
role="menuitem"
@@ -331,7 +326,7 @@ export function CommunitySwitcher({
<button
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
onClick={() => {
setDropdownOpen(false);
handleProfileMenuOpenChange(false);
onInvite();
}}
role="menuitem"
@@ -344,7 +339,7 @@ export function CommunitySwitcher({
<button
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
onClick={() => {
setDropdownOpen(false);
handleProfileMenuOpenChange(false);
setEditingCommunity(activeCommunity);
}}
role="menuitem"
@@ -367,7 +362,7 @@ export function CommunitySwitcher({
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-destructive outline-hidden transition-colors hover:bg-destructive/10 focus:bg-destructive/10 focus:outline-none focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
disabled={isLeaving}
onClick={() => {
setDropdownOpen(false);
handleProfileMenuOpenChange(false);
setRemovingCommunity(activeCommunity);
}}
role="menuitem"
@@ -390,7 +385,7 @@ export function CommunitySwitcher({
<button
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
onClick={() => {
setDropdownOpen(false);
handleProfileMenuOpenChange(false);
onAddCommunity();
}}
role="menuitem"

View File

@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features";
@@ -298,24 +299,11 @@ test.describe("community rail", () => {
expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0);
const menu = page.getByRole("menu", { name: "Community actions" });
await waitForAnimations(page);
await page.clock.install({ time: new Date("2026-10-23T12:00:00Z") });
await page.clock.pauseAt(new Date("2026-10-23T12:00:01Z"));
await communityTrigger.evaluate((trigger) => {
trigger.addEventListener(
"mouseenter",
() => {
trigger.dataset.hoverStartedAt = String(performance.now());
},
{ once: true },
);
trigger.addEventListener("mouseleave", () => {
trigger.dataset.leftAt = String(performance.now());
});
const observer = new MutationObserver((records) => {
if (
trigger.getAttribute("aria-expanded") === "true" &&
!trigger.dataset.expandedAt
) {
trigger.dataset.expandedAt = String(performance.now());
}
if (
records.some(
(record) =>
@@ -333,17 +321,11 @@ test.describe("community rail", () => {
});
});
await communityTrigger.hover();
await expect(menu).toBeVisible({ timeout: 700 });
const openDelayMs = await communityTrigger.evaluate((trigger) => {
const hoverStartedAt = Number(trigger.dataset.hoverStartedAt);
const expandedAt = Number(trigger.dataset.expandedAt);
if (!Number.isFinite(hoverStartedAt) || !Number.isFinite(expandedAt)) {
throw new Error("Community actions open timing was not recorded");
}
return expandedAt - hoverStartedAt;
});
expect(openDelayMs).toBeGreaterThanOrEqual(40);
expect(openDelayMs).toBeLessThan(300);
await page.clock.runFor(40);
await expect(communityTrigger).toHaveAttribute("aria-expanded", "false");
await page.clock.runFor(40);
await expect(menu).toBeVisible();
await page.clock.runFor(150);
const openTriggerBox = await communityTrigger.boundingBox();
const menuBox = await menu.boundingBox();
@@ -359,25 +341,15 @@ test.describe("community rail", () => {
);
await page.mouse.move(triggerExitX, triggerExitY);
await page.mouse.move(menuBox.x + 8, menuBox.y - 8);
await page.waitForTimeout(80);
await page.clock.runFor(80);
await page.mouse.move(menuBox.x + 8, menuBox.y + 8);
const bridgeDurationMs = await communityTrigger.evaluate((trigger) => {
const leftAt = Number(trigger.dataset.leftAt);
if (!Number.isFinite(leftAt)) {
throw new Error(
"Community actions trigger exit timing was not recorded",
);
}
return performance.now() - leftAt;
});
expect(bridgeDurationMs).toBeGreaterThanOrEqual(60);
expect(bridgeDurationMs).toBeLessThan(140);
await page.waitForTimeout(180);
await page.clock.runFor(180);
await expect(menu).toBeVisible();
await expect(communityTrigger).not.toHaveAttribute(
"data-closed-after-opening",
"true",
);
await page.clock.resume();
await expect(
menu.getByRole("menuitem", { name: "Copy community URL" }),
).toBeVisible();

View File

@@ -8,6 +8,15 @@ async function openGeneral(page: Page) {
await expect(page.getByTestId("chat-title")).toHaveText("general");
}
async function lastSignedMessageContent(page: Page) {
// Read-state and presence events can be signed after the message.
return page.evaluate(
() =>
window.__BUZZ_E2E_SIGNED_EVENTS__?.findLast((event) => event.kind === 9)
?.content,
);
}
async function selectText(input: Locator, selectedText: string) {
await input.evaluate((element, text) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
@@ -478,16 +487,7 @@ test("selected hard-break lines stay newline-separated in one code block", async
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.poll(() => lastSignedMessageContent(page))
.toBe("```\none\ntwo\nthree\n```");
});
@@ -521,16 +521,7 @@ test("selected list items become one multiline code block and keep neighbors", a
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.poll(() => lastSignedMessageContent(page))
.toBe("- before\n\n```\none\ntwo\n```\n\n- after");
});
@@ -548,16 +539,7 @@ test("caret-only block formatting serializes the prior draft unchanged", async (
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.poll(() => lastSignedMessageContent(page))
.toBe("before\n\n- item");
});
@@ -581,16 +563,7 @@ test("block formatting preserves the lines around a selected composer line", asy
await page.getByTestId("send-message").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{ content: string }>;
}
).__BUZZ_E2E_SIGNED_EVENTS__?.at(-1)?.content,
),
)
.poll(() => lastSignedMessageContent(page))
.toBe("before\n\n- selected\n\nafter");
});