Skip to content

Commit ea8814c

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 137d268 commit ea8814c

3 files changed

Lines changed: 129 additions & 21 deletions

File tree

packages/injected/src/webview/webViewInput.ts

Lines changed: 17 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,28 @@ 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+
if (isFrameOwner(target))
310+
return lastTask;
308311
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointermove', pointer)));
309312
return this._postTask(() => markAndDispatch(target, new MouseEvent('mousemove', base)));
310313
}
311314

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

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

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export class WVPage implements PageDelegate {
6565
private _sessionListeners: RegisteredListener[] = [];
6666
private _eventListeners: RegisteredListener[];
6767
private _pendingMainFrameLoaderResolvers = new Map<string, (loaderId: string) => void>();
68+
private _lastHoveredFrames: frames.Frame[] = [];
6869
readonly _browserContext: WVBrowserContext;
6970
private _firstNonInitialNavigationCommittedPromise: Promise<void>;
7071
private _firstNonInitialNavigationCommittedFulfill = () => {};
@@ -76,6 +77,7 @@ export class WVPage implements PageDelegate {
7677

7778
private readonly _dialogEndpoint: string | undefined;
7879

80+
7981
constructor(browserContext: WVBrowserContext, outerSession: WVSession, dialogEndpoint?: string) {
8082
this._outerSession = outerSession;
8183
this._dialogEndpoint = dialogEndpoint;
@@ -916,37 +918,81 @@ export class WVPage implements PageDelegate {
916918
}
917919

918920
private async _dispatchWebViewInput(progress: Progress, method: string, params: any): Promise<void> {
921+
if (method === 'mouseMove') {
922+
await this._dispatchMouseMove(progress, params);
923+
return;
924+
}
919925
const positional = typeof params === 'object' && params !== null && 'x' in params;
920926
let frame: frames.Frame = this._page.mainFrame();
921-
let x = positional ? params.x as number : 0;
922-
let y = positional ? params.y as number : 0;
927+
let x = 0;
928+
let y = 0;
929+
if (positional) {
930+
const path = await this._pointerPath(progress, params.x as number, params.y as number);
931+
({ frame, x, y } = path[path.length - 1]);
932+
} else {
933+
// Non-positional actions (keyboard, insertText) follow the focused <iframe> chain.
934+
for (;;) {
935+
const context = await progress.race(frame.mainContext());
936+
const iframe = await progress.race(context.evaluateHandle(() => (globalThis as any).__pwWebViewInput.activeIFrame())) as dom.ElementHandle;
937+
const childFrame = await this._childFrame(progress, iframe);
938+
if (!childFrame)
939+
break;
940+
frame = childFrame;
941+
}
942+
}
943+
const context = await progress.race(frame.mainContext());
944+
// This will automatically await if the result is a promise, so async dispatchers deliver every event before this resolves.
945+
await progress.race(context.evaluate(({ method, params }) => (globalThis as any).__pwWebViewInput[method](params), { method, params: positional ? { ...params, x, y } : params }));
946+
}
947+
948+
private async _childFrame(progress: Progress, iframe: dom.ElementHandle): Promise<frames.Frame | null> {
949+
try {
950+
return await progress.race(this.getContentFrame(iframe));
951+
} finally {
952+
iframe.dispose();
953+
}
954+
}
955+
956+
// Walk from the main frame into the deepest <iframe> that contains the point,
957+
// translating the point into each frame's coordinates along the way.
958+
private async _pointerPath(progress: Progress, x: number, y: number): Promise<{ frame: frames.Frame, x: number, y: number }[]> {
959+
const path: { frame: frames.Frame, x: number, y: number }[] = [];
960+
let frame: frames.Frame = this._page.mainFrame();
923961
for (;;) {
962+
path.push({ frame, x, y });
924963
const context = await progress.race(frame.mainContext());
925-
const position = positional
926-
? await progress.race(context.evaluateHandle(([px, py]) => (globalThis as any).__pwWebViewInput.positionInIFrame(px, py), [x, y]))
927-
: undefined;
964+
const position = await progress.race(context.evaluateHandle(([px, py]) => (globalThis as any).__pwWebViewInput.positionInIFrame(px, py), [x, y]));
928965
try {
929-
const iframe = (position
930-
? await position.getProperty(progress, 'iframe')
931-
: await progress.race(context.evaluateHandle(() => (globalThis as any).__pwWebViewInput.activeIFrame()))) as dom.ElementHandle;
932-
let childFrame: frames.Frame | null;
933-
try {
934-
childFrame = await progress.race(this.getContentFrame(iframe));
935-
} finally {
936-
iframe.dispose();
937-
}
966+
const iframe = await position.getProperty(progress, 'iframe') as dom.ElementHandle;
967+
const childFrame = await this._childFrame(progress, iframe);
938968
if (!childFrame)
939969
break;
940-
if (position)
941-
({ x, y } = await progress.race(position.evaluate(result => ({ x: result.x, y: result.y }))));
970+
({ x, y } = await progress.race(position.evaluate(result => ({ x: result.x, y: result.y }))));
942971
frame = childFrame;
943972
} finally {
944-
position?.dispose();
973+
position.dispose();
945974
}
946975
}
947-
const context = await progress.race(frame.mainContext());
948-
// This will automatically await if the result is a promise, so async dispatchers deliver every event before this resolves.
949-
await progress.race(context.evaluate(({ method, params }) => (globalThis as any).__pwWebViewInput[method](params), { method, params: positional ? { ...params, x, y } : params }));
976+
return path;
977+
}
978+
979+
// Mouse move maintains hover state across frames: as the pointer crosses an <iframe>
980+
// boundary it leaves the elements in one frame and enters elements in another.
981+
private async _dispatchMouseMove(progress: Progress, params: any): Promise<void> {
982+
const path = await this._pointerPath(progress, params.x as number, params.y as number);
983+
const framesToHover = path.map(entry => entry.frame);
984+
const frames = this._page.frameManager.frames();
985+
for (const hoveredFrame of this._lastHoveredFrames.reverse()) {
986+
if (framesToHover.includes(hoveredFrame) || !frames.includes(hoveredFrame))
987+
continue;
988+
const context = await progress.race(hoveredFrame.mainContext());
989+
await progress.race(context.evaluate(() => (globalThis as any).__pwWebViewInput.clearHover()));
990+
}
991+
for (const entry of path) {
992+
const context = await progress.race(entry.frame.mainContext());
993+
await progress.race(context.evaluate(p => (globalThis as any).__pwWebViewInput.mouseMove(p), { ...params, x: entry.x, y: entry.y }));
994+
}
995+
this._lastHoveredFrames = framesToHover;
950996
}
951997

952998
async resetForReuse(progress: Progress): 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)