Skip to content

Commit b49ebf2

Browse files
committed
fix(webview): maintain hover state when the mouse crosses an <iframe>
each `<iframe>` has its own mouse position as such, moving the pointer into or out of an `<iframe>` did not dispatch `mouseleave`/`mouseout`/etc. instead, track what `<iframe>` are hovered on the server so that movement is coordinated globally
1 parent a8a8c7b commit b49ebf2

3 files changed

Lines changed: 81 additions & 3 deletions

File tree

packages/injected/src/webview/webViewInput.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ export class WebViewInput {
291291
metaKey: params.metaKey,
292292
};
293293
const pointer: PointerEventInit = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true };
294+
let lastTask = Promise.resolve();
294295
const prev = this._hoverTarget;
295296
if (prev !== target) {
296297
if (prev && prev.isConnected) {
@@ -302,13 +303,29 @@ export class WebViewInput {
302303
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointerover', { ...pointer, relatedTarget: prev })));
303304
void this._postTask(() => markAndDispatch(target, new MouseEvent('mouseover', { ...base, relatedTarget: prev })));
304305
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointerenter', { ...pointer, bubbles: false, cancelable: false, relatedTarget: prev })));
305-
void this._postTask(() => markAndDispatch(target, new MouseEvent('mouseenter', { ...base, bubbles: false, cancelable: false, relatedTarget: prev })));
306+
lastTask = this._postTask(() => markAndDispatch(target, new MouseEvent('mouseenter', { ...base, bubbles: false, cancelable: false, relatedTarget: prev })));
306307
this._hoverTarget = target;
307308
}
309+
// Movements within an <iframe> are not dispatched in the owner context.
310+
if (isFrameOwner(target))
311+
return lastTask;
308312
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointermove', pointer)));
309313
return this._postTask(() => markAndDispatch(target, new MouseEvent('mousemove', base)));
310314
}
311315

316+
clearHover(): Promise<void> {
317+
const prev = this._hoverTarget;
318+
this._hoverTarget = null;
319+
if (!prev?.isConnected)
320+
return Promise.resolve();
321+
const base: MouseEventInit = { bubbles: true, cancelable: true, view: this._window, relatedTarget: null };
322+
const pointer: PointerEventInit = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true };
323+
void this._postTask(() => markAndDispatch(prev, new PointerEvent('pointerout', pointer)));
324+
void this._postTask(() => markAndDispatch(prev, new MouseEvent('mouseout', base)));
325+
void this._postTask(() => markAndDispatch(prev, new PointerEvent('pointerleave', { ...pointer, bubbles: false, cancelable: false })));
326+
return this._postTask(() => markAndDispatch(prev, new MouseEvent('mouseleave', { ...base, bubbles: false, cancelable: false })));
327+
}
328+
312329
mouseEvent(params: MouseEventParams): Promise<void> {
313330
// Resolve the hit target at dispatch time, not enqueue time: a queued move
314331
// ahead of this may reveal an overlay that should receive the press.

packages/playwright-core/src/server/webkit/webview/wvInput.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,29 @@ export class RawKeyboardImpl implements input.RawKeyboard {
9191

9292
export class RawMouseImpl implements input.RawMouse {
9393
private _page: WVPage;
94+
private _lastHoveredFrames: frames.Frame[] = [];
9495

9596
constructor(page: WVPage) {
9697
this._page = page;
9798
}
9899

99100
async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, forClick: boolean): Promise<void> {
100-
const { frame, point } = await this._page.deepestFrameForPoint(progress, x, y);
101-
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.mouseMove(p), { ...point, button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers) });
101+
const path = await this._page.framePointerPath(progress, x, y);
102+
const params = { button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers) };
103+
// Each frame tracks its own hover target, so as the pointer crosses an <iframe>
104+
// boundary it must leave the frames it is no longer within (deepest first) before
105+
// entering the frames along the new path. A move that stays within the same frames
106+
// leaves none of them, so no cross-frame mouseout/mouseleave is dispatched.
107+
const hoveredFrames = path.map(entry => entry.frame);
108+
const attachedFrames = this._page._page.frameManager.frames();
109+
for (const frame of this._lastHoveredFrames.reverse()) {
110+
if (hoveredFrames.includes(frame) || !attachedFrames.includes(frame))
111+
continue;
112+
await evaluateInFrame(progress, frame, () => (globalThis as any).__pwWebViewInput.clearHover(), undefined);
113+
}
114+
this._lastHoveredFrames = hoveredFrames;
115+
for (const { frame, point } of path)
116+
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.mouseMove(p), { ...params, ...point });
102117
}
103118

104119
async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, clickCount: number): Promise<void> {

tests/page/page-mouse.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,49 @@ it('should dispatch mouse move after context menu was opened', async ({ page, br
337337
}
338338
}
339339
});
340+
341+
it('should track hover across iframe boundaries', async ({ page, headless }) => {
342+
it.skip(!headless, 'headed messes up with hover');
343+
344+
await page.setContent(`
345+
<style>
346+
body, html { margin: 0; padding: 0; }
347+
#parentBox { position: absolute; left: 10px; top: 10px; width: 100px; height: 100px; }
348+
iframe { position: absolute; left: 200px; top: 10px; width: 200px; height: 200px; border: none; }
349+
</style>
350+
<div id="parentBox"></div>
351+
<iframe srcdoc="
352+
<style>body, html { margin: 0; padding: 0; } #childBox { width: 180px; height: 180px; }</style>
353+
<div id='childBox'></div>
354+
<script>
355+
const box = document.querySelector('#childBox');
356+
box.addEventListener('mouseenter', () => window.top.__log.push('child:enter'));
357+
box.addEventListener('mouseleave', () => window.top.__log.push('child:leave'));
358+
</script>
359+
"></iframe>
360+
<script>
361+
window.__log = [];
362+
const box = document.querySelector('#parentBox');
363+
box.addEventListener('mouseenter', () => window.__log.push('parent:enter'));
364+
box.addEventListener('mouseleave', () => window.__log.push('parent:leave'));
365+
</script>
366+
`);
367+
await page.waitForSelector('iframe');
368+
await page.frames()[1].waitForSelector('#childBox');
369+
const log = () => page.evaluate(() => window['__log']);
370+
const parentBox = (await page.locator('#parentBox').boundingBox())!;
371+
const iframeBox = (await page.locator('iframe').boundingBox())!;
372+
const parentCenter = { x: parentBox.x + parentBox.width / 2, y: parentBox.y + parentBox.height / 2 };
373+
const childCenter = { x: iframeBox.x + 90, y: iframeBox.y + 90 };
374+
375+
await page.mouse.move(parentCenter.x, parentCenter.y);
376+
await expect.poll(log).toEqual(['parent:enter']);
377+
378+
// Crossing into the iframe leaves the parent element and enters the child.
379+
await page.mouse.move(childCenter.x, childCenter.y);
380+
await expect.poll(log).toEqual(['parent:enter', 'parent:leave', 'child:enter']);
381+
382+
// Crossing back out leaves the child element and re-enters the parent.
383+
await page.mouse.move(parentCenter.x, parentCenter.y);
384+
await expect.poll(log).toEqual(['parent:enter', 'parent:leave', 'child:enter', 'child:leave', 'parent:enter']);
385+
});

0 commit comments

Comments
 (0)