Skip to content

Commit ec680e2

Browse files
authored
fix(webview): support input for elements inside cross-origin <iframe> (#41463)
1 parent 91565f0 commit ec680e2

5 files changed

Lines changed: 202 additions & 72 deletions

File tree

packages/injected/src/webview/webViewInput.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ function markAndDispatch(node: EventTarget, event: Event): boolean {
6666
return node.dispatchEvent(event);
6767
}
6868

69+
function isFrameOwner(element: Element | null): element is HTMLIFrameElement | HTMLFrameElement {
70+
return !!element && (element.localName === 'iframe' || element.localName === 'frame');
71+
}
72+
6973
// Legacy WebKit-only KeyboardEvent.keyIdentifier (a DOM Level 3 draft property
7074
// dropped by every other engine). It cannot be supplied via the constructor, so
7175
// compute it from the virtual key code and define it on the event before
@@ -145,6 +149,19 @@ export class WebViewInput {
145149
return el;
146150
}
147151

152+
positionInIFrame(x: number, y: number): { iframe: HTMLIFrameElement | HTMLFrameElement | null, x: number, y: number } {
153+
const target = this._deepElementFromPoint(x, y);
154+
if (!isFrameOwner(target))
155+
return { iframe: null, x, y };
156+
const frameRect = target.getBoundingClientRect();
157+
const frameStyle = this._window.getComputedStyle(target);
158+
return {
159+
iframe: target,
160+
x: x - frameRect.left - parseFloat(frameStyle.borderLeftWidth) - parseFloat(frameStyle.paddingLeft),
161+
y: y - frameRect.top - parseFloat(frameStyle.borderTopWidth) - parseFloat(frameStyle.paddingTop),
162+
};
163+
}
164+
148165
// The focused element may live inside one or more shadow roots, where
149166
// document.activeElement only reports the outermost shadow host.
150167
private _deepActiveElement(): Element | null {
@@ -154,6 +171,11 @@ export class WebViewInput {
154171
return active;
155172
}
156173

174+
activeIFrame(): HTMLIFrameElement | HTMLFrameElement | null {
175+
const active = this._deepActiveElement();
176+
return isFrameOwner(active) ? active : null;
177+
}
178+
157179
private _insertText(target: Element | null, text: string) {
158180
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
159181
const start = target.selectionStart ?? target.value.length;
@@ -269,6 +291,7 @@ export class WebViewInput {
269291
metaKey: params.metaKey,
270292
};
271293
const pointer: PointerEventInit = { ...base, pointerId: 1, pointerType: 'mouse', isPrimary: true };
294+
let lastTask = Promise.resolve();
272295
const prev = this._hoverTarget;
273296
if (prev !== target) {
274297
if (prev && prev.isConnected) {
@@ -280,13 +303,29 @@ export class WebViewInput {
280303
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointerover', { ...pointer, relatedTarget: prev })));
281304
void this._postTask(() => markAndDispatch(target, new MouseEvent('mouseover', { ...base, relatedTarget: prev })));
282305
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointerenter', { ...pointer, bubbles: false, cancelable: false, relatedTarget: prev })));
283-
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 })));
284307
this._hoverTarget = target;
285308
}
309+
// Movements within an <iframe> are not dispatched in the owner context.
310+
if (isFrameOwner(target))
311+
return lastTask;
286312
void this._postTask(() => markAndDispatch(target, new PointerEvent('pointermove', pointer)));
287313
return this._postTask(() => markAndDispatch(target, new MouseEvent('mousemove', base)));
288314
}
289315

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+
290329
mouseEvent(params: MouseEventParams): Promise<void> {
291330
// Resolve the hit target at dispatch time, not enqueue time: a queued move
292331
// ahead of this may reveal an overlay that should receive the press.
@@ -350,7 +389,7 @@ export class WebViewInput {
350389
metaKey: params.metaKey,
351390
};
352391
try {
353-
const touch = new Touch({ identifier: 0, target, clientX: params.x, clientY: params.y, screenX: params.x, screenY: params.y, pageX: params.x, pageY: params.y, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1 });
392+
const touch = new Touch({ identifier: 0, target, clientX: params.x, clientY: params.y, screenX: params.x, screenY: params.y, pageX: params.x + this._window.scrollX, pageY: params.y + this._window.scrollY, radiusX: 1, radiusY: 1, rotationAngle: 0, force: 1 });
354393
void this._postTask(() => markAndDispatch(target, new TouchEvent('touchstart', { ...init, touches: [touch], targetTouches: [touch], changedTouches: [touch] })));
355394
void this._postTask(() => markAndDispatch(target, new TouchEvent('touchend', { ...init, touches: [], targetTouches: [], changedTouches: [touch] })));
356395
} catch {

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

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
import * as input from '../../input';
1919

2020
import type * as types from '../../types';
21-
import type { WVSession } from './wvConnection';
21+
import type * as frames from '../../frames';
22+
import type { Func1 } from '../../javascript';
2223
import type { Progress } from '../../progress';
24+
import type { WVPage } from './wvPage';
2325

2426
function modifierFlags(modifiers: Set<types.KeyboardModifier>) {
2527
return {
@@ -51,11 +53,16 @@ function toButtonsMask(buttons: Set<types.MouseButton>): number {
5153
return mask;
5254
}
5355

56+
async function evaluateInFrame<Arg>(progress: Progress, frame: frames.Frame, pageFunction: Func1<Arg, void>, arg: Arg): Promise<void> {
57+
const context = await progress.race(frame.mainContext());
58+
await progress.race(context.evaluate(pageFunction, arg));
59+
}
60+
5461
export class RawKeyboardImpl implements input.RawKeyboard {
55-
private _session: WVSession | undefined;
62+
private _page: WVPage;
5663

57-
setSession(session: WVSession) {
58-
this._session = session;
64+
constructor(page: WVPage) {
65+
this._page = page;
5966
}
6067

6168
async keydown(progress: Progress, modifiers: Set<types.KeyboardModifier>, keyName: string, description: input.KeyDescription, autoRepeat: boolean): Promise<void> {
@@ -65,31 +72,48 @@ export class RawKeyboardImpl implements input.RawKeyboard {
6572
...modifierFlags(modifiers),
6673
...(text ? { text } : {}),
6774
};
68-
await callWebViewInput(progress, this._session, 'keydown', params);
75+
const frame = await this._page.deepestFocusedFrame(progress);
76+
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.keydown(p), params);
6977
}
7078

7179
async keyup(progress: Progress, modifiers: Set<types.KeyboardModifier>, keyName: string, description: input.KeyDescription): Promise<void> {
7280
const { code, keyCode, key, location } = description;
7381
const params = { code, key, keyCode, location, ...modifierFlags(modifiers) };
74-
await callWebViewInput(progress, this._session, 'keyup', params);
82+
const frame = await this._page.deepestFocusedFrame(progress);
83+
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.keyup(p), params);
7584
}
7685

7786
async sendText(progress: Progress, text: string): Promise<void> {
78-
await callWebViewInput(progress, this._session, 'insertText', text);
87+
const frame = await this._page.deepestFocusedFrame(progress);
88+
await evaluateInFrame(progress, frame, t => (globalThis as any).__pwWebViewInput.insertText(t), text);
7989
}
8090
}
8191

8292
export class RawMouseImpl implements input.RawMouse {
83-
private _session: WVSession | undefined;
93+
private _page: WVPage;
94+
private _lastHoveredFrames: frames.Frame[] = [];
8495

85-
setSession(session: WVSession) {
86-
this._session = session;
96+
constructor(page: WVPage) {
97+
this._page = page;
8798
}
8899

89100
async move(progress: Progress, x: number, y: number, button: types.MouseButton | 'none', buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, forClick: boolean): Promise<void> {
90-
await callWebViewInput(progress, this._session, 'mouseMove', {
91-
x, y, button: buttonToNumber(button), buttons: toButtonsMask(buttons), ...modifierFlags(modifiers),
92-
});
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 });
93117
}
94118

95119
async down(progress: Progress, x: number, y: number, button: types.MouseButton, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, clickCount: number): Promise<void> {
@@ -114,43 +138,27 @@ export class RawMouseImpl implements input.RawMouse {
114138
}
115139

116140
async wheel(progress: Progress, x: number, y: number, buttons: Set<types.MouseButton>, modifiers: Set<types.KeyboardModifier>, deltaX: number, deltaY: number): Promise<void> {
117-
await callWebViewInput(progress, this._session, 'wheel', { x, y, deltaX, deltaY, ...modifierFlags(modifiers) });
141+
const { frame, point } = await this._page.deepestFrameForPoint(progress, x, y);
142+
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.wheel(p), { ...point, deltaX, deltaY, ...modifierFlags(modifiers) });
118143
}
119144

120145
private async _mouseEvent(progress: Progress, type: string, x: number, y: number, button: number, buttons: number, modifiers: Set<types.KeyboardModifier>, clickCount: number) {
121-
await callWebViewInput(progress, this._session, 'mouseEvent', {
122-
type, x, y, button, buttons, clickCount, ...modifierFlags(modifiers),
146+
const { frame, point } = await this._page.deepestFrameForPoint(progress, x, y);
147+
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.mouseEvent(p), {
148+
type, ...point, button, buttons, clickCount, ...modifierFlags(modifiers),
123149
});
124150
}
125151
}
126152

127153
export class RawTouchscreenImpl implements input.RawTouchscreen {
128-
private _session: WVSession | undefined;
154+
private _page: WVPage;
129155

130-
setSession(session: WVSession) {
131-
this._session = session;
156+
constructor(page: WVPage) {
157+
this._page = page;
132158
}
133159

134160
async tap(progress: Progress, x: number, y: number, modifiers: Set<types.KeyboardModifier>) {
135-
await callWebViewInput(progress, this._session, 'tap', { x, y, ...modifierFlags(modifiers) });
136-
}
137-
}
138-
139-
async function callWebViewInput(progress: Progress, session: WVSession | undefined, method: string, arg: any): Promise<void> {
140-
if (!session)
141-
throw new Error('Page is not initialized');
142-
const expression = `window.__pwWebViewInput.${method}(${JSON.stringify(arg)})`;
143-
// Some dispatchers are async — they spread events across event-loop tasks the
144-
// way a real device does. Await the returned promise so the action only
145-
// resolves once every event has been delivered. Stock WebKit's Runtime.evaluate
146-
// has no awaitPromise option, so use the separate Runtime.awaitPromise command.
147-
const { result } = await progress.race(session.send('Runtime.evaluate', { expression, returnByValue: false }));
148-
// `result` is absent if evaluation failed (e.g. the frame navigated away).
149-
// Only promises carry an objectId here — every __pwWebViewInput method returns
150-
// void or a Promise — so this both awaits async dispatch and avoids leaking a
151-
// handle for the synchronous (void) case.
152-
if (result?.className === 'Promise' && result.objectId) {
153-
await progress.race(session.send('Runtime.awaitPromise', { promiseObjectId: result.objectId, returnByValue: true }));
154-
session.sendMayFail('Runtime.releaseObject', { objectId: result.objectId });
161+
const { frame, point } = await this._page.deepestFrameForPoint(progress, x, y);
162+
await evaluateInFrame(progress, frame, p => (globalThis as any).__pwWebViewInput.tap(p), { ...point, ...modifierFlags(modifiers) });
155163
}
156164
}

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

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ export class WVPage implements PageDelegate {
8080
constructor(browserContext: WVBrowserContext, outerSession: WVSession, dialogEndpoint?: string) {
8181
this._outerSession = outerSession;
8282
this._dialogEndpoint = dialogEndpoint;
83-
this.rawKeyboard = new RawKeyboardImpl();
84-
this.rawMouse = new RawMouseImpl();
85-
this.rawTouchscreen = new RawTouchscreenImpl();
83+
this.rawKeyboard = new RawKeyboardImpl(this);
84+
this.rawMouse = new RawMouseImpl(this);
85+
this.rawTouchscreen = new RawTouchscreenImpl(this);
8686
this._contextIdToContext = new Map();
8787
this._page = new Page(this, browserContext);
8888
this._workers = new WVWorkers(this._page, outerSession);
@@ -193,9 +193,6 @@ export class WVPage implements PageDelegate {
193193
private _setSession(session: WVSession) {
194194
eventsHelper.removeEventListeners(this._sessionListeners);
195195
this._session = session;
196-
this.rawKeyboard.setSession(session);
197-
this.rawMouse.setSession(session);
198-
this.rawTouchscreen.setSession(session);
199196
this._workers.setSession(session);
200197
this._addSessionListeners();
201198
}
@@ -923,6 +920,56 @@ export class WVPage implements PageDelegate {
923920
async inputActionEpilogue(): Promise<void> {
924921
}
925922

923+
async deepestFrameForPoint(progress: Progress, x: number, y: number): Promise<{ frame: frames.Frame, point: types.Point }> {
924+
const path = await this.framePointerPath(progress, x, y);
925+
return path[path.length - 1];
926+
}
927+
928+
async deepestFocusedFrame(progress: Progress): Promise<frames.Frame> {
929+
let frame: frames.Frame = this._page.mainFrame();
930+
for (;;) {
931+
const context = await progress.race(frame.mainContext());
932+
const iframe = await progress.race(context.evaluateHandle(() => (globalThis as any).__pwWebViewInput.activeIFrame())) as dom.ElementHandle;
933+
const childFrame = await this._childFrameAndDispose(progress, iframe);
934+
if (!childFrame)
935+
break;
936+
frame = childFrame;
937+
}
938+
return frame;
939+
}
940+
941+
// Walk from the main frame into the deepest <iframe> that contains the point,
942+
// recording each frame and the point translated into that frame's coordinates.
943+
async framePointerPath(progress: Progress, x: number, y: number): Promise<{ frame: frames.Frame, point: types.Point }[]> {
944+
const path: { frame: frames.Frame, point: types.Point }[] = [];
945+
let frame: frames.Frame = this._page.mainFrame();
946+
let point: types.Point = { x, y };
947+
for (;;) {
948+
path.push({ frame, point });
949+
const context = await progress.race(frame.mainContext());
950+
const position = await progress.race(context.evaluateHandle(p => (globalThis as any).__pwWebViewInput.positionInIFrame(p.x, p.y), point));
951+
try {
952+
const iframe = await position.getProperty(progress, 'iframe') as dom.ElementHandle;
953+
const childFrame = await this._childFrameAndDispose(progress, iframe);
954+
if (!childFrame)
955+
break;
956+
frame = childFrame;
957+
point = await progress.race(position.evaluate(result => ({ x: result.x, y: result.y })));
958+
} finally {
959+
position.dispose();
960+
}
961+
}
962+
return path;
963+
}
964+
965+
private async _childFrameAndDispose(progress: Progress, iframe: dom.ElementHandle): Promise<frames.Frame | null> {
966+
try {
967+
return await progress.race(this.getContentFrame(iframe));
968+
} finally {
969+
iframe.dispose();
970+
}
971+
}
972+
926973
async resetForReuse(progress: Progress): Promise<void> {
927974
}
928975

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)