From 9576e5677fab1eeb20ad2711bed87e49f15eb497 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sat, 21 Mar 2026 20:00:11 +0100 Subject: [PATCH 1/6] refactor: minimize suggestion menu host markup --- .../EarlyTabAcceptBridgeProtocol.ts | 1 + .../EarlyTabAcceptMainWorldBridge.ts | 38 +-- .../suggestions/SuggestionManagerRuntime.ts | 8 +- .../suggestions/SuggestionMenuPresenter.ts | 14 +- .../suggestions/SuggestionMenuView.ts | 17 +- tests/EarlyTabAcceptMainWorldBridge.test.ts | 16 +- tests/SuggestionMenuPresenter.test.ts | 2 + tests/SuggestionMenuView.test.ts | 27 +++ tests/e2e/full.e2e.test.ts | 217 +++++++++++++++--- tests/e2e/smoke.e2e.test.ts | 86 +++++-- tests/suggestionTestUtils.ts | 26 ++- 11 files changed, 348 insertions(+), 104 deletions(-) create mode 100644 tests/SuggestionMenuView.test.ts diff --git a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts index 6612202f..b257449f 100644 --- a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts +++ b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptBridgeProtocol.ts @@ -3,6 +3,7 @@ export const EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG = "__ftEarlyTabAcceptBridgeInstall export const EARLY_TAB_ACCEPT_ENTRY_ID_ATTR = "data-ft-suggestion-id"; export const EARLY_TAB_ACCEPT_ENABLED_ATTR = "data-ft-autocomplete-on-tab"; export const EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR = "data-ft-early-tab-bridge"; +export const EARLY_TAB_ACCEPT_VISIBLE_ATTR = "data-ft-suggestion-visible"; export const EARLY_TAB_ACCEPT_MESSAGE_TYPE = "ft-early-tab-accept-message"; export interface EarlyTabAcceptMessage { diff --git a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts index 88ffea6a..0fdf7a51 100644 --- a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts @@ -5,6 +5,7 @@ import { EARLY_TAB_ACCEPT_MAIN_WORLD_FLAG, EARLY_TAB_ACCEPT_MESSAGE_TYPE, EARLY_TAB_ACCEPT_REQUEST_EVENT, + EARLY_TAB_ACCEPT_VISIBLE_ATTR, } from "./EarlyTabAcceptBridgeProtocol"; type FluentTyperManagedElement = HTMLElement; @@ -13,46 +14,22 @@ type FluentTyperBridgeWindow = Window & { __ftEarlyTabAcceptBridgeKeydownHandler?: (event: KeyboardEvent) => void; }; -function resolveSuggestionMenu( - element: FluentTyperManagedElement, - doc: Document, -): HTMLElement | null { - const entryId = element.getAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); - if (!entryId) { - return null; - } - - const menu = doc.querySelector( - `[data-ft-suggestion-role="menu"][${EARLY_TAB_ACCEPT_ENTRY_ID_ATTR}="${entryId}"]`, - ); - return menu instanceof HTMLElement ? menu : null; -} - -function hasVisibleSuggestionMenu(element: FluentTyperManagedElement, doc: Document): boolean { - const menu = resolveSuggestionMenu(element, doc); - return menu instanceof HTMLElement && menu.isConnected && menu.style.display !== "none"; -} - function isManagedSuggestionTarget( element: HTMLElement | null, - doc: Document, ): element is FluentTyperManagedElement { return ( element instanceof HTMLElement && element.getAttribute("data-suggestion") === "true" && element.getAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR) === "true" && element.getAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR) === "true" && - hasVisibleSuggestionMenu(element, doc) + element.getAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR) === "true" ); } -function findManagedSuggestionTarget( - start: HTMLElement | null, - doc: Document, -): FluentTyperManagedElement | null { +function findManagedSuggestionTarget(start: HTMLElement | null): FluentTyperManagedElement | null { let current: Node | null = start; while (current) { - if (current instanceof HTMLElement && isManagedSuggestionTarget(current, doc)) { + if (current instanceof HTMLElement && isManagedSuggestionTarget(current)) { return current; } current = current.parentNode; @@ -67,7 +44,7 @@ function resolveManagedSuggestionTarget( const path = typeof event.composedPath === "function" ? event.composedPath() : [event.target]; for (const node of path) { if (node instanceof HTMLElement) { - const match = findManagedSuggestionTarget(node, doc); + const match = findManagedSuggestionTarget(node); if (match) { return match; } @@ -75,9 +52,7 @@ function resolveManagedSuggestionTarget( } const activeElement = doc.activeElement; - return activeElement instanceof HTMLElement - ? findManagedSuggestionTarget(activeElement, doc) - : null; + return activeElement instanceof HTMLElement ? findManagedSuggestionTarget(activeElement) : null; } export function installEarlyTabAcceptMainWorldBridge(doc: Document = document): void { @@ -155,4 +130,5 @@ export { EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, EARLY_TAB_ACCEPT_MESSAGE_TYPE, EARLY_TAB_ACCEPT_REQUEST_EVENT, + EARLY_TAB_ACCEPT_VISIBLE_ATTR, } from "./EarlyTabAcceptBridgeProtocol"; diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index 7c4504cc..1f0e719e 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -27,6 +27,7 @@ import { EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, EARLY_TAB_ACCEPT_ENABLED_ATTR, EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, + EARLY_TAB_ACCEPT_VISIBLE_ATTR, } from "./EarlyTabAcceptBridgeProtocol"; import { resolveTraceAgeMs } from "../predictionTrace"; import type { @@ -529,7 +530,8 @@ export class SuggestionManagerRuntime { EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, String(this.shouldUseEarlyTabBridge(elem)), ); - menu.dataset.ftSuggestionId = String(id); + elem.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); + menu.id = SuggestionMenuView.resolveHostId(id); elem.tributeMenu = menu; elem.suggestionMenu = menu; @@ -566,6 +568,7 @@ export class SuggestionManagerRuntime { entry.elem.removeAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); entry.elem.removeAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR); entry.elem.removeAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR); + entry.elem.removeAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR); this.entryRegistry.unregister(id); this.sessionRegistry.delete(id); @@ -711,7 +714,7 @@ export class SuggestionManagerRuntime { entry, editableContextResolver: this.editableContextResolver, clearPendingFallback: () => this.clearPendingKeyFallback(entry.id), - hideMenu: () => this.menuPresenter.hide(entry.menu, entry.list), + hideMenu: () => this.menuPresenter.hide(entry.menu, entry.list, entry.elem), clearInlinePresenter: () => this.inlinePresenter.clearAll(), isFocused: () => this.isEntryFocused(entry), displayLangHeader: this.displayLangHeader, @@ -723,6 +726,7 @@ export class SuggestionManagerRuntime { getPendingFallback: () => this.pendingKeyFallbacks.get(entry.id), renderMenu: ({ suggestions, selectedIndex, menuHeader, mentionText }) => this.menuPresenter.render({ + menuId: entry.id, menu: entry.menu, list: entry.list, target: entry.elem, diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts index 1ddb25a6..8013423b 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts @@ -1,8 +1,10 @@ +import { EARLY_TAB_ACCEPT_VISIBLE_ATTR } from "./EarlyTabAcceptBridgeProtocol"; import { SuggestionPositioningService } from "./SuggestionPositioningService"; import { SuggestionMenuView } from "./SuggestionMenuView"; import type { SuggestionElement } from "./types"; export interface SuggestionMenuRenderModel { + menuId: number; menu: HTMLDivElement; list: HTMLUListElement; target: SuggestionElement; @@ -44,7 +46,7 @@ export class SuggestionMenuPresenter { model.suggestions.forEach((suggestion, index) => { const li = document.createElement("li"); - li.id = `ft-suggestion-option-${model.menu.dataset.ftSuggestionId ?? "runtime"}-${index}`; + li.id = `ft-suggestion-option-${model.menuId}-${index}`; li.innerHTML = this.buildSuggestionMenuItemHtml({ mentionText: model.mentionText, suggestion, @@ -64,7 +66,7 @@ export class SuggestionMenuPresenter { }); if (model.suggestions.length === 0) { - this.hide(model.menu, model.list); + this.hide(model.menu, model.list, model.target); if (panel !== model.menu) { panel.setAttribute("aria-hidden", "true"); } @@ -75,7 +77,7 @@ export class SuggestionMenuPresenter { model.menu.style.setProperty("visibility", "hidden", "important"); this.positioningService.syncMenuTypography(model.menu, model.target); if (!this.positioningService.positionMenu(model.menu, model.target)) { - this.hide(model.menu, model.list); + this.hide(model.menu, model.list, model.target); if (panel !== model.menu) { panel.setAttribute("aria-hidden", "true"); } @@ -85,18 +87,20 @@ export class SuggestionMenuPresenter { panel.setAttribute("aria-hidden", "false"); panel.setAttribute( "aria-activedescendant", - `ft-suggestion-option-${model.menu.dataset.ftSuggestionId ?? "runtime"}-${model.selectedIndex}`, + `ft-suggestion-option-${model.menuId}-${model.selectedIndex}`, ); model.menu.style.setProperty("display", "block", "important"); model.menu.style.setProperty("visibility", "visible", "important"); + model.target.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); return true; } - public hide(menu: HTMLDivElement, list: HTMLUListElement): void { + public hide(menu: HTMLDivElement, list: HTMLUListElement, target?: SuggestionElement): void { const header = SuggestionMenuView.resolveHeader(menu); const panel = SuggestionMenuView.resolvePanel(menu); menu.style.setProperty("display", "none", "important"); menu.style.setProperty("visibility", "visible", "important"); + target?.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); if (header) { header.textContent = ""; header.hidden = true; diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts index 90ee8338..73d110e2 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts @@ -7,28 +7,24 @@ export interface SuggestionMenuElements { export class SuggestionMenuView { static readonly CONTAINER_CLASS = "ft-suggestion-container"; - static readonly OWNED_ATTR = "data-ft-suggestion-owned"; - static readonly ROLE_ATTR = "data-ft-suggestion-role"; - static readonly MENU_ROLE = "menu"; - static readonly SHADOW_ATTR = "data-ft-suggestion-shadow"; + static readonly HOST_ID_PREFIX = "ft-menu-"; static readonly PANEL_CLASS = "ft-suggestion-panel"; static readonly HEADER_CLASS = "ft-suggestion-header"; static readonly LIST_CLASS = "ft-suggestion-list"; + static resolveHostId(entryId: number | string): string { + return `${SuggestionMenuView.HOST_ID_PREFIX}${entryId}`; + } + static ensureMenu( container: HTMLElement = document.body ?? document.documentElement, ): SuggestionMenuElements { const doc = container.ownerDocument ?? document; const menu = doc.createElement("div"); - menu.className = SuggestionMenuView.CONTAINER_CLASS; - menu.setAttribute(SuggestionMenuView.OWNED_ATTR, "true"); - menu.setAttribute(SuggestionMenuView.ROLE_ATTR, SuggestionMenuView.MENU_ROLE); - menu.setAttribute("tabindex", "-1"); let list!: HTMLUListElement; if (typeof menu.attachShadow === "function") { this.applyBaseHostStyles(menu, true); - menu.setAttribute(SuggestionMenuView.SHADOW_ATTR, "true"); const shadowRoot = menu.attachShadow({ mode: "open" }); shadowRoot.appendChild(this.createShadowStyle(doc)); shadowRoot.appendChild( @@ -38,6 +34,7 @@ export class SuggestionMenuView { ); } else { this.applyBaseHostStyles(menu, false); + menu.className = SuggestionMenuView.CONTAINER_CLASS; list = doc.createElement("ul"); list.className = SuggestionMenuView.LIST_CLASS; menu.appendChild(this.createHeader(doc)); @@ -72,7 +69,7 @@ export class SuggestionMenuView { onListCreated: (list: HTMLUListElement) => void, ): HTMLDivElement { const panel = doc.createElement("div"); - panel.className = SuggestionMenuView.PANEL_CLASS; + panel.className = `${SuggestionMenuView.PANEL_CLASS} ${SuggestionMenuView.CONTAINER_CLASS}`; panel.setAttribute("part", "panel"); panel.setAttribute("role", "listbox"); panel.setAttribute("aria-hidden", "true"); diff --git a/tests/EarlyTabAcceptMainWorldBridge.test.ts b/tests/EarlyTabAcceptMainWorldBridge.test.ts index 7928c0cd..0715ae5b 100644 --- a/tests/EarlyTabAcceptMainWorldBridge.test.ts +++ b/tests/EarlyTabAcceptMainWorldBridge.test.ts @@ -4,6 +4,7 @@ import { EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, EARLY_TAB_ACCEPT_ENABLED_ATTR, EARLY_TAB_ACCEPT_MESSAGE_TYPE, + EARLY_TAB_ACCEPT_VISIBLE_ATTR, installEarlyTabAcceptMainWorldBridge, resetEarlyTabAcceptMainWorldBridgeForTests, } from "../src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge"; @@ -30,10 +31,9 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); const menu = document.createElement("div"); menu.style.display = "block"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); document.body.append(input, menu); const pageCaptureBlocker = (event: Event) => { @@ -71,10 +71,9 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "9"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); const menu = document.createElement("div"); menu.style.display = "block"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "9"); document.body.append(input, menu); const windowCaptureBlocker = (event: Event) => { @@ -112,10 +111,9 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); const menu = document.createElement("div"); menu.style.display = "none"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { @@ -140,10 +138,9 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "false"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "11"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); const menu = document.createElement("div"); menu.style.display = "block"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "11"); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { @@ -168,10 +165,9 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "false"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "13"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); const menu = document.createElement("div"); menu.style.display = "block"; - menu.setAttribute("data-ft-suggestion-role", "menu"); - menu.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "13"); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { diff --git a/tests/SuggestionMenuPresenter.test.ts b/tests/SuggestionMenuPresenter.test.ts index 9e9bea68..3db30fb1 100644 --- a/tests/SuggestionMenuPresenter.test.ts +++ b/tests/SuggestionMenuPresenter.test.ts @@ -15,6 +15,7 @@ describe("SuggestionMenuPresenter", () => { menu.appendChild(list); const rendered = presenter.render({ + menuId: 1, menu, list, target, @@ -48,6 +49,7 @@ describe("SuggestionMenuPresenter", () => { menu.appendChild(list); const rendered = presenter.render({ + menuId: 1, menu, list, target, diff --git a/tests/SuggestionMenuView.test.ts b/tests/SuggestionMenuView.test.ts new file mode 100644 index 00000000..817f6ef8 --- /dev/null +++ b/tests/SuggestionMenuView.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { SuggestionMenuView } from "../src/adapters/chrome/content-script/suggestions/SuggestionMenuView"; + +describe("SuggestionMenuView", () => { + test("keeps the public container class inside the shadow root", () => { + const mount = document.createElement("div"); + document.body.appendChild(mount); + + const { menu, list } = SuggestionMenuView.ensureMenu(mount); + + expect(menu.parentElement).toBe(mount); + expect(menu.classList.contains(SuggestionMenuView.CONTAINER_CLASS)).toBe(false); + expect(menu.getAttribute("data-ft-suggestion-owned")).toBeNull(); + expect(menu.getAttribute("data-ft-suggestion-role")).toBeNull(); + expect(menu.getAttribute("data-ft-suggestion-shadow")).toBeNull(); + expect(menu.getAttribute("tabindex")).toBeNull(); + expect(document.querySelector(`.${SuggestionMenuView.CONTAINER_CLASS}`)).toBeNull(); + + const shadowRoot = menu.shadowRoot; + expect(shadowRoot).not.toBeNull(); + expect(list.getRootNode()).toBe(shadowRoot); + + const panel = shadowRoot?.querySelector(`.${SuggestionMenuView.PANEL_CLASS}`); + expect(panel).not.toBeNull(); + expect(panel?.classList.contains(SuggestionMenuView.CONTAINER_CLASS)).toBe(true); + }); +}); diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index d0643adb..7c2f277e 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -1131,15 +1131,46 @@ async function waitForVisibleSuggestionTexts( const texts = await page.evaluate(() => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getManagedMenus = (): Element[] => { + const seen = new Set(); + return collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getManagedMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -1170,15 +1201,44 @@ async function hasVisibleSuggestions(page: Page): Promise { return page.evaluate(() => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getManagedMenus = (): Element[] => { + const seen = new Set(); + return collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getManagedMenus().filter((container) => container !== activeMenu), ]; return containers.some((container) => { const style = window.getComputedStyle(container); @@ -1203,15 +1263,44 @@ async function waitForNoVisibleSuggestions( () => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getManagedMenus = (): Element[] => { + const seen = new Set(); + return collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getManagedMenus().filter((container) => container !== activeMenu), ]; return containers.every((container) => { const style = window.getComputedStyle(container); @@ -1238,15 +1327,44 @@ async function clickFirstVisibleSuggestion( () => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getManagedMenus = (): Element[] => { + const seen = new Set(); + return collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getManagedMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -2053,10 +2171,51 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { const state = await page.evaluate(() => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getManagedMenus = (): Element[] => { + const seen = new Set(); + return collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => + typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; const hasInlineSuggestion = Boolean( document.querySelector(".ft-suggestion-inline")?.textContent, ); - const containers = Array.from(document.querySelectorAll(".ft-suggestion-container")); + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; + const containers = [ + ...(activeMenu instanceof Element ? [activeMenu] : []), + ...getManagedMenus().filter((container) => container !== activeMenu), + ]; const hasVisiblePopup = containers.some((container) => { const style = window.getComputedStyle(container); if ( diff --git a/tests/e2e/smoke.e2e.test.ts b/tests/e2e/smoke.e2e.test.ts index 6ebaa3cb..0891cd10 100644 --- a/tests/e2e/smoke.e2e.test.ts +++ b/tests/e2e/smoke.e2e.test.ts @@ -672,15 +672,44 @@ async function waitForSuggestionTexts(page: Page): Promise { () => { const getMenuRoot = (container: Element): ParentNode => (container as HTMLElement).shadowRoot ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getManagedMenus = (): Element[] => { + const seen = new Set(); + return collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getManagedMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -716,15 +745,44 @@ async function getVisibleSuggestionThemeSnapshot(page: Page): Promise<{ ((container as HTMLElement).shadowRoot?.querySelector( ".ft-suggestion-panel", ) as Element | null) ?? container; - const activeElement = document.activeElement as - | (HTMLElement & { suggestionMenu?: Element | null }) - | null; - const activeMenu = activeElement?.suggestionMenu; + const getDeepActiveElement = (): HTMLElement | null => { + let active: Element | null = document.activeElement; + while (active instanceof HTMLElement && active.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active instanceof HTMLElement ? active : null; + }; + const getMenuHostId = (entryId: string): string => `ft-menu-${entryId}`; + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + const getManagedMenus = (): Element[] => { + const seen = new Set(); + return collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element) + .filter((menu) => { + if (seen.has(menu)) { + return false; + } + seen.add(menu); + return true; + }); + }; + const activeElement = getDeepActiveElement(); + const activeEntryId = activeElement?.getAttribute("data-ft-suggestion-id"); + const activeMenu = + typeof activeEntryId === "string" + ? document.getElementById(getMenuHostId(activeEntryId)) + : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...Array.from(document.querySelectorAll(".ft-suggestion-container")).filter( - (container) => container !== activeMenu, - ), + ...getManagedMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); diff --git a/tests/suggestionTestUtils.ts b/tests/suggestionTestUtils.ts index a14447e6..1e64e2d3 100644 --- a/tests/suggestionTestUtils.ts +++ b/tests/suggestionTestUtils.ts @@ -2,6 +2,8 @@ import type { SuggestionEntry, SuggestionElement, } from "../src/adapters/chrome/content-script/suggestions/types"; +import { EARLY_TAB_ACCEPT_ENTRY_ID_ATTR } from "../src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge"; +import { SuggestionMenuView } from "../src/adapters/chrome/content-script/suggestions/SuggestionMenuView"; export function createSuggestionEntry( overrides: Partial & { elem?: SuggestionElement } = {}, @@ -76,9 +78,27 @@ export function createRect(left = 10, top = 20, width = 30, height = 12): DOMRec } export function getSuggestionMenuRoots(doc: Document = document): ParentNode[] { - return Array.from(doc.querySelectorAll(".ft-suggestion-container")).map( - (container) => container.shadowRoot ?? container, - ); + const seen = new Set(); + const collectManagedElements = (root: ParentNode): HTMLElement[] => [ + ...Array.from(root.querySelectorAll('[data-suggestion="true"]')), + ...Array.from(root.querySelectorAll("*")).flatMap((element) => + element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], + ), + ]; + + return collectManagedElements(doc) + .map((element) => element.getAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR)) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => doc.getElementById(SuggestionMenuView.resolveHostId(entryId))) + .filter((menu): menu is HTMLElement => menu instanceof HTMLElement) + .map((container) => container.shadowRoot ?? container) + .filter((root) => { + if (seen.has(root)) { + return false; + } + seen.add(root); + return true; + }); } export function querySuggestionMenuItems(doc: Document = document): HTMLLIElement[] { From e84cf55e9ea774951fe877fe61c005707c9be200 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 06:23:16 +0100 Subject: [PATCH 2/6] fix: validate popup host visibility before tab accept --- .../EarlyTabAcceptMainWorldBridge.ts | 22 ++++- .../SuggestionLifecycleController.ts | 6 +- .../suggestions/SuggestionMenuHost.ts | 31 +++++++ .../suggestions/SuggestionMenuPresenter.ts | 3 +- .../suggestions/SuggestionMenuView.ts | 8 +- tests/EarlyTabAcceptMainWorldBridge.test.ts | 76 ++++++++++++++-- tests/SuggestionManagerRuntime.test.ts | 91 +++++++++++++++++++ 7 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 src/adapters/chrome/content-script/suggestions/SuggestionMenuHost.ts diff --git a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts index 0fdf7a51..26f09458 100644 --- a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts @@ -7,6 +7,10 @@ import { EARLY_TAB_ACCEPT_REQUEST_EVENT, EARLY_TAB_ACCEPT_VISIBLE_ATTR, } from "./EarlyTabAcceptBridgeProtocol"; +import { + isSuggestionMenuHostVisible, + resolveSuggestionMenuHost, +} from "./SuggestionMenuHost"; type FluentTyperManagedElement = HTMLElement; type FluentTyperBridgeWindow = Window & { @@ -16,20 +20,28 @@ type FluentTyperBridgeWindow = Window & { function isManagedSuggestionTarget( element: HTMLElement | null, + doc: Document, ): element is FluentTyperManagedElement { + const entryId = + element instanceof HTMLElement ? element.getAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR) : null; return ( element instanceof HTMLElement && element.getAttribute("data-suggestion") === "true" && element.getAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR) === "true" && element.getAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR) === "true" && - element.getAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR) === "true" + element.getAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR) === "true" && + !!entryId && + isSuggestionMenuHostVisible(resolveSuggestionMenuHost(doc, entryId)) ); } -function findManagedSuggestionTarget(start: HTMLElement | null): FluentTyperManagedElement | null { +function findManagedSuggestionTarget( + start: HTMLElement | null, + doc: Document, +): FluentTyperManagedElement | null { let current: Node | null = start; while (current) { - if (current instanceof HTMLElement && isManagedSuggestionTarget(current)) { + if (current instanceof HTMLElement && isManagedSuggestionTarget(current, doc)) { return current; } current = current.parentNode; @@ -44,7 +56,7 @@ function resolveManagedSuggestionTarget( const path = typeof event.composedPath === "function" ? event.composedPath() : [event.target]; for (const node of path) { if (node instanceof HTMLElement) { - const match = findManagedSuggestionTarget(node); + const match = findManagedSuggestionTarget(node, doc); if (match) { return match; } @@ -52,7 +64,7 @@ function resolveManagedSuggestionTarget( } const activeElement = doc.activeElement; - return activeElement instanceof HTMLElement ? findManagedSuggestionTarget(activeElement) : null; + return activeElement instanceof HTMLElement ? findManagedSuggestionTarget(activeElement, doc) : null; } export function installEarlyTabAcceptMainWorldBridge(doc: Document = document): void { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts b/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts index 1c191f4d..7df720ea 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionLifecycleController.ts @@ -1,3 +1,4 @@ +import { isSuggestionMenuHostVisible } from "./SuggestionMenuHost"; import type { SuggestionEntry } from "./types"; export interface SuggestionLifecycleControllerOptions { @@ -222,10 +223,7 @@ export class SuggestionLifecycleController { } private isDocumentTabFallbackEligible(entry: SuggestionEntry): boolean { - return ( - entry.inlineSuggestion !== null || - (entry.suggestions.length > 0 && entry.menu.style.display !== "none") - ); + return entry.inlineSuggestion !== null || isSuggestionMenuHostVisible(entry.menu); } private onDocumentSelectionChange(): void { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuHost.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuHost.ts new file mode 100644 index 00000000..9142dd44 --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuHost.ts @@ -0,0 +1,31 @@ +export const SUGGESTION_MENU_HOST_ID_PREFIX = "ft-menu-"; + +export function resolveSuggestionMenuHostId(entryId: number | string): string { + return `${SUGGESTION_MENU_HOST_ID_PREFIX}${entryId}`; +} + +export function resolveSuggestionMenuHost( + doc: Document, + entryId: number | string, +): HTMLElement | null { + const menu = doc.getElementById(resolveSuggestionMenuHostId(entryId)); + return menu instanceof HTMLElement ? menu : null; +} + +export function isSuggestionMenuHostVisible(menu: HTMLElement | null): boolean { + if (!(menu instanceof HTMLElement) || !menu.isConnected) { + return false; + } + + const win = menu.ownerDocument?.defaultView; + if (!win) { + return false; + } + + const computed = win.getComputedStyle(menu); + return ( + computed.display !== "none" && + computed.visibility !== "hidden" && + computed.visibility !== "collapse" + ); +} diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts index 8013423b..ad682163 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts @@ -1,4 +1,5 @@ import { EARLY_TAB_ACCEPT_VISIBLE_ATTR } from "./EarlyTabAcceptBridgeProtocol"; +import { isSuggestionMenuHostVisible } from "./SuggestionMenuHost"; import { SuggestionPositioningService } from "./SuggestionPositioningService"; import { SuggestionMenuView } from "./SuggestionMenuView"; import type { SuggestionElement } from "./types"; @@ -111,7 +112,7 @@ export class SuggestionMenuPresenter { } public isVisible(menu: HTMLDivElement, suggestionCount: number): boolean { - return menu.style.display !== "none" && suggestionCount > 0; + return suggestionCount > 0 && isSuggestionMenuHostVisible(menu); } public updateHighlight(list: HTMLUListElement, selectedIndex: number): void { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts index 73d110e2..153fba2a 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts @@ -1,3 +1,7 @@ +import { + resolveSuggestionMenuHostId, + SUGGESTION_MENU_HOST_ID_PREFIX, +} from "./SuggestionMenuHost"; import { SUGGESTION_POPUP_SHADOW_CSS } from "./SuggestionPopupShadowStyles"; export interface SuggestionMenuElements { @@ -7,13 +11,13 @@ export interface SuggestionMenuElements { export class SuggestionMenuView { static readonly CONTAINER_CLASS = "ft-suggestion-container"; - static readonly HOST_ID_PREFIX = "ft-menu-"; + static readonly HOST_ID_PREFIX = SUGGESTION_MENU_HOST_ID_PREFIX; static readonly PANEL_CLASS = "ft-suggestion-panel"; static readonly HEADER_CLASS = "ft-suggestion-header"; static readonly LIST_CLASS = "ft-suggestion-list"; static resolveHostId(entryId: number | string): string { - return `${SuggestionMenuView.HOST_ID_PREFIX}${entryId}`; + return resolveSuggestionMenuHostId(entryId); } static ensureMenu( diff --git a/tests/EarlyTabAcceptMainWorldBridge.test.ts b/tests/EarlyTabAcceptMainWorldBridge.test.ts index 0715ae5b..ed4d7905 100644 --- a/tests/EarlyTabAcceptMainWorldBridge.test.ts +++ b/tests/EarlyTabAcceptMainWorldBridge.test.ts @@ -15,6 +15,14 @@ declare global { } } +function createMenu(entryId: string, styles: Partial = {}): HTMLDivElement { + const menu = document.createElement("div"); + menu.id = `ft-menu-${entryId}`; + menu.style.display = "block"; + Object.assign(menu.style, styles); + return menu; +} + describe("EarlyTabAcceptMainWorldBridge", () => { afterEach(() => { document.body.innerHTML = ""; @@ -32,8 +40,7 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); - const menu = document.createElement("div"); - menu.style.display = "block"; + const menu = createMenu("7"); document.body.append(input, menu); const pageCaptureBlocker = (event: Event) => { @@ -72,8 +79,7 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "9"); input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); - const menu = document.createElement("div"); - menu.style.display = "block"; + const menu = createMenu("9"); document.body.append(input, menu); const windowCaptureBlocker = (event: Event) => { @@ -112,8 +118,7 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "7"); input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); - const menu = document.createElement("div"); - menu.style.display = "none"; + const menu = createMenu("7", { display: "none" }); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { @@ -139,8 +144,7 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "11"); input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); - const menu = document.createElement("div"); - menu.style.display = "block"; + const menu = createMenu("11"); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { @@ -166,8 +170,60 @@ describe("EarlyTabAcceptMainWorldBridge", () => { input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "false"); input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "13"); input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); - const menu = document.createElement("div"); - menu.style.display = "block"; + const menu = createMenu("13"); + document.body.append(input, menu); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + postMessageSpy.mockRestore(); + }); + + test("does not post when the popup host was removed without clearing the visible flag", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("div"); + input.setAttribute("contenteditable", "true"); + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "17"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("17"); + document.body.append(input, menu); + menu.remove(); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(keydown); + + expect(postMessageSpy).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + postMessageSpy.mockRestore(); + }); + + test("does not post when the popup host is computed hidden without clearing the visible flag", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + const input = document.createElement("div"); + input.setAttribute("contenteditable", "true"); + input.setAttribute("data-suggestion", "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + input.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "19"); + input.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("19", { visibility: "hidden" }); document.body.append(input, menu); const keydown = new window.KeyboardEvent("keydown", { diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index 31bb5ecc..dbd48cc9 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -612,6 +612,50 @@ describe("SuggestionManagerRuntime", () => { expect(acceptSuggestionAtIndex).toHaveBeenCalledWith(0); }); + test("early bridge accept reports no visible suggestion state when the popup host is hidden", () => { + const runtime = makeRuntime(); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + document.body.appendChild(editable); + + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(editable); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + entry.suggestions = ["hello"]; + entry.selectedIndex = 0; + entry.menu.style.display = "block"; + entry.menu.style.visibility = "hidden"; + + const session = getAttachedSession(runtime, entry.id); + const acceptSuggestionAtIndex = jest.fn(() => true); + session.acceptSuggestionAtIndex = acceptSuggestionAtIndex; + + expect( + ( + runtime as unknown as { + handleEarlyTabAcceptRequest: (entryId: string) => { + accepted: boolean; + reason: string; + }; + } + ).handleEarlyTabAcceptRequest(String(entry.id)), + ).toEqual( + expect.objectContaining({ + accepted: false, + reason: "no_visible_suggestion_state", + }), + ); + expect(acceptSuggestionAtIndex).not.toHaveBeenCalled(); + }); + test("early bridge accept reports failure when session acceptance returns false", () => { const runtime = makeRuntime(); const editable = document.createElement("div"); @@ -650,6 +694,53 @@ describe("SuggestionManagerRuntime", () => { ); }); + test("document-level Tab capture ignores suggestions when the popup host was removed", () => { + const runtime = makeRuntime(); + const wrapper = document.createElement("div"); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + Object.defineProperty(editable, "isContentEditable", { value: true, configurable: true }); + wrapper.appendChild(editable); + document.body.appendChild(wrapper); + + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(editable); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + entry.suggestions = ["hello"]; + entry.selectedIndex = 0; + entry.menu.style.display = "block"; + entry.menu.remove(); + + const session = getAttachedSession(runtime, entry.id); + const acceptSuggestionAtIndex = jest.fn(() => true); + session.acceptSuggestionAtIndex = acceptSuggestionAtIndex; + + wrapper.addEventListener( + "keydown", + (event) => { + event.stopPropagation(); + }, + true, + ); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + editable.dispatchEvent(keydown); + + expect(acceptSuggestionAtIndex).not.toHaveBeenCalled(); + expect(keydown.defaultPrevented).toBe(false); + }); + test("selection reconciliation delegates to the attached session", () => { const runtime = makeRuntime("input"); const input = document.createElement("input"); From b89227b0ed47d3e80957580d446b499759ae95a1 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 06:25:21 +0100 Subject: [PATCH 3/6] fix: restore light-dom suggestion menu style hooks --- .../suggestions/SuggestionMenuView.ts | 5 ++++ tests/SuggestionMenuView.test.ts | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts index 153fba2a..30def479 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts @@ -12,6 +12,9 @@ export interface SuggestionMenuElements { export class SuggestionMenuView { static readonly CONTAINER_CLASS = "ft-suggestion-container"; static readonly HOST_ID_PREFIX = SUGGESTION_MENU_HOST_ID_PREFIX; + static readonly OWNED_ATTR = "data-ft-suggestion-owned"; + static readonly ROLE_ATTR = "data-ft-suggestion-role"; + static readonly MENU_ROLE = "menu"; static readonly PANEL_CLASS = "ft-suggestion-panel"; static readonly HEADER_CLASS = "ft-suggestion-header"; static readonly LIST_CLASS = "ft-suggestion-list"; @@ -39,6 +42,8 @@ export class SuggestionMenuView { } else { this.applyBaseHostStyles(menu, false); menu.className = SuggestionMenuView.CONTAINER_CLASS; + menu.setAttribute(SuggestionMenuView.OWNED_ATTR, "true"); + menu.setAttribute(SuggestionMenuView.ROLE_ATTR, SuggestionMenuView.MENU_ROLE); list = doc.createElement("ul"); list.className = SuggestionMenuView.LIST_CLASS; menu.appendChild(this.createHeader(doc)); diff --git a/tests/SuggestionMenuView.test.ts b/tests/SuggestionMenuView.test.ts index 817f6ef8..d9e0bc6c 100644 --- a/tests/SuggestionMenuView.test.ts +++ b/tests/SuggestionMenuView.test.ts @@ -24,4 +24,31 @@ describe("SuggestionMenuView", () => { expect(panel).not.toBeNull(); expect(panel?.classList.contains(SuggestionMenuView.CONTAINER_CLASS)).toBe(true); }); + + test("keeps styling hooks on the light-DOM fallback host when shadow DOM is unavailable", () => { + const mount = document.createElement("div"); + document.body.appendChild(mount); + + const originalAttachShadow = HTMLElement.prototype.attachShadow; + Object.defineProperty(HTMLElement.prototype, "attachShadow", { + value: undefined, + configurable: true, + }); + + try { + const { menu, list } = SuggestionMenuView.ensureMenu(mount); + + expect(menu.parentElement).toBe(mount); + expect(menu.classList.contains(SuggestionMenuView.CONTAINER_CLASS)).toBe(true); + expect(menu.getAttribute("data-ft-suggestion-owned")).toBe("true"); + expect(menu.getAttribute("data-ft-suggestion-role")).toBe("menu"); + expect(list.getRootNode()).toBe(document); + expect(menu.shadowRoot).toBeNull(); + } finally { + Object.defineProperty(HTMLElement.prototype, "attachShadow", { + value: originalAttachShadow, + configurable: true, + }); + } + }); }); From 2ed0fbb8a538ac53a90f76d20c0e316ecf13502d Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 06:46:26 +0100 Subject: [PATCH 4/6] fix: keep editor iframe overlays out of saved content --- .../suggestions/InlineSuggestionView.ts | 4 ++- .../suggestions/SuggestionManagerRuntime.ts | 5 ++- .../suggestions/SuggestionOverlayRoot.ts | 3 ++ tests/InlineSuggestionView.test.ts | 28 +++++++++++++++ tests/SuggestionManagerRuntime.test.ts | 36 +++++++++++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/adapters/chrome/content-script/suggestions/SuggestionOverlayRoot.ts create mode 100644 tests/InlineSuggestionView.test.ts diff --git a/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts b/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts index 7f489198..22947177 100644 --- a/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts +++ b/src/adapters/chrome/content-script/suggestions/InlineSuggestionView.ts @@ -1,3 +1,5 @@ +import { resolveSuggestionOverlayRoot } from "./SuggestionOverlayRoot"; + export class InlineSuggestionView { static readonly CLASS_NAME = "ft-suggestion-inline"; static readonly OWNED_ATTR = "data-ft-suggestion-owned"; @@ -54,7 +56,7 @@ export class InlineSuggestionView { ghost.style.maxWidth = `${maxWidth}px`; } - doc.body.appendChild(ghost); + resolveSuggestionOverlayRoot(doc).appendChild(ghost); return ghost; } diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index 1f0e719e..477d3065 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -19,6 +19,7 @@ import { SuggestionPositioningService } from "./SuggestionPositioningService"; import { SuggestionPredictionCoordinator } from "./SuggestionPredictionCoordinator"; import { SuggestionMenuView } from "./SuggestionMenuView"; import { SuggestionTelemetryService } from "./SuggestionTelemetryService"; +import { resolveSuggestionOverlayRoot } from "./SuggestionOverlayRoot"; import { EditableContextResolver } from "./EditableContextResolver"; import { SuggestionTextEditService } from "./SuggestionTextEditService"; import { ContentEditableAdapter } from "./ContentEditableAdapter"; @@ -456,7 +457,9 @@ export class SuggestionManagerRuntime { const id = this.entryRegistry.allocateId(); - const { menu, list } = SuggestionMenuView.ensureMenu(document.body ?? document.documentElement); + const { menu, list } = SuggestionMenuView.ensureMenu( + resolveSuggestionOverlayRoot(elem.ownerDocument ?? document), + ); const entry: SuggestionEntry = { id, diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionOverlayRoot.ts b/src/adapters/chrome/content-script/suggestions/SuggestionOverlayRoot.ts new file mode 100644 index 00000000..21762340 --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionOverlayRoot.ts @@ -0,0 +1,3 @@ +export function resolveSuggestionOverlayRoot(doc: Document = document): HTMLElement { + return doc.documentElement ?? doc.body; +} diff --git a/tests/InlineSuggestionView.test.ts b/tests/InlineSuggestionView.test.ts new file mode 100644 index 00000000..70048a67 --- /dev/null +++ b/tests/InlineSuggestionView.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { InlineSuggestionView } from "../src/adapters/chrome/content-script/suggestions/InlineSuggestionView"; + +describe("InlineSuggestionView", () => { + afterEach(() => { + document.documentElement.innerHTML = ""; + }); + + test("mounts inline ghost outside a contenteditable body root", () => { + document.body.setAttribute("contenteditable", "true"); + Object.defineProperty(document.body, "isContentEditable", { + value: true, + configurable: true, + }); + document.body.textContent = "hello"; + + const ghost = InlineSuggestionView.render({ + target: document.body, + text: " world", + caretRect: { left: 10, top: 20, width: 0, height: 16 } as DOMRect, + doc: document, + }); + + expect(ghost).not.toBeNull(); + expect(ghost?.parentElement).toBe(document.documentElement); + expect(document.body.querySelector(`.${InlineSuggestionView.CLASS_NAME}`)).toBeNull(); + }); +}); diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index dbd48cc9..89c0e46b 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -165,6 +165,8 @@ describe("SuggestionManagerRuntime", () => { (globalThis as unknown as { getComputedStyle: typeof getComputedStyle }).getComputedStyle = baseGlobals.getComputedStyle; document.body.innerHTML = ""; + document.body.removeAttribute("contenteditable"); + delete (document.body as { isContentEditable?: boolean }).isContentEditable; document.documentElement.dir = ""; (globalThis as unknown as { chrome: unknown }).chrome = { runtime: { @@ -227,6 +229,40 @@ describe("SuggestionManagerRuntime", () => { expect(input.hasAttribute("data-suggestion")).toBe(false); }); + test("mounts the popup host outside a contenteditable body root", () => { + const runtime = makeRuntime(); + const originalBodyContentEditable = Object.getOwnPropertyDescriptor( + document.body, + "isContentEditable", + ); + document.body.setAttribute("contenteditable", "true"); + Object.defineProperty(document.body, "isContentEditable", { + value: true, + configurable: true, + }); + try { + runtime.queryAndAttachHelper(); + + const runtimeInternal = runtime as unknown as { + entryRegistry: { getByElement: (elem: Element) => SuggestionEntry | undefined }; + }; + const entry = runtimeInternal.entryRegistry.getByElement(document.body); + if (!entry) { + throw new Error("Expected attached suggestion entry"); + } + + expect(entry.menu.parentElement).toBe(document.documentElement); + expect(document.body.querySelector(`#${entry.menu.id}`)).toBeNull(); + } finally { + document.body.removeAttribute("contenteditable"); + if (originalBodyContentEditable) { + Object.defineProperty(document.body, "isContentEditable", originalBodyContentEditable); + } else { + delete (document.body as { isContentEditable?: boolean }).isContentEditable; + } + } + }); + test("runtime only orchestrates attach, active-session lookup, and response routing", () => { const runtime = makeRuntime(); const input = document.createElement("input"); From 234362129ca7e44bf06558605f1fdc1273bb9a21 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 06:57:48 +0100 Subject: [PATCH 5/6] fix: keep body-root editor markers out of saved content --- .../EarlyTabAcceptMainWorldBridge.ts | 9 ++-- .../suggestions/SuggestionManagerRuntime.ts | 27 +++++++----- .../suggestions/SuggestionMenuPresenter.ts | 7 ++- .../suggestions/SuggestionMenuView.ts | 5 +-- .../suggestions/SuggestionStateHost.ts | 9 ++++ tests/EarlyTabAcceptMainWorldBridge.test.ts | 44 +++++++++++++++++++ tests/SuggestionManagerRuntime.test.ts | 18 ++++++++ 7 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 src/adapters/chrome/content-script/suggestions/SuggestionStateHost.ts diff --git a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts index 26f09458..9fd1b99d 100644 --- a/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts +++ b/src/adapters/chrome/content-script/suggestions/EarlyTabAcceptMainWorldBridge.ts @@ -7,10 +7,7 @@ import { EARLY_TAB_ACCEPT_REQUEST_EVENT, EARLY_TAB_ACCEPT_VISIBLE_ATTR, } from "./EarlyTabAcceptBridgeProtocol"; -import { - isSuggestionMenuHostVisible, - resolveSuggestionMenuHost, -} from "./SuggestionMenuHost"; +import { isSuggestionMenuHostVisible, resolveSuggestionMenuHost } from "./SuggestionMenuHost"; type FluentTyperManagedElement = HTMLElement; type FluentTyperBridgeWindow = Window & { @@ -64,7 +61,9 @@ function resolveManagedSuggestionTarget( } const activeElement = doc.activeElement; - return activeElement instanceof HTMLElement ? findManagedSuggestionTarget(activeElement, doc) : null; + return activeElement instanceof HTMLElement + ? findManagedSuggestionTarget(activeElement, doc) + : null; } export function installEarlyTabAcceptMainWorldBridge(doc: Document = document): void { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts index 477d3065..d201a933 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionManagerRuntime.ts @@ -17,6 +17,7 @@ import { SuggestionLifecycleController } from "./SuggestionLifecycleController"; import { SuggestionMenuPresenter } from "./SuggestionMenuPresenter"; import { SuggestionPositioningService } from "./SuggestionPositioningService"; import { SuggestionPredictionCoordinator } from "./SuggestionPredictionCoordinator"; +import { resolveSuggestionStateHost } from "./SuggestionStateHost"; import { SuggestionMenuView } from "./SuggestionMenuView"; import { SuggestionTelemetryService } from "./SuggestionTelemetryService"; import { resolveSuggestionOverlayRoot } from "./SuggestionOverlayRoot"; @@ -456,6 +457,7 @@ export class SuggestionManagerRuntime { this.removeManualAttachUi(elem); const id = this.entryRegistry.allocateId(); + const stateHost = resolveSuggestionStateHost(elem); const { menu, list } = SuggestionMenuView.ensureMenu( resolveSuggestionOverlayRoot(elem.ownerDocument ?? document), @@ -525,15 +527,15 @@ export class SuggestionManagerRuntime { }; entry.handlers.menuClick = this.onMenuClick.bind(this, id); - elem.setAttribute("data-tribute", "true"); - elem.setAttribute("data-suggestion", "true"); - elem.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, String(id)); - elem.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, String(this.autocompleteOnTab)); - elem.setAttribute( + stateHost.setAttribute("data-tribute", "true"); + stateHost.setAttribute("data-suggestion", "true"); + stateHost.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, String(id)); + stateHost.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, String(this.autocompleteOnTab)); + stateHost.setAttribute( EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, String(this.shouldUseEarlyTabBridge(elem)), ); - elem.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); + stateHost.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); menu.id = SuggestionMenuView.resolveHostId(id); elem.tributeMenu = menu; elem.suggestionMenu = menu; @@ -563,15 +565,16 @@ export class SuggestionManagerRuntime { this.sessionRegistry.get(id)?.dispose(); this.lifecycleController.detachEntryListeners(entry); entry.menu.remove(); + const stateHost = resolveSuggestionStateHost(entry.elem); delete entry.elem.tributeMenu; delete entry.elem.suggestionMenu; - entry.elem.removeAttribute("data-tribute"); - entry.elem.removeAttribute("data-suggestion"); - entry.elem.removeAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); - entry.elem.removeAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR); - entry.elem.removeAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR); - entry.elem.removeAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR); + stateHost.removeAttribute("data-tribute"); + stateHost.removeAttribute("data-suggestion"); + stateHost.removeAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); + stateHost.removeAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR); + stateHost.removeAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR); + stateHost.removeAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR); this.entryRegistry.unregister(id); this.sessionRegistry.delete(id); diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts index ad682163..a2240892 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuPresenter.ts @@ -1,5 +1,6 @@ import { EARLY_TAB_ACCEPT_VISIBLE_ATTR } from "./EarlyTabAcceptBridgeProtocol"; import { isSuggestionMenuHostVisible } from "./SuggestionMenuHost"; +import { resolveSuggestionStateHost } from "./SuggestionStateHost"; import { SuggestionPositioningService } from "./SuggestionPositioningService"; import { SuggestionMenuView } from "./SuggestionMenuView"; import type { SuggestionElement } from "./types"; @@ -92,7 +93,7 @@ export class SuggestionMenuPresenter { ); model.menu.style.setProperty("display", "block", "important"); model.menu.style.setProperty("visibility", "visible", "important"); - model.target.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + resolveSuggestionStateHost(model.target).setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); return true; } @@ -101,7 +102,9 @@ export class SuggestionMenuPresenter { const panel = SuggestionMenuView.resolvePanel(menu); menu.style.setProperty("display", "none", "important"); menu.style.setProperty("visibility", "visible", "important"); - target?.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); + if (target) { + resolveSuggestionStateHost(target).setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "false"); + } if (header) { header.textContent = ""; header.hidden = true; diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts index 30def479..391c3992 100644 --- a/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts +++ b/src/adapters/chrome/content-script/suggestions/SuggestionMenuView.ts @@ -1,7 +1,4 @@ -import { - resolveSuggestionMenuHostId, - SUGGESTION_MENU_HOST_ID_PREFIX, -} from "./SuggestionMenuHost"; +import { resolveSuggestionMenuHostId, SUGGESTION_MENU_HOST_ID_PREFIX } from "./SuggestionMenuHost"; import { SUGGESTION_POPUP_SHADOW_CSS } from "./SuggestionPopupShadowStyles"; export interface SuggestionMenuElements { diff --git a/src/adapters/chrome/content-script/suggestions/SuggestionStateHost.ts b/src/adapters/chrome/content-script/suggestions/SuggestionStateHost.ts new file mode 100644 index 00000000..aa60448e --- /dev/null +++ b/src/adapters/chrome/content-script/suggestions/SuggestionStateHost.ts @@ -0,0 +1,9 @@ +import type { SuggestionElement } from "./types"; + +export function resolveSuggestionStateHost(target: SuggestionElement): HTMLElement { + const doc = target.ownerDocument ?? document; + if (target === doc.body && target.isContentEditable) { + return doc.documentElement ?? target; + } + return target; +} diff --git a/tests/EarlyTabAcceptMainWorldBridge.test.ts b/tests/EarlyTabAcceptMainWorldBridge.test.ts index ed4d7905..33138e42 100644 --- a/tests/EarlyTabAcceptMainWorldBridge.test.ts +++ b/tests/EarlyTabAcceptMainWorldBridge.test.ts @@ -26,6 +26,14 @@ function createMenu(entryId: string, styles: Partial = {}): describe("EarlyTabAcceptMainWorldBridge", () => { afterEach(() => { document.body.innerHTML = ""; + document.body.removeAttribute("contenteditable"); + delete (document.body as { isContentEditable?: boolean }).isContentEditable; + document.documentElement.removeAttribute("data-suggestion"); + document.documentElement.removeAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR); + document.documentElement.removeAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR); + document.documentElement.removeAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR); + document.documentElement.removeAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR); + document.querySelectorAll('[id^="ft-menu-"]').forEach((node) => node.remove()); resetEarlyTabAcceptMainWorldBridgeForTests(document); }); @@ -237,4 +245,40 @@ describe("EarlyTabAcceptMainWorldBridge", () => { expect(keydown.defaultPrevented).toBe(false); postMessageSpy.mockRestore(); }); + + test("posts for a contenteditable body when the bridge markers live on the html root", () => { + installEarlyTabAcceptMainWorldBridge(document); + const postMessageSpy = jest.spyOn(window, "postMessage"); + + document.body.setAttribute("contenteditable", "true"); + Object.defineProperty(document.body, "isContentEditable", { + value: true, + configurable: true, + }); + document.documentElement.setAttribute("data-suggestion", "true"); + document.documentElement.setAttribute(EARLY_TAB_ACCEPT_ENABLED_ATTR, "true"); + document.documentElement.setAttribute(EARLY_TAB_ACCEPT_BRIDGE_TARGET_ATTR, "true"); + document.documentElement.setAttribute(EARLY_TAB_ACCEPT_ENTRY_ID_ATTR, "29"); + document.documentElement.setAttribute(EARLY_TAB_ACCEPT_VISIBLE_ATTR, "true"); + const menu = createMenu("29"); + document.documentElement.append(menu); + + const keydown = new window.KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + }); + document.body.dispatchEvent(keydown); + + expect(postMessageSpy).toHaveBeenCalledWith( + { + source: "ft-early-tab-accept-request", + type: EARLY_TAB_ACCEPT_MESSAGE_TYPE, + entryId: "29", + }, + "*", + ); + expect(keydown.defaultPrevented).toBe(true); + postMessageSpy.mockRestore(); + }); }); diff --git a/tests/SuggestionManagerRuntime.test.ts b/tests/SuggestionManagerRuntime.test.ts index 89c0e46b..4e7181b6 100644 --- a/tests/SuggestionManagerRuntime.test.ts +++ b/tests/SuggestionManagerRuntime.test.ts @@ -117,6 +117,11 @@ function clickManualAttachButton(button: HTMLButtonElement): void { button.dispatchEvent(new window.MouseEvent("click", { bubbles: true, cancelable: true })); } +function removeSuggestionOverlayNodes(): void { + document.querySelectorAll('[id^="ft-menu-"]').forEach((node) => node.remove()); + document.querySelectorAll(".ft-suggestion-inline").forEach((node) => node.remove()); +} + function mockRect( element: Element, rect: Pick, @@ -167,6 +172,7 @@ describe("SuggestionManagerRuntime", () => { document.body.innerHTML = ""; document.body.removeAttribute("contenteditable"); delete (document.body as { isContentEditable?: boolean }).isContentEditable; + removeSuggestionOverlayNodes(); document.documentElement.dir = ""; (globalThis as unknown as { chrome: unknown }).chrome = { runtime: { @@ -197,6 +203,7 @@ describe("SuggestionManagerRuntime", () => { (globalThis as unknown as { getComputedStyle: typeof getComputedStyle }).getComputedStyle = baseGlobals.getComputedStyle; (globalThis as unknown as { chrome: unknown }).chrome = baseGlobals.chrome; + removeSuggestionOverlayNodes(); releaseDomGlobalLock?.(); releaseDomGlobalLock = null; }); @@ -253,6 +260,17 @@ describe("SuggestionManagerRuntime", () => { expect(entry.menu.parentElement).toBe(document.documentElement); expect(document.body.querySelector(`#${entry.menu.id}`)).toBeNull(); + expect(document.body.hasAttribute("data-suggestion")).toBe(false); + expect(document.body.hasAttribute("data-tribute")).toBe(false); + expect(document.body.hasAttribute("data-ft-suggestion-id")).toBe(false); + expect(document.documentElement.getAttribute("data-suggestion")).toBe("true"); + expect(document.documentElement.getAttribute("data-tribute")).toBe("true"); + expect(document.documentElement.getAttribute("data-ft-suggestion-id")).toBe(String(entry.id)); + + runtime.detachAllHelpers(); + expect(document.documentElement.hasAttribute("data-suggestion")).toBe(false); + expect(document.documentElement.hasAttribute("data-tribute")).toBe(false); + expect(document.documentElement.hasAttribute("data-ft-suggestion-id")).toBe(false); } finally { document.body.removeAttribute("contenteditable"); if (originalBodyContentEditable) { From 97ad86ecfe96e9f3a7ee0946fd7ebe20a113c475 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Sun, 22 Mar 2026 07:11:46 +0100 Subject: [PATCH 6/6] test: harden e2e suggestion menu discovery --- tests/e2e/full.e2e.test.ts | 94 +++++++++++++++++++++++-------------- tests/e2e/smoke.e2e.test.ts | 36 +++++++++----- 2 files changed, 83 insertions(+), 47 deletions(-) diff --git a/tests/e2e/full.e2e.test.ts b/tests/e2e/full.e2e.test.ts index 7c2f277e..9f6d9313 100644 --- a/tests/e2e/full.e2e.test.ts +++ b/tests/e2e/full.e2e.test.ts @@ -1145,14 +1145,18 @@ async function waitForVisibleSuggestionTexts( element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], ), ]; - const getManagedMenus = (): Element[] => { + const getKnownMenus = (): Element[] => { const seen = new Set(); - return collectManagedElements(document) - .map((element) => element.getAttribute("data-ft-suggestion-id")) - .filter( - (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, - ) - .map((entryId) => document.getElementById(getMenuHostId(entryId))) + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] .filter((menu): menu is Element => menu instanceof Element) .filter((menu) => { if (seen.has(menu)) { @@ -1170,7 +1174,7 @@ async function waitForVisibleSuggestionTexts( : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...getManagedMenus().filter((container) => container !== activeMenu), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -1215,12 +1219,16 @@ async function hasVisibleSuggestions(page: Page): Promise { element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], ), ]; - const getManagedMenus = (): Element[] => { + const getKnownMenus = (): Element[] => { const seen = new Set(); - return collectManagedElements(document) - .map((element) => element.getAttribute("data-ft-suggestion-id")) - .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) - .map((entryId) => document.getElementById(getMenuHostId(entryId))) + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] .filter((menu): menu is Element => menu instanceof Element) .filter((menu) => { if (seen.has(menu)) { @@ -1238,7 +1246,7 @@ async function hasVisibleSuggestions(page: Page): Promise { : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...getManagedMenus().filter((container) => container !== activeMenu), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; return containers.some((container) => { const style = window.getComputedStyle(container); @@ -1277,12 +1285,18 @@ async function waitForNoVisibleSuggestions( element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], ), ]; - const getManagedMenus = (): Element[] => { + const getKnownMenus = (): Element[] => { const seen = new Set(); - return collectManagedElements(document) - .map((element) => element.getAttribute("data-ft-suggestion-id")) - .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) - .map((entryId) => document.getElementById(getMenuHostId(entryId))) + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] .filter((menu): menu is Element => menu instanceof Element) .filter((menu) => { if (seen.has(menu)) { @@ -1300,7 +1314,7 @@ async function waitForNoVisibleSuggestions( : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...getManagedMenus().filter((container) => container !== activeMenu), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; return containers.every((container) => { const style = window.getComputedStyle(container); @@ -1341,12 +1355,18 @@ async function clickFirstVisibleSuggestion( element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], ), ]; - const getManagedMenus = (): Element[] => { + const getKnownMenus = (): Element[] => { const seen = new Set(); - return collectManagedElements(document) - .map((element) => element.getAttribute("data-ft-suggestion-id")) - .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) - .map((entryId) => document.getElementById(getMenuHostId(entryId))) + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] .filter((menu): menu is Element => menu instanceof Element) .filter((menu) => { if (seen.has(menu)) { @@ -1364,7 +1384,7 @@ async function clickFirstVisibleSuggestion( : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...getManagedMenus().filter((container) => container !== activeMenu), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -2185,15 +2205,19 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], ), ]; - const getManagedMenus = (): Element[] => { + const getKnownMenus = (): Element[] => { const seen = new Set(); - return collectManagedElements(document) - .map((element) => element.getAttribute("data-ft-suggestion-id")) - .filter( - (entryId): entryId is string => - typeof entryId === "string" && entryId.length > 0, - ) - .map((entryId) => document.getElementById(getMenuHostId(entryId))) + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => + typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] .filter((menu): menu is Element => menu instanceof Element) .filter((menu) => { if (seen.has(menu)) { @@ -2214,7 +2238,7 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => { : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...getManagedMenus().filter((container) => container !== activeMenu), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; const hasVisiblePopup = containers.some((container) => { const style = window.getComputedStyle(container); diff --git a/tests/e2e/smoke.e2e.test.ts b/tests/e2e/smoke.e2e.test.ts index 0891cd10..25475c5d 100644 --- a/tests/e2e/smoke.e2e.test.ts +++ b/tests/e2e/smoke.e2e.test.ts @@ -686,12 +686,18 @@ async function waitForSuggestionTexts(page: Page): Promise { element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], ), ]; - const getManagedMenus = (): Element[] => { + const getKnownMenus = (): Element[] => { const seen = new Set(); - return collectManagedElements(document) - .map((element) => element.getAttribute("data-ft-suggestion-id")) - .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) - .map((entryId) => document.getElementById(getMenuHostId(entryId))) + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] .filter((menu): menu is Element => menu instanceof Element) .filter((menu) => { if (seen.has(menu)) { @@ -709,7 +715,7 @@ async function waitForSuggestionTexts(page: Page): Promise { : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...getManagedMenus().filter((container) => container !== activeMenu), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container); @@ -759,12 +765,18 @@ async function getVisibleSuggestionThemeSnapshot(page: Page): Promise<{ element.shadowRoot ? collectManagedElements(element.shadowRoot) : [], ), ]; - const getManagedMenus = (): Element[] => { + const getKnownMenus = (): Element[] => { const seen = new Set(); - return collectManagedElements(document) - .map((element) => element.getAttribute("data-ft-suggestion-id")) - .filter((entryId): entryId is string => typeof entryId === "string" && entryId.length > 0) - .map((entryId) => document.getElementById(getMenuHostId(entryId))) + return [ + ...collectManagedElements(document) + .map((element) => element.getAttribute("data-ft-suggestion-id")) + .filter( + (entryId): entryId is string => typeof entryId === "string" && entryId.length > 0, + ) + .map((entryId) => document.getElementById(getMenuHostId(entryId))) + .filter((menu): menu is Element => menu instanceof Element), + ...Array.from(document.querySelectorAll('[id^="ft-menu-"]')), + ] .filter((menu): menu is Element => menu instanceof Element) .filter((menu) => { if (seen.has(menu)) { @@ -782,7 +794,7 @@ async function getVisibleSuggestionThemeSnapshot(page: Page): Promise<{ : null; const containers = [ ...(activeMenu instanceof Element ? [activeMenu] : []), - ...getManagedMenus().filter((container) => container !== activeMenu), + ...getKnownMenus().filter((container) => container !== activeMenu), ]; for (const container of containers) { const style = window.getComputedStyle(container);