Skip to content

Commit 55b4bc9

Browse files
authored
feat(actions): requery the element when it was detached during the action (#1853)
1 parent e466508 commit 55b4bc9

File tree

12 files changed

+239
-147
lines changed

12 files changed

+239
-147
lines changed

src/chromium/crPage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { CRPDF } from './crPdf';
3636
import { CRBrowserContext } from './crBrowser';
3737
import * as types from '../types';
3838
import { ConsoleMessage } from '../console';
39+
import { NotConnectedError } from '../errors';
3940

4041
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
4142

@@ -765,6 +766,8 @@ class FrameSession {
765766
objectId: toRemoteObject(handle).objectId,
766767
rect,
767768
}).catch(e => {
769+
if (e instanceof Error && e.message.includes('Node is detached from document'))
770+
throw new NotConnectedError();
768771
if (e instanceof Error && e.message.includes('Node does not have a layout object'))
769772
e.message = 'Node is either not visible or not an HTMLElement';
770773
throw e;

src/dom.ts

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,19 @@
1414
* limitations under the License.
1515
*/
1616

17-
import * as debug from 'debug';
1817
import * as fs from 'fs';
1918
import * as mime from 'mime';
2019
import * as path from 'path';
2120
import * as util from 'util';
2221
import * as frames from './frames';
23-
import { assert, debugError, helper } from './helper';
24-
import Injected from './injected/injected';
22+
import { assert, debugError, helper, debugInput } from './helper';
23+
import { Injected, InjectedResult } from './injected/injected';
2524
import * as input from './input';
2625
import * as js from './javascript';
2726
import { Page } from './page';
2827
import { selectors } from './selectors';
2928
import * as types from './types';
29+
import { NotConnectedError, TimeoutError } from './errors';
3030

3131
export type PointerActionOptions = {
3232
modifiers?: input.Modifier[];
@@ -37,8 +37,6 @@ export type ClickOptions = PointerActionOptions & input.MouseClickOptions;
3737

3838
export type MultiClickOptions = PointerActionOptions & input.MouseMultiClickOptions;
3939

40-
const debugInput = debug('pw:input');
41-
4240
export class FrameExecutionContext extends js.ExecutionContext {
4341
readonly frame: frames.Frame;
4442
private _injectedPromise?: Promise<js.JSHandle>;
@@ -220,10 +218,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
220218
const position = options ? options.position : undefined;
221219
await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
222220
const point = position ? await this._offsetPoint(position) : await this._clickablePoint();
223-
224221
point.x = (point.x * 100 | 0) / 100;
225222
point.y = (point.y * 100 | 0) / 100;
226-
227223
if (!force)
228224
await this._waitForHitTargetAt(point, deadline);
229225

@@ -270,18 +266,18 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
270266
assert(helper.isNumber(option.index), 'Indices must be numbers. Found index "' + option.index + '" of type "' + (typeof option.index) + '"');
271267
}
272268
return await this._page._frameManager.waitForSignalsCreatedBy<string[]>(async () => {
273-
return this._evaluateInUtility(({ injected, node }, selectOptions) => injected.selectOptions(node, selectOptions), selectOptions);
269+
const injectedResult = await this._evaluateInUtility(({ injected, node }, selectOptions) => injected.selectOptions(node, selectOptions), selectOptions);
270+
return handleInjectedResult(injectedResult, '');
274271
}, deadline, options);
275272
}
276273

277274
async fill(value: string, options?: types.NavigatingActionWaitOptions): Promise<void> {
278275
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
279276
const deadline = this._page._timeoutSettings.computeDeadline(options);
280277
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
281-
const errorOrNeedsInput = await this._evaluateInUtility(({ injected, node }, value) => injected.fill(node, value), value);
282-
if (typeof errorOrNeedsInput === 'string')
283-
throw new Error(errorOrNeedsInput);
284-
if (errorOrNeedsInput) {
278+
const injectedResult = await this._evaluateInUtility(({ injected, node }, value) => injected.fill(node, value), value);
279+
const needsInput = handleInjectedResult(injectedResult, '');
280+
if (needsInput) {
285281
if (value)
286282
await this._page.keyboard.insertText(value);
287283
else
@@ -291,19 +287,21 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
291287
}
292288

293289
async selectText(): Promise<void> {
294-
const error = await this._evaluateInUtility(({ injected, node }) => injected.selectText(node), {});
295-
if (typeof error === 'string')
296-
throw new Error(error);
290+
const injectedResult = await this._evaluateInUtility(({ injected, node }) => injected.selectText(node), {});
291+
handleInjectedResult(injectedResult, '');
297292
}
298293

299294
async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options?: types.NavigatingActionWaitOptions) {
300295
const deadline = this._page._timeoutSettings.computeDeadline(options);
301-
const multiple = await this._evaluateInUtility(({ node }) => {
296+
const injectedResult = await this._evaluateInUtility(({ node }): InjectedResult<boolean> => {
302297
if (node.nodeType !== Node.ELEMENT_NODE || (node as Node as Element).tagName !== 'INPUT')
303-
throw new Error('Node is not an HTMLInputElement');
298+
return { status: 'error', error: 'Node is not an HTMLInputElement' };
299+
if (!node.isConnected)
300+
return { status: 'notconnected' };
304301
const input = node as Node as HTMLInputElement;
305-
return input.multiple;
302+
return { status: 'success', value: input.multiple };
306303
}, {});
304+
const multiple = handleInjectedResult(injectedResult, '');
307305
let ff: string[] | types.FilePayload[];
308306
if (!Array.isArray(files))
309307
ff = [ files ] as string[] | types.FilePayload[];
@@ -329,14 +327,8 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
329327
}
330328

331329
async focus() {
332-
const errorMessage = await this._evaluateInUtility(({ node }) => {
333-
if (!(node as any)['focus'])
334-
return 'Node is not an HTML or SVG element.';
335-
(node as Node as HTMLElement | SVGElement).focus();
336-
return false;
337-
}, {});
338-
if (errorMessage)
339-
throw new Error(errorMessage);
330+
const injectedResult = await this._evaluateInUtility(({ injected, node }) => injected.focusNode(node), {});
331+
handleInjectedResult(injectedResult, '');
340332
}
341333

342334
async type(text: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) {
@@ -416,7 +408,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
416408
const stablePromise = this._evaluateInUtility(({ injected, node }, timeout) => {
417409
return injected.waitForDisplayedAtStablePosition(node, timeout);
418410
}, helper.timeUntilDeadline(deadline));
419-
await helper.waitWithDeadline(stablePromise, 'element to be displayed and not moving', deadline);
411+
const timeoutMessage = 'element to be displayed and not moving';
412+
const injectedResult = await helper.waitWithDeadline(stablePromise, timeoutMessage, deadline);
413+
handleInjectedResult(injectedResult, timeoutMessage);
420414
debugInput('...done');
421415
}
422416

@@ -434,7 +428,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
434428
const hitTargetPromise = this._evaluateInUtility(({ injected, node }, { timeout, point }) => {
435429
return injected.waitForHitTargetAt(node, timeout, point);
436430
}, { timeout: helper.timeUntilDeadline(deadline), point });
437-
await helper.waitWithDeadline(hitTargetPromise, 'element to receive pointer events', deadline);
431+
const timeoutMessage = 'element to receive pointer events';
432+
const injectedResult = await helper.waitWithDeadline(hitTargetPromise, timeoutMessage, deadline);
433+
handleInjectedResult(injectedResult, timeoutMessage);
438434
debugInput('...done');
439435
}
440436
}
@@ -446,3 +442,13 @@ export function toFileTransferPayload(files: types.FilePayload[]): types.FileTra
446442
data: file.buffer.toString('base64')
447443
}));
448444
}
445+
446+
function handleInjectedResult<T = undefined>(injectedResult: InjectedResult<T>, timeoutMessage: string): T {
447+
if (injectedResult.status === 'notconnected')
448+
throw new NotConnectedError();
449+
if (injectedResult.status === 'timeout')
450+
throw new TimeoutError(`waiting for ${timeoutMessage} failed: timeout exceeded`);
451+
if (injectedResult.status === 'error')
452+
throw new Error(injectedResult.error);
453+
return injectedResult.value as T;
454+
}

