diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx index 4a241befef..d27b5ec429 100644 --- a/apps/web/src/browser/HostedBrowserWebview.test.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -48,6 +48,7 @@ import { } from "~/hooks/useSettings"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; import * as desktopTabLifetime from "./desktopTabLifetime"; +import { isGuestFocusPointerDown } from "./guestFocusPointer"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; let renderer: ReactTestRenderer | undefined; @@ -99,6 +100,74 @@ afterEach(async () => { }); describe("HostedBrowserWebview settings hydration", () => { + it("dismisses host popups on guest focus and removes the listener on unmount", async () => { + mocks.getClientSettings.mockResolvedValue(DEFAULT_CLIENT_SETTINGS); + await act(async () => ensureClientSettingsHydrated()); + class TestPointerEvent extends Event { + readonly pointerType: string; + + constructor(type: string, init: EventInit & { pointerType: string }) { + super(type, init); + this.pointerType = init.pointerType; + } + } + vi.stubGlobal("PointerEvent", TestPointerEvent); + const guest = Object.assign(new EventTarget(), { getWebContentsId: () => 41 }); + const hostDocument = new EventTarget(); + const onHostOutsidePointer = vi.fn(); + hostDocument.addEventListener("pointerdown", onHostOutsidePointer); + const dispatchGuestEvent = guest.dispatchEvent.bind(guest); + vi.spyOn(guest, "dispatchEvent").mockImplementation((event) => { + const delivered = dispatchGuestEvent(event); + if (event.bubbles) hostDocument.dispatchEvent(event); + return delivered; + }); + const onPointerDown = vi.fn(); + guest.addEventListener("pointerdown", onPointerDown); + + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? guest + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + + guest.dispatchEvent(new Event("focus")); + expect(onPointerDown).toHaveBeenCalledOnce(); + expect(onHostOutsidePointer).toHaveBeenCalledOnce(); + expect(isGuestFocusPointerDown(onHostOutsidePointer.mock.calls[0]?.[0])).toBe(true); + expect( + isGuestFocusPointerDown(new TestPointerEvent("pointerdown", { pointerType: "mouse" })), + ).toBe(false); + expect(onPointerDown.mock.calls[0]?.[0]).toMatchObject({ + type: "pointerdown", + bubbles: true, + pointerType: "mouse", + }); + + await act(() => renderer?.unmount()); + guest.dispatchEvent(new Event("focus")); + expect(onPointerDown).toHaveBeenCalledOnce(); + expect(onHostOutsidePointer).toHaveBeenCalledOnce(); + }); + it("starts a retained background tab only after a settings read succeeds on retry", async () => { const firstRead = deferred(); const retryRead = deferred(); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 521da09397..e487d46b40 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -23,6 +23,7 @@ import { import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; +import { dispatchGuestFocusPointerDown } from "./guestFocusPointer"; import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; import { useBrowserViewportResize } from "./useBrowserViewportResize"; @@ -180,7 +181,13 @@ export function HostedBrowserWebview(props: { dispatchSnapShotComposerFocus(); } }; + // Guest presses do not reach this document, so host popovers miss their + // outside-pointer dismissal when focus moves into the webview. + const dismissHostPopups = () => { + dispatchGuestFocusPointerDown(webview); + }; webview.addEventListener("focus", handleFocus); + webview.addEventListener("focus", dismissHostPopups); webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); webview.addEventListener("render-process-gone", recoverGuest); @@ -189,6 +196,7 @@ export function HostedBrowserWebview(props: { disposed = true; if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("focus", handleFocus); + webview.removeEventListener("focus", dismissHostPopups); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); diff --git a/apps/web/src/browser/guestFocusPointer.ts b/apps/web/src/browser/guestFocusPointer.ts new file mode 100644 index 0000000000..77ddeef05b --- /dev/null +++ b/apps/web/src/browser/guestFocusPointer.ts @@ -0,0 +1,13 @@ +// A guest press only surfaces in the host as webview focus. Mark the synthetic +// outside pointer so the theme inspector leaves it to host popup listeners. +const guestFocusPointers = new WeakSet(); + +export function dispatchGuestFocusPointerDown(webview: HTMLElement): void { + const event = new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }); + guestFocusPointers.add(event); + webview.dispatchEvent(event); +} + +export function isGuestFocusPointerDown(event: Event): boolean { + return guestFocusPointers.has(event); +} diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 8f9dccd249..53503c0ffd 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -35,6 +35,7 @@ import { type ThemeDefinition, } from "../../themePalette"; import { cn } from "../../lib/utils"; +import { isGuestFocusPointerDown } from "../../browser/guestFocusPointer"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; @@ -682,6 +683,7 @@ export function ThemeEditorPanel({ if (event.relatedTarget === null) clearHover(); }; const handlePointerDown = (event: PointerEvent) => { + if (isGuestFocusPointerDown(event)) return; const target = event.target; if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; event.preventDefault();