From 599b494a714cee69eb3c3736322fadf97cec463f Mon Sep 17 00:00:00 2001 From: huyua9 Date: Wed, 5 Aug 2026 15:55:36 +0800 Subject: [PATCH 01/12] feat(core,page-controller): gate hover_element_by_index behind experimentalPointerActions Implement the maintainer's preferred scope for #222: expose a single experimental pointer-based action (hover) only when experimentalPointerActions: true is set on both PageControllerConfig and AgentConfig. Disabled by default so the tool surface stays minimal until real usage shows the value. Mirrors the existing experimentalScriptExecutionTool pattern: - PageController.hoverElement(index) returns an explanatory failure when the flag is off, and dispatches pointerover/pointerenter + mouseover/mouseenter otherwise. - PageAgentCore deletes hover_element_by_index from this.tools at construction unless experimentalPointerActions is true. Extension bridge is intentionally untouched. Tests cover tool gating in PageAgentCore and the hoverElement method behavior in PageController. --- packages/core/src/PageAgentCore.test.ts | 22 ++++++++++ packages/core/src/PageAgentCore.ts | 4 ++ packages/core/src/tools/index.ts | 16 +++++++ packages/core/src/types.ts | 12 ++++++ .../src/PageController.test.ts | 33 +++++++++++++++ .../page-controller/src/PageController.ts | 42 +++++++++++++++++++ packages/page-controller/src/actions.ts | 32 ++++++++++++++ 7 files changed, 161 insertions(+) diff --git a/packages/core/src/PageAgentCore.test.ts b/packages/core/src/PageAgentCore.test.ts index 5dc801038..0bd87f26b 100644 --- a/packages/core/src/PageAgentCore.test.ts +++ b/packages/core/src/PageAgentCore.test.ts @@ -363,3 +363,25 @@ describe.concurrent('PageAgentCore lifecycle', () => { }) }) }) + +describe.concurrent('experimental tool gating', () => { + it('omits hover_element_by_index by default', () => { + const agent = createAgent(createFetchMock()) + expect(agent.tools.has('hover_element_by_index')).toBe(false) + }) + + it('keeps hover_element_by_index available when experimentalPointerActions is true', () => { + const agent = createAgent(createFetchMock(), { experimentalPointerActions: true }) + expect(agent.tools.has('hover_element_by_index')).toBe(true) + }) + + it('removes execute_javascript when experimentalScriptExecutionTool is false', () => { + const agent = createAgent(createFetchMock()) + expect(agent.tools.has('execute_javascript')).toBe(false) + }) + + it('keeps execute_javascript when experimentalScriptExecutionTool is true', () => { + const agent = createAgent(createFetchMock(), { experimentalScriptExecutionTool: true }) + expect(agent.tools.has('execute_javascript')).toBe(true) + }) +}) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index c3dba4c41..93ad65b82 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -144,6 +144,10 @@ export class PageAgentCore extends EventTarget { if (!this.config.experimentalScriptExecutionTool) { this.tools.delete('execute_javascript') } + + if (!this.config.experimentalPointerActions) { + this.tools.delete('hover_element_by_index') + } } /** Get current agent status */ diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 016936041..3c3a8d58b 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -135,6 +135,22 @@ tools.set( }) ) +tools.set( + 'hover_element_by_index', + tool({ + // @experimental Tool surface gated by `experimentalPointerActions` in PageAgentCore. + description: + 'Hover over element by index. Use this to reveal dropdown menus or hidden submenus before clicking on their items. Requires the `experimentalPointerActions` flag to be enabled.', + inputSchema: z.object({ + index: z.int().min(0), + }), + execute: async function (this: PageAgentCore, input) { + const result = await this.pageController.hoverElement(input.index) + return result.message + }, + }) +) + /** * @note Reference from browser-use */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a90717f0e..6319fa054 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -131,6 +131,18 @@ export interface AgentConfig extends LLMConfig { */ experimentalLlmsTxt?: boolean + /** + * @experimental + * Enable experimental pointer-based tools (currently only `hover_element_by_index`). + * Disabled by default — keep tool surface minimal until real usage shows the value. + * Also pass `experimentalPointerActions: true` to the underlying `PageControllerConfig` + * to actually allow the action to run; mismatched flags will cause the tool to + * fail with an explanatory message. + * @see https://github.com/alibaba/page-agent/issues/222 + * @default false + */ + experimentalPointerActions?: boolean + /** * Transform page content before sending to LLM. * Called after DOM extraction and simplification, before LLM invocation. diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index 122d6ecc2..38f1d4449 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -37,4 +37,37 @@ describe('PageController', () => { expect(result.message).toContain('❌') }) }) + + describe('hoverElement (experimental)', () => { + it('returns a disabled failure when experimentalPointerActions is not set', async () => { + const controller = new PageController() + const result = await controller.hoverElement(0) + expect(result.success).toBe(false) + expect(result.message).toContain('experimentalPointerActions') + }) + + it('dispatches hover events when experimentalPointerActions is enabled', async () => { + document.body.innerHTML = '' + const target = document.querySelector('#target')! + + const seen: string[] = [] + for (const evt of ['pointerover', 'pointerenter', 'mouseover', 'mouseenter']) { + target.addEventListener(evt, () => seen.push(evt)) + } + + // Stub index 0 -> our target. + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: target, + }) + + const result = await controller.hoverElement(0) + expect(result.success).toBe(true) + expect(result.message).toContain('Hovered over element') + expect(seen).toEqual( + expect.arrayContaining(['pointerover', 'pointerenter', 'mouseover', 'mouseenter']) + ) + }) + }) }) diff --git a/packages/page-controller/src/PageController.ts b/packages/page-controller/src/PageController.ts index ad9352853..7beb71de0 100644 --- a/packages/page-controller/src/PageController.ts +++ b/packages/page-controller/src/PageController.ts @@ -9,6 +9,7 @@ import { clickElement, getElementByIndex, + hoverElement, inputTextElement, scrollHorizontally, scrollVertically, @@ -26,6 +27,14 @@ import { isAnchorElement } from './utils' export interface PageControllerConfig extends dom.DomConfig { /** Enable visual mask overlay during operations (default: false) */ enableMask?: boolean + + /** + * Enable experimental pointer-based actions (currently only hover). + * @experimental Disabled by default. Tool registration in `PageAgentCore` is gated on + * this flag — see `AgentConfig.experimentalPointerActions`. + * @default false + */ + experimentalPointerActions?: boolean } /** @@ -312,6 +321,39 @@ export class PageController extends EventTarget { } } + /** + * Hover over element by index (reveal dropdown menus / hidden submenus before clicking). + * Requires `experimentalPointerActions: true` in PageControllerConfig — the matching + * `hover_element_by_index` tool is gated on `AgentConfig.experimentalPointerActions`. + * @experimental + */ + async hoverElement(index: number): Promise { + if (!this.config.experimentalPointerActions) { + return { + success: false, + message: + '❌ hoverElement is disabled. Set `experimentalPointerActions: true` in PageControllerConfig to enable it.', + } + } + + try { + this.assertIndexed() + const element = getElementByIndex(this.selectorMap, index) + const elemText = this.elementTextMap.get(index) + await hoverElement(element) + + return { + success: true, + message: `✅ Hovered over element (${elemText ?? index}).`, + } + } catch (error) { + return { + success: false, + message: `❌ Failed to hover over element: ${error}`, + } + } + } + /** * Scroll vertically */ diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index e90326eb3..a501aab1b 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -41,6 +41,38 @@ export function getElementByIndex( return element } +/** + * @experimental Pointer hover — opt-in via PageControllerConfig.experimentalPointerActions. + * Move the pointer over the element and dispatch hover-related events without clicking + * or focusing it. Intended for revealing dropdown menus / hidden submenus before + * clicking on their items. + * @private Internal method, subject to change at any time. + */ +export async function hoverElement(element: HTMLElement) { + await scrollIntoViewIfNeeded(element) + + const rect = element.getBoundingClientRect() + const x = rect.left + rect.width / 2 + const y = rect.top + rect.height / 2 + + await movePointerToElement(element, x, y) + + const pointerOpts = { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + pointerType: 'mouse', + } + const mouseOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y } + + // Hover — pointer events first, then mouse events (spec order) + element.dispatchEvent(new PointerEvent('pointerover', pointerOpts)) + element.dispatchEvent(new PointerEvent('pointerenter', { ...pointerOpts, bubbles: false })) + element.dispatchEvent(new MouseEvent('mouseover', mouseOpts)) + element.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false })) +} + let lastClickedElement: HTMLElement | null = null function blurLastClickedElement() { From 52998961f54977d67d54cf9f93eabc39fac65a4f Mon Sep 17 00:00:00 2001 From: huyua9 Date: Wed, 5 Aug 2026 16:59:30 +0800 Subject: [PATCH 02/12] fix: address hover tool review feedback --- packages/core/src/PageAgentCore.test.ts | 12 +++++++++ packages/core/src/PageAgentCore.ts | 15 +++++------ packages/core/src/tools/index.ts | 2 +- .../src/PageController.test.ts | 25 ++++++++++++++++++- .../page-controller/src/PageController.ts | 9 +++++-- packages/page-controller/src/actions.ts | 15 ++++++++--- 6 files changed, 63 insertions(+), 15 deletions(-) diff --git a/packages/core/src/PageAgentCore.test.ts b/packages/core/src/PageAgentCore.test.ts index 0bd87f26b..8007c90c8 100644 --- a/packages/core/src/PageAgentCore.test.ts +++ b/packages/core/src/PageAgentCore.test.ts @@ -375,6 +375,18 @@ describe.concurrent('experimental tool gating', () => { expect(agent.tools.has('hover_element_by_index')).toBe(true) }) + it('preserves a caller-provided hover tool when the built-in gate is disabled', () => { + const customHover = tool({ + description: 'caller-provided hover tool', + inputSchema: z.object({}), + execute: async () => 'custom hover', + }) + const agent = createAgent(createFetchMock(), { + customTools: { hover_element_by_index: customHover }, + }) + expect(agent.tools.get('hover_element_by_index')).toBe(customHover) + }) + it('removes execute_javascript when experimentalScriptExecutionTool is false', () => { const agent = createAgent(createFetchMock()) expect(agent.tools.has('execute_javascript')).toBe(false) diff --git a/packages/core/src/PageAgentCore.ts b/packages/core/src/PageAgentCore.ts index 93ad65b82..ebd7a8f4d 100644 --- a/packages/core/src/PageAgentCore.ts +++ b/packages/core/src/PageAgentCore.ts @@ -114,6 +114,14 @@ export class PageAgentCore extends EventTarget { this.tools = new Map(tools) this.pageController = config.pageController + if (!this.config.experimentalScriptExecutionTool) { + this.tools.delete('execute_javascript') + } + + if (!this.config.experimentalPointerActions) { + this.tools.delete('hover_element_by_index') + } + this.#llm.addEventListener('retry', (e) => { const { attempt, maxAttempts, lastError } = (e as CustomEvent).detail this.#emitActivity({ type: 'retrying', attempt, maxAttempts }) @@ -141,13 +149,6 @@ export class PageAgentCore extends EventTarget { } } - if (!this.config.experimentalScriptExecutionTool) { - this.tools.delete('execute_javascript') - } - - if (!this.config.experimentalPointerActions) { - this.tools.delete('hover_element_by_index') - } } /** Get current agent status */ diff --git a/packages/core/src/tools/index.ts b/packages/core/src/tools/index.ts index 3c3a8d58b..c8aa29e13 100644 --- a/packages/core/src/tools/index.ts +++ b/packages/core/src/tools/index.ts @@ -140,7 +140,7 @@ tools.set( tool({ // @experimental Tool surface gated by `experimentalPointerActions` in PageAgentCore. description: - 'Hover over element by index. Use this to reveal dropdown menus or hidden submenus before clicking on their items. Requires the `experimentalPointerActions` flag to be enabled.', + 'Dispatch synthetic pointer/mouse hover events to an element by index for JavaScript hover handlers. This does not activate CSS :hover. Requires the `experimentalPointerActions` flag to be enabled.', inputSchema: z.object({ index: z.int().min(0), }), diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index 38f1d4449..f0c7be94f 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -64,10 +64,33 @@ describe('PageController', () => { const result = await controller.hoverElement(0) expect(result.success).toBe(true) - expect(result.message).toContain('Hovered over element') + expect(result.message).toContain('Dispatched synthetic hover events') expect(seen).toEqual( expect.arrayContaining(['pointerover', 'pointerenter', 'mouseover', 'mouseenter']) ) }) + + it('dispatches leave events when moving synthetic hover to another element', async () => { + document.body.innerHTML = '' + const first = document.querySelector('#first')! + const second = document.querySelector('#second')! + const left: string[] = [] + for (const evt of ['pointerout', 'pointerleave', 'mouseout', 'mouseleave']) { + first.addEventListener(evt, () => left.push(evt)) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: first, + }) + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(1, { + ref: second, + }) + + await controller.hoverElement(0) + await controller.hoverElement(1) + expect(left).toEqual(['pointerout', 'mouseout', 'pointerleave', 'mouseleave']) + }) }) }) diff --git a/packages/page-controller/src/PageController.ts b/packages/page-controller/src/PageController.ts index 7beb71de0..daac4a2e5 100644 --- a/packages/page-controller/src/PageController.ts +++ b/packages/page-controller/src/PageController.ts @@ -91,6 +91,9 @@ export class PageController extends EventTarget { /** Whether the tree has been indexed at least once */ private isIndexed = false + /** Last element that received synthetic hover events in this controller. */ + private lastHoveredElement: HTMLElement | null = null + /** Visual mask overlay for blocking user interaction during automation */ private mask: InstanceType | null = null private maskReady: Promise | null = null @@ -340,11 +343,12 @@ export class PageController extends EventTarget { this.assertIndexed() const element = getElementByIndex(this.selectorMap, index) const elemText = this.elementTextMap.get(index) - await hoverElement(element) + await hoverElement(element, this.lastHoveredElement) + this.lastHoveredElement = element return { success: true, - message: `✅ Hovered over element (${elemText ?? index}).`, + message: `✅ Dispatched synthetic hover events to element (${elemText ?? index}). CSS :hover is not activated.`, } } catch (error) { return { @@ -469,6 +473,7 @@ export class PageController extends EventTarget { this.elementTextMap.clear() this.simplifiedHTML = '' this.isIndexed = false + this.lastHoveredElement = null this.mask?.dispose() this.mask = null } diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index a501aab1b..bae5f2649 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -43,12 +43,19 @@ export function getElementByIndex( /** * @experimental Pointer hover — opt-in via PageControllerConfig.experimentalPointerActions. - * Move the pointer over the element and dispatch hover-related events without clicking - * or focusing it. Intended for revealing dropdown menus / hidden submenus before - * clicking on their items. + * Dispatch synthetic hover-related events without clicking or focusing the element. + * This can invoke JavaScript hover handlers, but does not activate the browser's + * CSS `:hover` state. * @private Internal method, subject to change at any time. */ -export async function hoverElement(element: HTMLElement) { +export async function hoverElement(element: HTMLElement, previousElement?: HTMLElement | null) { + if (previousElement && previousElement !== element) { + previousElement.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })) + previousElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })) + previousElement.dispatchEvent(new PointerEvent('pointerleave', { bubbles: false })) + previousElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })) + } + await scrollIntoViewIfNeeded(element) const rect = element.getBoundingClientRect() From e7cb8ba37db15c72e239b2f674a457b49ef6375d Mon Sep 17 00:00:00 2001 From: huyua9 Date: Wed, 5 Aug 2026 17:09:15 +0800 Subject: [PATCH 03/12] fix: preserve nested synthetic hover transitions --- .../src/PageController.test.ts | 32 ++++++++++++++++++- packages/page-controller/src/actions.ts | 32 ++++++++++++++++--- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index f0c7be94f..67f71fa9c 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -71,7 +71,8 @@ describe('PageController', () => { }) it('dispatches leave events when moving synthetic hover to another element', async () => { - document.body.innerHTML = '' + document.body.innerHTML = + '' const first = document.querySelector('#first')! const second = document.querySelector('#second')! const left: string[] = [] @@ -92,5 +93,34 @@ describe('PageController', () => { await controller.hoverElement(1) expect(left).toEqual(['pointerout', 'mouseout', 'pointerleave', 'mouseleave']) }) + + it('preserves hover when moving from a menu trigger into its descendant', async () => { + document.body.innerHTML = '
Open
Item
' + const trigger = document.querySelector('#trigger')! + const item = document.querySelector('#item')! + const events: Array<{ type: string; relatedTarget: EventTarget | null }> = [] + for (const evt of ['pointerout', 'pointerleave', 'mouseout', 'mouseleave']) { + trigger.addEventListener(evt, (event) => + events.push({ type: event.type, relatedTarget: event.relatedTarget }) + ) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: trigger, + }) + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(1, { + ref: item, + }) + + await controller.hoverElement(0) + await controller.hoverElement(1) + + expect(events).toEqual([ + { type: 'pointerout', relatedTarget: item }, + { type: 'mouseout', relatedTarget: item }, + ]) + }) }) }) diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index bae5f2649..f364e7092 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -50,10 +50,25 @@ export function getElementByIndex( */ export async function hoverElement(element: HTMLElement, previousElement?: HTMLElement | null) { if (previousElement && previousElement !== element) { - previousElement.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })) - previousElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })) - previousElement.dispatchEvent(new PointerEvent('pointerleave', { bubbles: false })) - previousElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })) + const movingWithinPreviousElement = previousElement.contains(element) + previousElement.dispatchEvent( + new PointerEvent('pointerout', { bubbles: true, relatedTarget: element }) + ) + previousElement.dispatchEvent( + new MouseEvent('mouseout', { bubbles: true, relatedTarget: element }) + ) + + // Native pointer/mouse leave events do not fire when moving from an element + // into one of its descendants. Keeping relatedTarget on the bubbling out + // events also lets React derive the same containment check for onMouseLeave. + if (!movingWithinPreviousElement) { + previousElement.dispatchEvent( + new PointerEvent('pointerleave', { bubbles: false, relatedTarget: element }) + ) + previousElement.dispatchEvent( + new MouseEvent('mouseleave', { bubbles: false, relatedTarget: element }) + ) + } } await scrollIntoViewIfNeeded(element) @@ -70,8 +85,15 @@ export async function hoverElement(element: HTMLElement, previousElement?: HTMLE clientX: x, clientY: y, pointerType: 'mouse', + relatedTarget: previousElement ?? null, + } + const mouseOpts = { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + relatedTarget: previousElement ?? null, } - const mouseOpts = { bubbles: true, cancelable: true, clientX: x, clientY: y } // Hover — pointer events first, then mouse events (spec order) element.dispatchEvent(new PointerEvent('pointerover', pointerOpts)) From dd3281ee7b5969cad71b5353c70575e14afc086d Mon Sep 17 00:00:00 2001 From: huyua9 Date: Wed, 5 Aug 2026 17:21:00 +0800 Subject: [PATCH 04/12] fix: route experimental hover through extension controller --- .../extension/src/agent/MultiPageAgent.ts | 4 +++- .../agent/RemotePageController.background.ts | 9 ++++++- .../src/agent/RemotePageController.content.ts | 24 ++++++++++++------- .../src/agent/RemotePageController.ts | 17 ++++++++++++- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/extension/src/agent/MultiPageAgent.ts b/packages/extension/src/agent/MultiPageAgent.ts index fec72cfc1..c2d0cf5df 100644 --- a/packages/extension/src/agent/MultiPageAgent.ts +++ b/packages/extension/src/agent/MultiPageAgent.ts @@ -25,7 +25,9 @@ export class MultiPageAgent extends PageAgentCore { constructor(config: MultiPageAgentConfig) { // multi page controller const tabsController = new TabsController() - const pageController = new RemotePageController(tabsController) + const pageController = new RemotePageController(tabsController, { + experimentalPointerActions: config.experimentalPointerActions, + }) const customTools = createTabTools(tabsController) // system prompt - auto-detect language if not specified diff --git a/packages/extension/src/agent/RemotePageController.background.ts b/packages/extension/src/agent/RemotePageController.background.ts index 8fb89aedb..7c877dfbf 100644 --- a/packages/extension/src/agent/RemotePageController.background.ts +++ b/packages/extension/src/agent/RemotePageController.background.ts @@ -4,7 +4,13 @@ */ export function handlePageControlMessage( - message: { type: 'PAGE_CONTROL'; action: string; payload: any; targetTabId: number }, + message: { + type: 'PAGE_CONTROL' + action: string + payload: any + targetTabId: number + experimentalPointerActions?: boolean + }, sender: chrome.runtime.MessageSender, sendResponse: (response: unknown) => void ): true | undefined { @@ -26,6 +32,7 @@ export function handlePageControlMessage( type: 'PAGE_CONTROL', action, payload, + experimentalPointerActions: message.experimentalPointerActions, }) .then((result) => { sendResponse(result) diff --git a/packages/extension/src/agent/RemotePageController.content.ts b/packages/extension/src/agent/RemotePageController.content.ts index 55eff28b0..ef948cfc8 100644 --- a/packages/extension/src/agent/RemotePageController.content.ts +++ b/packages/extension/src/agent/RemotePageController.content.ts @@ -1,11 +1,16 @@ /** * content script for RemotePageController */ -import { PageController } from '@page-agent/page-controller' +import { PageController, type PageControllerConfig } from '@page-agent/page-controller' export function initPageController() { let pageController: PageController | null = null let intervalID: number | null = null + const pageControllerConfig: PageControllerConfig = { + enableMask: false, + viewportExpansion: 400, + experimentalPointerActions: false, + } const myTabIdPromise = chrome.runtime .sendMessage({ type: 'PAGE_CONTROL', action: 'get_my_tab_id' }) @@ -17,12 +22,12 @@ export function initPageController() { return null }) - function getPC(): PageController { + function getPC(experimentalPointerActions = false): PageController { + // PageController retains this config object, so a later PAGE_CONTROL message + // can enable the experimental action without losing the indexed tree. + pageControllerConfig.experimentalPointerActions ||= experimentalPointerActions if (!pageController) { - pageController = new PageController({ - enableMask: false, - viewportExpansion: 400, - }) + pageController = new PageController(pageControllerConfig) } return pageController } @@ -66,10 +71,10 @@ export function initPageController() { return } - const { action, payload } = message + const { action, payload, experimentalPointerActions } = message const methodName = getMethodName(action) - const pc = getPC() as any + const pc = getPC(experimentalPointerActions === true) as any switch (action) { case 'get_last_update_time': @@ -81,6 +86,7 @@ export function initPageController() { case 'select_option': case 'scroll': case 'scroll_horizontally': + case 'hover_element_by_index': case 'execute_javascript': pc[methodName](...(payload || [])) .then((result: any) => sendResponse(result)) @@ -126,6 +132,8 @@ function getMethodName(action: string): string { return 'scroll' as const case 'scroll_horizontally': return 'scrollHorizontally' as const + case 'hover_element_by_index': + return 'hoverElement' as const case 'execute_javascript': return 'executeJavascript' as const diff --git a/packages/extension/src/agent/RemotePageController.ts b/packages/extension/src/agent/RemotePageController.ts index 580f9c20d..b3eeb3439 100644 --- a/packages/extension/src/agent/RemotePageController.ts +++ b/packages/extension/src/agent/RemotePageController.ts @@ -11,6 +11,7 @@ function sendMessage(message: { action: string targetTabId: number payload?: any + experimentalPointerActions?: boolean }): Promise { return chrome.runtime.sendMessage(message).catch((error) => { console.error(PREFIX, message.action, error) @@ -25,9 +26,14 @@ function sendMessage(message: { */ export class RemotePageController { tabsController: TabsController + private experimentalPointerActions: boolean - constructor(tabsController: TabsController) { + constructor( + tabsController: TabsController, + config: { experimentalPointerActions?: boolean } = {} + ) { this.tabsController = tabsController + this.experimentalPointerActions = config.experimentalPointerActions === true } get currentTabId(): number | null { @@ -52,6 +58,7 @@ export class RemotePageController { type: 'PAGE_CONTROL', action: 'get_last_update_time', targetTabId: this.currentTabId, + experimentalPointerActions: this.experimentalPointerActions, }) } @@ -75,6 +82,7 @@ export class RemotePageController { type: 'PAGE_CONTROL', action: 'get_browser_state', targetTabId: this.currentTabId, + experimentalPointerActions: this.experimentalPointerActions, }) } @@ -95,6 +103,7 @@ export class RemotePageController { type: 'PAGE_CONTROL', action: 'update_tree', targetTabId: this.currentTabId, + experimentalPointerActions: this.experimentalPointerActions, }) } @@ -107,6 +116,7 @@ export class RemotePageController { type: 'PAGE_CONTROL', action: 'clean_up_highlights', targetTabId: this.currentTabId, + experimentalPointerActions: this.experimentalPointerActions, }) } @@ -133,6 +143,10 @@ export class RemotePageController { return this.remoteCallDomAction('scroll_horizontally', args) } + async hoverElement(...args: any[]): Promise { + return this.remoteCallDomAction('hover_element_by_index', args) + } + // `execute_javascript` is intentionally not implemented: AbortSignal cannot cross context /** @note Managed by content script via storage polling. */ @@ -160,6 +174,7 @@ export class RemotePageController { action: action, targetTabId: this.currentTabId!, payload, + experimentalPointerActions: this.experimentalPointerActions, }) } } From f634c55e6175cb61064ab863f3c449cadb3ce376 Mon Sep 17 00:00:00 2001 From: huyua9 Date: Wed, 5 Aug 2026 17:30:35 +0800 Subject: [PATCH 05/12] fix: clear stale hover state before pointer actions --- .../src/agent/RemotePageController.content.ts | 2 +- .../src/PageController.test.ts | 26 +++++++++++ .../page-controller/src/PageController.ts | 13 ++++++ packages/page-controller/src/actions.ts | 43 +++++++++++-------- 4 files changed, 64 insertions(+), 20 deletions(-) diff --git a/packages/extension/src/agent/RemotePageController.content.ts b/packages/extension/src/agent/RemotePageController.content.ts index ef948cfc8..5fdd97c13 100644 --- a/packages/extension/src/agent/RemotePageController.content.ts +++ b/packages/extension/src/agent/RemotePageController.content.ts @@ -25,7 +25,7 @@ export function initPageController() { function getPC(experimentalPointerActions = false): PageController { // PageController retains this config object, so a later PAGE_CONTROL message // can enable the experimental action without losing the indexed tree. - pageControllerConfig.experimentalPointerActions ||= experimentalPointerActions + pageControllerConfig.experimentalPointerActions = experimentalPointerActions if (!pageController) { pageController = new PageController(pageControllerConfig) } diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index 67f71fa9c..6c9043739 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -122,5 +122,31 @@ describe('PageController', () => { { type: 'mouseout', relatedTarget: item }, ]) }) + + it('clears synthetic hover before clicking outside the hovered subtree', async () => { + document.body.innerHTML = + '' + const first = document.querySelector('#first')! + const second = document.querySelector('#second')! + const left: string[] = [] + for (const evt of ['pointerout', 'pointerleave', 'mouseout', 'mouseleave']) { + first.addEventListener(evt, () => left.push(evt)) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: first, + }) + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(1, { + ref: second, + }) + + await controller.hoverElement(0) + const result = await controller.clickElement(1) + + expect(result.success).toBe(true) + expect(left).toEqual(['pointerout', 'mouseout', 'pointerleave', 'mouseleave']) + }) }) }) diff --git a/packages/page-controller/src/PageController.ts b/packages/page-controller/src/PageController.ts index daac4a2e5..4ba8f9925 100644 --- a/packages/page-controller/src/PageController.ts +++ b/packages/page-controller/src/PageController.ts @@ -8,6 +8,7 @@ */ import { clickElement, + dispatchHoverLeave, getElementByIndex, hoverElement, inputTextElement, @@ -250,6 +251,15 @@ export class PageController extends EventTarget { } } + /** Clear synthetic hover state before a pointer action targets another subtree. */ + private clearSyntheticHover(nextElement: HTMLElement): void { + if (!this.lastHoveredElement || this.lastHoveredElement === nextElement) return + if (!this.lastHoveredElement.contains(nextElement)) { + dispatchHoverLeave(this.lastHoveredElement, nextElement) + this.lastHoveredElement = null + } + } + /** * Click element by index */ @@ -258,6 +268,7 @@ export class PageController extends EventTarget { this.assertIndexed() const element = getElementByIndex(this.selectorMap, index) const elemText = this.elementTextMap.get(index) + this.clearSyntheticHover(element) await clickElement(element) // Handle links that open in new tabs @@ -288,6 +299,7 @@ export class PageController extends EventTarget { this.assertIndexed() const element = getElementByIndex(this.selectorMap, index) const elemText = this.elementTextMap.get(index) + this.clearSyntheticHover(element) await inputTextElement(element, text) return { @@ -310,6 +322,7 @@ export class PageController extends EventTarget { this.assertIndexed() const element = getElementByIndex(this.selectorMap, index) const elemText = this.elementTextMap.get(index) + this.clearSyntheticHover(element) await selectOptionElement(element as HTMLSelectElement, optionText) return { diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index f364e7092..fca987620 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -50,25 +50,7 @@ export function getElementByIndex( */ export async function hoverElement(element: HTMLElement, previousElement?: HTMLElement | null) { if (previousElement && previousElement !== element) { - const movingWithinPreviousElement = previousElement.contains(element) - previousElement.dispatchEvent( - new PointerEvent('pointerout', { bubbles: true, relatedTarget: element }) - ) - previousElement.dispatchEvent( - new MouseEvent('mouseout', { bubbles: true, relatedTarget: element }) - ) - - // Native pointer/mouse leave events do not fire when moving from an element - // into one of its descendants. Keeping relatedTarget on the bubbling out - // events also lets React derive the same containment check for onMouseLeave. - if (!movingWithinPreviousElement) { - previousElement.dispatchEvent( - new PointerEvent('pointerleave', { bubbles: false, relatedTarget: element }) - ) - previousElement.dispatchEvent( - new MouseEvent('mouseleave', { bubbles: false, relatedTarget: element }) - ) - } + dispatchHoverLeave(previousElement, element) } await scrollIntoViewIfNeeded(element) @@ -102,6 +84,29 @@ export async function hoverElement(element: HTMLElement, previousElement?: HTMLE element.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false })) } +/** + * Dispatch the leave half of a synthetic pointer transition. Native leave + * events do not fire when moving into a descendant, while bubbling out events + * retain relatedTarget so delegated handlers can perform the same check. + */ +export function dispatchHoverLeave( + previousElement: HTMLElement, + relatedTarget: HTMLElement | null +) { + const movingWithinPreviousElement = relatedTarget + ? previousElement.contains(relatedTarget) + : false + previousElement.dispatchEvent(new PointerEvent('pointerout', { bubbles: true, relatedTarget })) + previousElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget })) + + if (!movingWithinPreviousElement) { + previousElement.dispatchEvent( + new PointerEvent('pointerleave', { bubbles: false, relatedTarget }) + ) + previousElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false, relatedTarget })) + } +} + let lastClickedElement: HTMLElement | null = null function blurLastClickedElement() { From bb78116e723225d036beccd0443cea4cb7582649 Mon Sep 17 00:00:00 2001 From: huyua9 Date: Wed, 5 Aug 2026 17:50:07 +0800 Subject: [PATCH 06/12] fix: preserve synthetic hover ancestry --- .../src/PageController.test.ts | 33 +++++++++++++++++++ .../page-controller/src/PageController.ts | 19 ++++++----- packages/page-controller/src/actions.ts | 16 ++++++++- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index 6c9043739..5d835c002 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -148,5 +148,38 @@ describe('PageController', () => { expect(result.success).toBe(true) expect(left).toEqual(['pointerout', 'mouseout', 'pointerleave', 'mouseleave']) }) + + it('clears the full hover ancestry after entering a descendant', async () => { + document.body.innerHTML = + '' + const menu = document.querySelector('#menu')! + const item = document.querySelector('#item')! + const outside = document.querySelector('#outside')! + const menuLeaves: string[] = [] + const itemLeaves: string[] = [] + for (const evt of ['pointerleave', 'mouseleave']) { + menu.addEventListener(evt, () => menuLeaves.push(evt)) + item.addEventListener(evt, () => itemLeaves.push(evt)) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: menu, + }) + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(1, { + ref: item, + }) + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(2, { + ref: outside, + }) + + await controller.hoverElement(0) + await controller.hoverElement(1) + await controller.clickElement(2) + + expect(itemLeaves).toEqual(['pointerleave', 'mouseleave']) + expect(menuLeaves).toEqual(['pointerleave', 'mouseleave']) + }) }) }) diff --git a/packages/page-controller/src/PageController.ts b/packages/page-controller/src/PageController.ts index 4ba8f9925..934ac40f5 100644 --- a/packages/page-controller/src/PageController.ts +++ b/packages/page-controller/src/PageController.ts @@ -92,8 +92,8 @@ export class PageController extends EventTarget { /** Whether the tree has been indexed at least once */ private isIndexed = false - /** Last element that received synthetic hover events in this controller. */ - private lastHoveredElement: HTMLElement | null = null + /** Elements that currently represent the synthetic hover ancestry, outermost first. */ + private syntheticHoverPath: HTMLElement[] = [] /** Visual mask overlay for blocking user interaction during automation */ private mask: InstanceType | null = null @@ -253,11 +253,11 @@ export class PageController extends EventTarget { /** Clear synthetic hover state before a pointer action targets another subtree. */ private clearSyntheticHover(nextElement: HTMLElement): void { - if (!this.lastHoveredElement || this.lastHoveredElement === nextElement) return - if (!this.lastHoveredElement.contains(nextElement)) { - dispatchHoverLeave(this.lastHoveredElement, nextElement) - this.lastHoveredElement = null + const retained = this.syntheticHoverPath.filter((element) => element.contains(nextElement)) + for (const element of [...this.syntheticHoverPath].reverse()) { + if (!element.contains(nextElement)) dispatchHoverLeave(element, nextElement) } + this.syntheticHoverPath = retained } /** @@ -356,8 +356,9 @@ export class PageController extends EventTarget { this.assertIndexed() const element = getElementByIndex(this.selectorMap, index) const elemText = this.elementTextMap.get(index) - await hoverElement(element, this.lastHoveredElement) - this.lastHoveredElement = element + await hoverElement(element, this.syntheticHoverPath) + const retained = this.syntheticHoverPath.filter((hovered) => hovered.contains(element)) + this.syntheticHoverPath = retained.includes(element) ? retained : [...retained, element] return { success: true, @@ -486,7 +487,7 @@ export class PageController extends EventTarget { this.elementTextMap.clear() this.simplifiedHTML = '' this.isIndexed = false - this.lastHoveredElement = null + this.syntheticHoverPath = [] this.mask?.dispose() this.mask = null } diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index fca987620..a0cf99768 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -48,9 +48,23 @@ export function getElementByIndex( * CSS `:hover` state. * @private Internal method, subject to change at any time. */ -export async function hoverElement(element: HTMLElement, previousElement?: HTMLElement | null) { + +export async function hoverElement( + element: HTMLElement, + previousElementOrPath?: HTMLElement | null | readonly HTMLElement[] +) { + const previousPath = Array.isArray(previousElementOrPath) + ? previousElementOrPath + : previousElementOrPath + ? [previousElementOrPath] + : [] + const previousElement = previousPath.at(-1) + if (previousElement && previousElement !== element) { dispatchHoverLeave(previousElement, element) + for (const ancestor of [...previousPath.slice(0, -1)].reverse()) { + if (!ancestor.contains(element)) dispatchHoverLeave(ancestor, element) + } } await scrollIntoViewIfNeeded(element) From 602c4f34d6c2a3a5b2fd6ffe86553bf967aa404b Mon Sep 17 00:00:00 2001 From: huyua9 Date: Wed, 5 Aug 2026 21:47:26 +0800 Subject: [PATCH 07/12] fix: dispatch hover enters to ancestors --- .../src/PageController.test.ts | 20 +++++++++++++++++++ packages/page-controller/src/actions.ts | 14 +++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index 5d835c002..f5190a0aa 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -70,6 +70,26 @@ describe('PageController', () => { ) }) + it('dispatches enter events on ancestors when hovering a descendant directly', async () => { + document.body.innerHTML = '' + const menu = document.querySelector('#menu')! + const item = document.querySelector('#item')! + const entered: string[] = [] + for (const evt of ['pointerenter', 'mouseenter']) { + menu.addEventListener(evt, () => entered.push(evt)) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: item, + }) + + await controller.hoverElement(0) + + expect(entered).toEqual(['pointerenter', 'mouseenter']) + }) + it('dispatches leave events when moving synthetic hover to another element', async () => { document.body.innerHTML = '' diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index a0cf99768..affd805f1 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -59,6 +59,10 @@ export async function hoverElement( ? [previousElementOrPath] : [] const previousElement = previousPath.at(-1) + const ancestorPath: HTMLElement[] = [] + for (let ancestor: HTMLElement | null = element; ancestor; ancestor = ancestor.parentElement) { + ancestorPath.unshift(ancestor) + } if (previousElement && previousElement !== element) { dispatchHoverLeave(previousElement, element) @@ -93,8 +97,18 @@ export async function hoverElement( // Hover — pointer events first, then mouse events (spec order) element.dispatchEvent(new PointerEvent('pointerover', pointerOpts)) + for (const ancestor of ancestorPath) { + if (ancestor !== element && !previousPath.includes(ancestor)) { + ancestor.dispatchEvent(new PointerEvent('pointerenter', { ...pointerOpts, bubbles: false })) + } + } element.dispatchEvent(new PointerEvent('pointerenter', { ...pointerOpts, bubbles: false })) element.dispatchEvent(new MouseEvent('mouseover', mouseOpts)) + for (const ancestor of ancestorPath) { + if (ancestor !== element && !previousPath.includes(ancestor)) { + ancestor.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false })) + } + } element.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false })) } From 739cf43b162e11a7c3b072518f51168e50a8ae64 Mon Sep 17 00:00:00 2001 From: huyua9 Date: Thu, 6 Aug 2026 00:33:04 +0800 Subject: [PATCH 08/12] fix: retain synthetic hover ancestor path --- .../src/PageController.test.ts | 27 +++++++++++++++++++ .../page-controller/src/PageController.ts | 4 +-- packages/page-controller/src/actions.ts | 4 ++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index f5190a0aa..7194e460c 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -90,6 +90,33 @@ describe('PageController', () => { expect(entered).toEqual(['pointerenter', 'mouseenter']) }) + it('clears ancestor hover after hovering a descendant directly', async () => { + document.body.innerHTML = + '' + const menu = document.querySelector('#menu')! + const item = document.querySelector('#item')! + const outside = document.querySelector('#outside')! + const menuLeaves: string[] = [] + for (const evt of ['pointerleave', 'mouseleave']) { + menu.addEventListener(evt, () => menuLeaves.push(evt)) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: item, + }) + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(1, { + ref: outside, + }) + + await controller.hoverElement(0) + const result = await controller.clickElement(1) + + expect(result.success).toBe(true) + expect(menuLeaves).toEqual(['pointerleave', 'mouseleave']) + }) + it('dispatches leave events when moving synthetic hover to another element', async () => { document.body.innerHTML = '' diff --git a/packages/page-controller/src/PageController.ts b/packages/page-controller/src/PageController.ts index 934ac40f5..231efe897 100644 --- a/packages/page-controller/src/PageController.ts +++ b/packages/page-controller/src/PageController.ts @@ -356,9 +356,7 @@ export class PageController extends EventTarget { this.assertIndexed() const element = getElementByIndex(this.selectorMap, index) const elemText = this.elementTextMap.get(index) - await hoverElement(element, this.syntheticHoverPath) - const retained = this.syntheticHoverPath.filter((hovered) => hovered.contains(element)) - this.syntheticHoverPath = retained.includes(element) ? retained : [...retained, element] + this.syntheticHoverPath = await hoverElement(element, this.syntheticHoverPath) return { success: true, diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index affd805f1..14a81a6dc 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -52,7 +52,7 @@ export function getElementByIndex( export async function hoverElement( element: HTMLElement, previousElementOrPath?: HTMLElement | null | readonly HTMLElement[] -) { +): Promise { const previousPath = Array.isArray(previousElementOrPath) ? previousElementOrPath : previousElementOrPath @@ -110,6 +110,8 @@ export async function hoverElement( } } element.dispatchEvent(new MouseEvent('mouseenter', { ...mouseOpts, bubbles: false })) + + return ancestorPath } /** From d3e015863cbbf3cd90d4f17a5744425fe52a35d4 Mon Sep 17 00:00:00 2001 From: huyua9 Date: Thu, 6 Aug 2026 11:30:42 +0800 Subject: [PATCH 09/12] fix: clear synthetic hover on dispose --- .../src/PageController.test.ts | 24 +++++++++++++++++++ .../page-controller/src/PageController.ts | 10 ++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index 7194e460c..28e80e8e2 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -117,6 +117,30 @@ describe('PageController', () => { expect(menuLeaves).toEqual(['pointerleave', 'mouseleave']) }) + it('clears synthetic hover when the controller is disposed', async () => { + document.body.innerHTML = '' + const menu = document.querySelector('#menu')! + const item = document.querySelector('#item')! + const menuLeaves: string[] = [] + const itemLeaves: string[] = [] + for (const evt of ['pointerleave', 'mouseleave']) { + menu.addEventListener(evt, () => menuLeaves.push(evt)) + item.addEventListener(evt, () => itemLeaves.push(evt)) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: item, + }) + + await controller.hoverElement(0) + controller.dispose() + + expect(itemLeaves).toEqual(['pointerleave', 'mouseleave']) + expect(menuLeaves).toEqual(['pointerleave', 'mouseleave']) + }) + it('dispatches leave events when moving synthetic hover to another element', async () => { document.body.innerHTML = '' diff --git a/packages/page-controller/src/PageController.ts b/packages/page-controller/src/PageController.ts index 231efe897..98f42dc5d 100644 --- a/packages/page-controller/src/PageController.ts +++ b/packages/page-controller/src/PageController.ts @@ -251,9 +251,11 @@ export class PageController extends EventTarget { } } - /** Clear synthetic hover state before a pointer action targets another subtree. */ - private clearSyntheticHover(nextElement: HTMLElement): void { - const retained = this.syntheticHoverPath.filter((element) => element.contains(nextElement)) + /** Clear synthetic hover state before a pointer action or controller teardown. */ + private clearSyntheticHover(nextElement: HTMLElement | null): void { + const retained = nextElement + ? this.syntheticHoverPath.filter((element) => element.contains(nextElement)) + : [] for (const element of [...this.syntheticHoverPath].reverse()) { if (!element.contains(nextElement)) dispatchHoverLeave(element, nextElement) } @@ -479,13 +481,13 @@ export class PageController extends EventTarget { * Dispose and clean up resources */ dispose(): void { + this.clearSyntheticHover(null) dom.cleanUpHighlights() this.flatTree = null this.selectorMap.clear() this.elementTextMap.clear() this.simplifiedHTML = '' this.isIndexed = false - this.syntheticHoverPath = [] this.mask?.dispose() this.mask = null } From 4cf203d6ae04a13eea06451fac3b3fa3be978d9d Mon Sep 17 00:00:00 2001 From: huyua9 Date: Thu, 6 Aug 2026 11:54:55 +0800 Subject: [PATCH 10/12] fix: preserve mouse metadata when clearing hover --- .../src/PageController.test.ts | 27 +++++++++++++++++++ packages/page-controller/src/actions.ts | 5 ++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index 28e80e8e2..078b64ca9 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -141,6 +141,33 @@ describe('PageController', () => { expect(menuLeaves).toEqual(['pointerleave', 'mouseleave']) }) + it('preserves mouse pointer metadata when clearing synthetic hover', async () => { + document.body.innerHTML = + '' + const item = document.querySelector('#item')! + const outside = document.querySelector('#outside')! + const pointerTypes: string[] = [] + for (const evt of ['pointerout', 'pointerleave']) { + item.addEventListener(evt, (event) => + pointerTypes.push((event as PointerEvent).pointerType) + ) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: item, + }) + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(1, { + ref: outside, + }) + + await controller.hoverElement(0) + await controller.hoverElement(1) + + expect(pointerTypes).toEqual(['mouse', 'mouse']) + }) + it('dispatches leave events when moving synthetic hover to another element', async () => { document.body.innerHTML = '' diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index 14a81a6dc..acf3e34a9 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -126,12 +126,13 @@ export function dispatchHoverLeave( const movingWithinPreviousElement = relatedTarget ? previousElement.contains(relatedTarget) : false - previousElement.dispatchEvent(new PointerEvent('pointerout', { bubbles: true, relatedTarget })) + const pointerLeaveOpts = { bubbles: true, pointerType: 'mouse', relatedTarget } + previousElement.dispatchEvent(new PointerEvent('pointerout', pointerLeaveOpts)) previousElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget })) if (!movingWithinPreviousElement) { previousElement.dispatchEvent( - new PointerEvent('pointerleave', { bubbles: false, relatedTarget }) + new PointerEvent('pointerleave', { ...pointerLeaveOpts, bubbles: false }) ) previousElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false, relatedTarget })) } From 8ad0772deaa8b2e2651ab16cb9ac1f7ebc83f395 Mon Sep 17 00:00:00 2001 From: huyua9 Date: Fri, 7 Aug 2026 14:04:44 +0800 Subject: [PATCH 11/12] fix(pointer): clear clicked hover before synthetic hover --- .../src/PageController.test.ts | 34 +++++++++++++++++-- packages/page-controller/src/actions.ts | 12 ++++--- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index 078b64ca9..d8ca8689f 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -196,10 +196,10 @@ describe('PageController', () => { document.body.innerHTML = '
Open
Item
' const trigger = document.querySelector('#trigger')! const item = document.querySelector('#item')! - const events: Array<{ type: string; relatedTarget: EventTarget | null }> = [] + const events: { type: string; relatedTarget: EventTarget | null }[] = [] for (const evt of ['pointerout', 'pointerleave', 'mouseout', 'mouseleave']) { trigger.addEventListener(evt, (event) => - events.push({ type: event.type, relatedTarget: event.relatedTarget }) + events.push({ type: event.type, relatedTarget: (event as MouseEvent).relatedTarget }) ) } @@ -247,6 +247,36 @@ describe('PageController', () => { expect(left).toEqual(['pointerout', 'mouseout', 'pointerleave', 'mouseleave']) }) + it('clears click hover before hovering another element', async () => { + document.body.innerHTML = + '' + const clicked = document.querySelector('#clicked')! + const hovered = document.querySelector('#hovered')! + const left: string[] = [] + const pointerTypes: string[] = [] + for (const evt of ['pointerout', 'pointerleave', 'mouseout', 'mouseleave']) { + clicked.addEventListener(evt, (event) => { + left.push(evt) + if (event instanceof PointerEvent) pointerTypes.push(event.pointerType) + }) + } + + const controller = new PageController({ experimentalPointerActions: true }) + ;(controller as unknown as { isIndexed: boolean }).isIndexed = true + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(0, { + ref: clicked, + }) + ;(controller as unknown as { selectorMap: Map }).selectorMap.set(1, { + ref: hovered, + }) + + await controller.clickElement(0) + await controller.hoverElement(1) + + expect(left).toEqual(['pointerout', 'pointerleave', 'mouseout', 'mouseleave']) + expect(pointerTypes).toEqual(['mouse', 'mouse']) + }) + it('clears the full hover ancestry after entering a descendant', async () => { document.body.innerHTML = '' diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index acf3e34a9..e0aac1b40 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -53,6 +53,7 @@ export async function hoverElement( element: HTMLElement, previousElementOrPath?: HTMLElement | null | readonly HTMLElement[] ): Promise { + clearLastClickedElement() const previousPath = Array.isArray(previousElementOrPath) ? previousElementOrPath : previousElementOrPath @@ -140,10 +141,11 @@ export function dispatchHoverLeave( let lastClickedElement: HTMLElement | null = null -function blurLastClickedElement() { +export function clearLastClickedElement() { if (lastClickedElement) { - lastClickedElement.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })) - lastClickedElement.dispatchEvent(new PointerEvent('pointerleave', { bubbles: false })) + const pointerLeaveOpts = { bubbles: true, pointerType: 'mouse' as const } + lastClickedElement.dispatchEvent(new PointerEvent('pointerout', pointerLeaveOpts)) + lastClickedElement.dispatchEvent(new PointerEvent('pointerleave', { ...pointerLeaveOpts, bubbles: false })) lastClickedElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })) lastClickedElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })) lastClickedElement.blur() @@ -159,7 +161,7 @@ function blurLastClickedElement() { * @private Internal method, subject to change at any time. */ export async function clickElement(element: HTMLElement) { - blurLastClickedElement() + clearLastClickedElement() lastClickedElement = element @@ -325,7 +327,7 @@ export async function inputTextElement(element: HTMLElement, text: string) { await waitFor(0.1) - blurLastClickedElement() + clearLastClickedElement() } /** From 5f165cfc15f636d1184ccc1d16796b9f94a1021a Mon Sep 17 00:00:00 2001 From: huyua9 <535257617@qq.com> Date: Thu, 13 Aug 2026 12:11:44 +0800 Subject: [PATCH 12/12] fix: compose synthetic hover events --- packages/page-controller/src/PageController.test.ts | 7 ++++++- packages/page-controller/src/actions.ts | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/page-controller/src/PageController.test.ts b/packages/page-controller/src/PageController.test.ts index d8ca8689f..b0fee9324 100644 --- a/packages/page-controller/src/PageController.test.ts +++ b/packages/page-controller/src/PageController.test.ts @@ -51,8 +51,12 @@ describe('PageController', () => { const target = document.querySelector('#target')! const seen: string[] = [] + const composed: boolean[] = [] for (const evt of ['pointerover', 'pointerenter', 'mouseover', 'mouseenter']) { - target.addEventListener(evt, () => seen.push(evt)) + target.addEventListener(evt, (event) => { + seen.push(evt) + if (evt === 'pointerover' || evt === 'mouseover') composed.push(event.composed) + }) } // Stub index 0 -> our target. @@ -68,6 +72,7 @@ describe('PageController', () => { expect(seen).toEqual( expect.arrayContaining(['pointerover', 'pointerenter', 'mouseover', 'mouseenter']) ) + expect(composed).toEqual([true, true]) }) it('dispatches enter events on ancestors when hovering a descendant directly', async () => { diff --git a/packages/page-controller/src/actions.ts b/packages/page-controller/src/actions.ts index e0aac1b40..22edfbf3a 100644 --- a/packages/page-controller/src/actions.ts +++ b/packages/page-controller/src/actions.ts @@ -87,6 +87,7 @@ export async function hoverElement( clientY: y, pointerType: 'mouse', relatedTarget: previousElement ?? null, + composed: true, } const mouseOpts = { bubbles: true, @@ -94,6 +95,7 @@ export async function hoverElement( clientX: x, clientY: y, relatedTarget: previousElement ?? null, + composed: true, } // Hover — pointer events first, then mouse events (spec order) @@ -145,7 +147,9 @@ export function clearLastClickedElement() { if (lastClickedElement) { const pointerLeaveOpts = { bubbles: true, pointerType: 'mouse' as const } lastClickedElement.dispatchEvent(new PointerEvent('pointerout', pointerLeaveOpts)) - lastClickedElement.dispatchEvent(new PointerEvent('pointerleave', { ...pointerLeaveOpts, bubbles: false })) + lastClickedElement.dispatchEvent( + new PointerEvent('pointerleave', { ...pointerLeaveOpts, bubbles: false }) + ) lastClickedElement.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })) lastClickedElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })) lastClickedElement.blur()