src/errors.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,10 @@ class CustomError extends Error {
2323
}
2424
}
2525

26+
export class NotConnectedError extends CustomError {
27+
constructor() {
28+
super('Element is not attached to the DOM');
29+
}
30+
}
31+
2632
export class TimeoutError extends CustomError {}

src/firefox/ffPage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { RawKeyboardImpl, RawMouseImpl } from './ffInput';
3131
import { FFNetworkManager, headersArray } from './ffNetworkManager';
3232
import { Protocol } from './protocol';
3333
import { selectors } from '../selectors';
34+
import { NotConnectedError } from '../errors';
3435

3536
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
3637

@@ -422,6 +423,10 @@ export class FFPage implements PageDelegate {
422423
frameId: handle._context.frame._id,
423424
objectId: toRemoteObject(handle).objectId!,
424425
rect,
426+
}).catch(e => {
427+
if (e instanceof Error && e.message.includes('Node is detached from document'))
428+
throw new NotConnectedError();
429+
throw e;
425430
});
426431
}
427432

src/frames.ts

Lines changed: 55 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import * as fs from 'fs';
1919
import * as util from 'util';
2020
import { ConsoleMessage } from './console';
2121
import * as dom from './dom';
22-
import { TimeoutError } from './errors';
22+
import { TimeoutError, NotConnectedError } from './errors';
2323
import { Events } from './events';
24-
import { assert, helper, RegisteredListener } from './helper';
24+
import { assert, helper, RegisteredListener, debugInput } from './helper';
2525
import * as js from './javascript';
2626
import * as network from './network';
2727
import { Page } from './page';
@@ -693,72 +693,82 @@ export class Frame {
693693
return result!;
694694
}
695695

