fix(ui): retire picker focus listeners together (#142258)

This commit is contained in:
Peter Steinberger 2026-09-08 08:57:31 -07:00 • committed by GitHub
parent 7e2241a15d
commit e5a9b28604
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 112 additions and 1 deletions

View file

@ -1,15 +1,120 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { CDPSession } from "playwright";
import { expect, it } from "vitest";
import {
captureUiProof,
captureUiProofEnabled,
chatSessionListResponse,
controlUiSessionUrl,
createChatFlowE2eSuite,
expectDefined,
installMockGateway,
} from "./chat-flow.test-support.ts";
import { createControlUiE2eContextOptions } from "./control-ui-e2e-suite.test-support.ts";
const suite = createChatFlowE2eSuite();
async function focusListenerCounts(protocol: CDPSession, selector: string) {
const objectGroup = "picker-focus-listeners";
try {
const { result } = await protocol.send("Runtime.evaluate", {
expression: `document.querySelector(${JSON.stringify(selector)})`,
objectGroup,
});
const { listeners } = await protocol.send("DOMDebugger.getEventListeners", {
objectId: expectDefined(result.objectId, "mounted picker trigger"),
});
return {
blur: listeners.filter((listener) => listener.type === "blur").length,
keydown: listeners.filter((listener) => listener.type === "keydown").length,
};
} finally {
await protocol.send("Runtime.releaseObjectGroup", { objectGroup });
}
}
suite.define(() => {
it.each(["blur", "keydown"] as const)(
"keeps retained trigger listeners bounded when %s completes pointer focus",
async (completion) => {
await suite.withPage(
{
locale: "en-US",
reducedMotion: "reduce",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
},
async ({ context, page }) => {
const gateway = await installMockGateway(page, {});
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
const selector =
'openclaw-chat-pane[aria-hidden="false"] [data-chat-permission-select="true"]';
const trigger = page.locator(selector);
const picker = page.locator(
'openclaw-chat-pane[aria-hidden="false"] .chat-controls__permission-picker',
);
const composer = page.locator(
'openclaw-chat-pane[aria-hidden="false"] .agent-chat__composer-combobox textarea',
);
const retainedTrigger = expectDefined(await trigger.elementHandle(), "picker trigger");
const protocol = await context.newCDPSession(page);
const marker = "data-chat-pointer-restored-focus";
try {
const cycle = async () => {
await trigger.click();
await expect.poll(() => trigger.getAttribute(marker)).toBe("");
expect(
await retainedTrigger.evaluate(
(element, triggerSelector) => element === document.querySelector(triggerSelector),
selector,
),
).toBe(true);
expect(await trigger.evaluate((element) => document.activeElement === element)).toBe(
true,
);
if (completion === "blur") {
await composer.click();
} else {
await trigger.press("Shift");
}
await expect.poll(() => trigger.getAttribute(marker)).toBeNull();
if (completion === "keydown") {
// Close on the same focused trigger; blur would consume the companion listener.
await trigger.click();
}
await expect.poll(() => picker.getAttribute("open")).toBeNull();
};
// Warm Web Awesome's own bindings, then compare the same completed phase.
await cycle();
const before = await focusListenerCounts(protocol, selector);
for (let index = 0; index < 20; index += 1) {
await cycle();
}
const after = await focusListenerCounts(protocol, selector);
if (captureUiProofEnabled) {
await writeFile(
path.join(suite.artifactDir, `${completion}-listeners.json`),
`${JSON.stringify({ browser: suite.browser.version(), completion, cycles: 20, before, after, retainedTriggerConnected: await retainedTrigger.evaluate((element) => element.isConnected) }, null, 2)}\n`,
);
}
await captureUiProof(suite, page, "picker-focus", `${completion}-completed.png`);
await trigger.click();
await expect.poll(() => trigger.getAttribute(marker)).toBe("");
await captureUiProof(suite, page, "picker-focus", `${completion}-open.png`);
await trigger.press("Shift");
await expect.poll(() => trigger.getAttribute(marker)).toBeNull();
expect(after).toEqual(before);
} finally {
await protocol.detach();
await retainedTrigger.dispose();
}
},
);
},
);
it("shows the selected permission and disables dropdown motion when requested", async () => {
const context = await suite.newBrowserContext({
locale: "en-US",

View file

@ -46,6 +46,13 @@ function pickerTrigger(picker: HTMLElement): HTMLElement | null {
: picker.querySelector<HTMLElement>("[slot=trigger]");
}
function clearPointerFocus(this: HTMLElement): void {
// Blur and keyboard takeover complete the same pointer-focus lifetime.
this.removeEventListener("blur", clearPointerFocus);
this.removeEventListener("keydown", clearPointerFocus);
this.removeAttribute(POINTER_RESTORED_FOCUS_ATTRIBUTE);
}
function dismissChatComposerPickersOutside(event: PointerEvent): void {
const path = event.composedPath();
for (const picker of openChatComposerPickers()) {
@ -186,7 +193,6 @@ export function restorePointerOpenedChatComposerTrigger(event: Event): void {
return;
}
trigger.setAttribute(POINTER_RESTORED_FOCUS_ATTRIBUTE, "");
const clearPointerFocus = () => trigger.removeAttribute(POINTER_RESTORED_FOCUS_ATTRIBUTE);
trigger.addEventListener("blur", clearPointerFocus, { once: true });
trigger.addEventListener("keydown", clearPointerFocus, { once: true });
trigger.focus({ preventScroll: true });