696+
private async _retryWithSelectorIfNotConnected<R>(
697+
selector: string, options: types.TimeoutOptions,
698+
action: (handle: dom.ElementHandle<Element>, deadline: number) => Promise<R>): Promise<R> {
699+
const deadline = this._page._timeoutSettings.computeDeadline(options);
700+
while (!helper.isPastDeadline(deadline)) {
701+
try {
702+
const { world, task } = selectors._waitForSelectorTask(selector, 'attached', deadline);
703+
const handle = await this._scheduleRerunnableTask(task, world, deadline, `selector "${selector}"`);
704+
const element = handle.asElement() as dom.ElementHandle<Element>;
705+
try {
706+
return await action(element, deadline);
707+
} finally {
708+
element.dispose();
709+
}
710+
} catch (e) {
711+
if (!(e instanceof NotConnectedError))
712+
throw e;
713+
debugInput('Element was detached from the DOM, retrying');
714+
}
715+
}
716+
throw new TimeoutError(`waiting for selector "${selector}" failed: timeout exceeded`);
717+
}
718+
696719
async click(selector: string, options: dom.ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
697-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
698-
await handle.click(helper.optionsWithUpdatedTimeout(options, deadline));
699-
handle.dispose();
720+
await this._retryWithSelectorIfNotConnected(selector, options,
721+
(handle, deadline) => handle.click(helper.optionsWithUpdatedTimeout(options, deadline)));
700722
}
701723

702724
async dblclick(selector: string, options: dom.MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
703-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
704-
await handle.dblclick(helper.optionsWithUpdatedTimeout(options, deadline));
705-
handle.dispose();
725+
await this._retryWithSelectorIfNotConnected(selector, options,
726+
(handle, deadline) => handle.dblclick(helper.optionsWithUpdatedTimeout(options, deadline)));
706727
}
707728

708729
async fill(selector: string, value: string, options: types.NavigatingActionWaitOptions = {}) {
709-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
710-
await handle.fill(value, helper.optionsWithUpdatedTimeout(options, deadline));
711-
handle.dispose();
730+
await this._retryWithSelectorIfNotConnected(selector, options,
731+
(handle, deadline) => handle.fill(value, helper.optionsWithUpdatedTimeout(options, deadline)));
712732
}
713733

714-
async focus(selector: string, options?: types.TimeoutOptions) {
715-
const { handle } = await this._waitForSelectorInUtilityContext(selector, options);
716-
await handle.focus();
717-
handle.dispose();
734+
async focus(selector: string, options: types.TimeoutOptions = {}) {
735+
await this._retryWithSelectorIfNotConnected(selector, options,
736+
(handle, deadline) => handle.focus());
718737
}
719738

720-
async hover(selector: string, options?: dom.PointerActionOptions & types.PointerActionWaitOptions) {
721-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
722-
await handle.hover(helper.optionsWithUpdatedTimeout(options, deadline));
723-
handle.dispose();
739+
async hover(selector: string, options: dom.PointerActionOptions & types.PointerActionWaitOptions = {}) {
740+
await this._retryWithSelectorIfNotConnected(selector, options,
741+
(handle, deadline) => handle.hover(helper.optionsWithUpdatedTimeout(options, deadline)));
724742
}
725743

726-
async selectOption(selector: string, values: string | dom.ElementHandle | types.SelectOption | string[] | dom.ElementHandle[] | types.SelectOption[], options?: types.NavigatingActionWaitOptions): Promise<string[]> {
727-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
728-
const result = await handle.selectOption(values, helper.optionsWithUpdatedTimeout(options, deadline));
729-
handle.dispose();
730-
return result;
744+
async selectOption(selector: string, values: string | dom.ElementHandle | types.SelectOption | string[] | dom.ElementHandle[] | types.SelectOption[], options: types.NavigatingActionWaitOptions = {}): Promise<string[]> {
745+
return await this._retryWithSelectorIfNotConnected(selector, options,
746+
(handle, deadline) => handle.selectOption(values, helper.optionsWithUpdatedTimeout(options, deadline)));
731747
}
732748

733-
async setInputFiles(selector: string, files: string | types.FilePayload | string[] | types.FilePayload[], options?: types.NavigatingActionWaitOptions): Promise<void> {
734-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
735-
const result = await handle.setInputFiles(files, helper.optionsWithUpdatedTimeout(options, deadline));
736-
handle.dispose();
737-
return result;
749+
async setInputFiles(selector: string, files: string | types.FilePayload | string[] | types.FilePayload[], options: types.NavigatingActionWaitOptions = {}): Promise<void> {
750+
await this._retryWithSelectorIfNotConnected(selector, options,
751+
(handle, deadline) => handle.setInputFiles(files, helper.optionsWithUpdatedTimeout(options, deadline)));
738752
}
739753

740-
async type(selector: string, text: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) {
741-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
742-
await handle.type(text, helper.optionsWithUpdatedTimeout(options, deadline));
743-
handle.dispose();
754+
async type(selector: string, text: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
755+
await this._retryWithSelectorIfNotConnected(selector, options,
756+
(handle, deadline) => handle.type(text, helper.optionsWithUpdatedTimeout(options, deadline)));
744757
}
745758

746-
async press(selector: string, key: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) {
747-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
748-
await handle.press(key, helper.optionsWithUpdatedTimeout(options, deadline));
749-
handle.dispose();
759+
async press(selector: string, key: string, options: { delay?: number } & types.NavigatingActionWaitOptions = {}) {
760+
await this._retryWithSelectorIfNotConnected(selector, options,
761+
(handle, deadline) => handle.press(key, helper.optionsWithUpdatedTimeout(options, deadline)));
750762
}
751763

752-
async check(selector: string, options?: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
753-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
754-
await handle.check(helper.optionsWithUpdatedTimeout(options, deadline));
755-
handle.dispose();
764+
async check(selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
765+
await this._retryWithSelectorIfNotConnected(selector, options,
766+
(handle, deadline) => handle.check(helper.optionsWithUpdatedTimeout(options, deadline)));
756767
}
757768

758-
async uncheck(selector: string, options?: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
759-
const { handle, deadline } = await this._waitForSelectorInUtilityContext(selector, options);
760-
await handle.uncheck(helper.optionsWithUpdatedTimeout(options, deadline));
761-
handle.dispose();
769+
async uncheck(selector: string, options: types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
770+
await this._retryWithSelectorIfNotConnected(selector, options,
771+
(handle, deadline) => handle.uncheck(helper.optionsWithUpdatedTimeout(options, deadline)));
762772
}
763773

764774
async waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: types.WaitForFunctionOptions & types.WaitForElementOptions = {}, arg?: any): Promise<js.JSHandle | null> {
@@ -773,14 +783,6 @@ export class Frame {
773783
return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout)));
774784
}
775785

776-
private async _waitForSelectorInUtilityContext(selector: string, options?: types.WaitForElementOptions): Promise<{ handle: dom.ElementHandle<Element>, deadline: number }> {
777-
const { waitFor = 'attached' } = options || {};
778-
const deadline = this._page._timeoutSettings.computeDeadline(options);
779-
const { world, task } = selectors._waitForSelectorTask(selector, waitFor, deadline);
780-
const result = await this._scheduleRerunnableTask(task, world, deadline, `selector "${selectorToString(selector, waitFor)}"`);
781-
return { handle: result.asElement() as dom.ElementHandle<Element>, deadline };
782-
}
783-
784786
async waitForFunction<R, Arg>(pageFunction: types.Func1<Arg, R>, arg: Arg, options?: types.WaitForFunctionOptions): Promise<types.SmartHandle<R>>;
785787
async waitForFunction<R>(pageFunction: types.Func1<void, R>, arg?: any, options?: types.WaitForFunctionOptions): Promise<types.SmartHandle<R>>;
786788
async waitForFunction<R, Arg>(pageFunction: types.Func1<Arg, R>, arg: Arg, options: types.WaitForFunctionOptions = {}): Promise<types.SmartHandle<R>> {

src/helper.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { TimeoutError } from './errors';
2424
import * as types from './types';
2525

2626
export const debugError = debug(`pw:error`);
27+
export const debugInput = debug('pw:input');
2728

2829
export type RegisteredListener = {
2930
emitter: EventEmitter;
@@ -346,6 +347,10 @@ class Helper {
346347
return seconds * 1000 + (nanoseconds / 1000000 | 0);
347348
}
348349

350+
static isPastDeadline(deadline: number) {
351+
return deadline !== Number.MAX_SAFE_INTEGER && this.monotonicTime() >= deadline;
352+
}
353+
349354
static timeUntilDeadline(deadline: number): number {
350355
return Math.min(deadline - this.monotonicTime(), 2147483647); // 2^31-1 safe setTimeout in Node.
351356
}

0 commit comments

Comments
 (0)