chore: prepare parsed selectors to more tasks (#2696)

We currently have dispatchEventTask and waitForSelectorTask.
However, most selector-based operations make sense as tasks, to ensure
atomic execution, e.g. textContent(selector) or focus(selector).
This will fight hydration, elements recycling and other async issues.

In preparation, decouple tasks from selectors parsing so that
we can have common infrastructure for tasks.
This commit is contained in:
Dmitry Gozman 2020-06-24 17:03:28 -07:00 committed by GitHub
parent 39ce35e154
commit f111974ad6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 53 additions and 44 deletions

View file

@ -29,7 +29,6 @@ import * as types from './types';
import { BrowserContext } from './browserContext';
import { Progress, ProgressController } from './progress';
type ContextType = 'main' | 'utility';
type ContextData = {
contextPromise: Promise<dom.FrameExecutionContext>;
contextResolveCallback: (c: dom.FrameExecutionContext) => void;
@ -312,7 +311,7 @@ export class Frame {
private _parentFrame: Frame | null;
_url = '';
private _detached = false;
private _contextData = new Map<ContextType, ContextData>();
private _contextData = new Map<types.World, ContextData>();
private _childFrames = new Set<Frame>();
_name = '';
_inflightRequests = new Set<network.Request>();
@ -418,10 +417,10 @@ export class Frame {
return this._page._delegate.getFrameElement(this);
}
_context(contextType: ContextType): Promise<dom.FrameExecutionContext> {
_context(world: types.World): Promise<dom.FrameExecutionContext> {
if (this._detached)
throw new Error(`Execution Context is not available in detached frame "${this.url()}" (are you trying to evaluate?)`);
return this._contextData.get(contextType)!.contextPromise;
return this._contextData.get(world)!.contextPromise;
}
_mainContext(): Promise<dom.FrameExecutionContext> {
@ -460,10 +459,11 @@ export class Frame {
const { state = 'visible' } = options;
if (!['attached', 'detached', 'visible', 'hidden'].includes(state))
throw new Error(`Unsupported state option "${state}"`);
const { world, task } = selectors._waitForSelectorTask(selector, state);
const info = selectors._parseSelector(selector);
const task = selectors._waitForSelectorTask(info, state);
return this._runAbortableTask(async progress => {
progress.logger.info(`waiting for selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`);
const result = await this._scheduleRerunnableTask(progress, world, task);
const result = await this._scheduleRerunnableTask(progress, info.world, task);
if (!result.asElement()) {
result.dispose();
return null;
@ -480,7 +480,8 @@ export class Frame {
}
async dispatchEvent(selector: string, type: string, eventInit?: Object, options: types.TimeoutOptions = {}): Promise<void> {
const task = selectors._dispatchEventTask(selector, type, eventInit || {});
const info = selectors._parseSelector(selector);
const task = selectors._dispatchEventTask(info, type, eventInit || {});
return this._runAbortableTask(async progress => {
progress.logger.info(`Dispatching "${type}" event on selector "${selector}"...`);
const result = await this._scheduleRerunnableTask(progress, 'main', task);
@ -716,11 +717,12 @@ export class Frame {
selector: string, options: types.TimeoutOptions,
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>,
apiName: string): Promise<R> {
const info = selectors._parseSelector(selector);
return this._runAbortableTask(async progress => {
while (progress.isRunning()) {
progress.logger.info(`waiting for selector "${selector}"`);
const { world, task } = selectors._waitForSelectorTask(selector, 'attached');
const handle = await this._scheduleRerunnableTask(progress, world, task);
const task = selectors._waitForSelectorTask(info, 'attached');
const handle = await this._scheduleRerunnableTask(progress, info.world, task);
const element = handle.asElement() as dom.ElementHandle<Element>;
progress.cleanupWhenAborted(() => element.dispose());
const result = await action(progress, element);
@ -841,16 +843,16 @@ export class Frame {
this._parentFrame = null;
}
private _scheduleRerunnableTask<T>(progress: Progress, contextType: ContextType, task: SchedulableTask<T>): Promise<js.SmartHandle<T>> {
const data = this._contextData.get(contextType)!;
private _scheduleRerunnableTask<T>(progress: Progress, world: types.World, task: SchedulableTask<T>): Promise<js.SmartHandle<T>> {
const data = this._contextData.get(world)!;
const rerunnableTask = new RerunnableTask(data, progress, task);
if (data.context)
rerunnableTask.rerun(data.context);
return rerunnableTask.promise;
}
private _setContext(contextType: ContextType, context: dom.FrameExecutionContext | null) {
const data = this._contextData.get(contextType)!;
private _setContext(world: types.World, context: dom.FrameExecutionContext | null) {
const data = this._contextData.get(world)!;
data.context = context;
if (context) {
data.contextResolveCallback.call(null, context);
@ -863,20 +865,20 @@ export class Frame {
}
}
_contextCreated(contextType: ContextType, context: dom.FrameExecutionContext) {
const data = this._contextData.get(contextType)!;
_contextCreated(world: types.World, context: dom.FrameExecutionContext) {
const data = this._contextData.get(world)!;
// In case of multiple sessions to the same target, there's a race between
// connections so we might end up creating multiple isolated worlds.
// We can use either.
if (data.context)
this._setContext(contextType, null);
this._setContext(contextType, context);
this._setContext(world, null);
this._setContext(world, context);
}
_contextDestroyed(context: dom.FrameExecutionContext) {
for (const [contextType, data] of this._contextData) {
for (const [world, data] of this._contextData) {
if (data.context === context)
this._setContext(contextType, null);
this._setContext(world, null);
}
}

View file

@ -18,8 +18,15 @@ import * as dom from './dom';
import * as frames from './frames';
import { helper, assert } from './helper';
import * as js from './javascript';
import * as types from './types';
import { ParsedSelector, parseSelector } from './common/selectorParser';
export type SelectorInfo = {
parsed: ParsedSelector,
world: types.World,
selector: string,
};
export class Selectors {
readonly _builtinEngines: Set<string>;
readonly _engines: Map<string, { source: string, contentScript: boolean }>;
@ -51,20 +58,13 @@ export class Selectors {
this._engines.set(name, { source, contentScript });
}
private _needsMainContext(parsed: ParsedSelector): boolean {
return parsed.parts.some(({name}) => {
const custom = this._engines.get(name);
return custom ? !custom.contentScript : false;
});
}
async _query(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> {
const parsed = this._parseSelector(selector);
const context = this._needsMainContext(parsed) ? await frame._mainContext() : await frame._utilityContext();
const info = this._parseSelector(selector);
const context = await frame._context(info.world);
const injectedScript = await context.injectedScript();
const handle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
return injected.querySelector(parsed, scope || document);
}, { parsed, scope });
}, { parsed: info.parsed, scope });
const elementHandle = handle.asElement() as dom.ElementHandle<Element> | null;
if (!elementHandle) {
handle.dispose();
@ -79,22 +79,22 @@ export class Selectors {
}
async _queryArray(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise<js.JSHandle<Element[]>> {
const parsed = this._parseSelector(selector);
const info = this._parseSelector(selector);
const context = await frame._mainContext();
const injectedScript = await context.injectedScript();
const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
return injected.querySelectorAll(parsed, scope || document);
}, { parsed, scope });
}, { parsed: info.parsed, scope });
return arrayHandle;
}
async _queryAll(frame: frames.Frame, selector: string, scope?: dom.ElementHandle, allowUtilityContext?: boolean): Promise<dom.ElementHandle<Element>[]> {
const parsed = this._parseSelector(selector);
const context = !allowUtilityContext || this._needsMainContext(parsed) ? await frame._mainContext() : await frame._utilityContext();
const info = this._parseSelector(selector);
const context = await frame._context(allowUtilityContext ? info.world : 'main');
const injectedScript = await context.injectedScript();
const arrayHandle = await injectedScript.evaluateHandle((injected, { parsed, scope }) => {
return injected.querySelectorAll(parsed, scope || document);
}, { parsed, scope });
}, { parsed: info.parsed, scope });
const properties = await arrayHandle.getProperties();
arrayHandle.dispose();
@ -109,9 +109,8 @@ export class Selectors {
return result;
}
_waitForSelectorTask(selector: string, state: 'attached' | 'detached' | 'visible' | 'hidden'): { world: 'main' | 'utility', task: frames.SchedulableTask<Element | undefined> } {
const parsed = this._parseSelector(selector);
const task = async (context: dom.FrameExecutionContext) => {
_waitForSelectorTask(selector: SelectorInfo, state: 'attached' | 'detached' | 'visible' | 'hidden'): frames.SchedulableTask<Element | undefined> {
return async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, state }) => {
let lastElement: Element | undefined;
@ -139,13 +138,11 @@ export class Selectors {
return !visible ? undefined : continuePolling;
}
});
}, { parsed, state });
}, { parsed: selector.parsed, state });
};
return { world: this._needsMainContext(parsed) ? 'main' : 'utility', task };
}
_dispatchEventTask(selector: string, type: string, eventInit: Object): frames.SchedulableTask<undefined> {
const parsed = this._parseSelector(selector);
_dispatchEventTask(selector: SelectorInfo, type: string, eventInit: Object): frames.SchedulableTask<undefined> {
const task = async (context: dom.FrameExecutionContext) => {
const injectedScript = await context.injectedScript();
return injectedScript.evaluateHandle((injected, { parsed, type, eventInit }) => {
@ -155,7 +152,7 @@ export class Selectors {
injected.dispatchEvent(element, type, eventInit);
return element ? undefined : continuePolling;
});
}, { parsed, type, eventInit });
}, { parsed: selector.parsed, type, eventInit });
};
return task;
}
@ -168,14 +165,22 @@ export class Selectors {
}, { target: handle, name });
}
private _parseSelector(selector: string): ParsedSelector {
_parseSelector(selector: string): SelectorInfo {
assert(helper.isString(selector), `selector must be a string`);
const parsed = parseSelector(selector);
for (const {name} of parsed.parts) {
if (!this._builtinEngines.has(name) && !this._engines.has(name))
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
}
return parsed;
const needsMainWorld = parsed.parts.some(({name}) => {
const custom = this._engines.get(name);
return custom ? !custom.contentScript : false;
});
return {
parsed,
selector,
world: needsMainWorld ? 'main' : 'utility',
};
}
}

View file

@ -178,3 +178,5 @@ export type MouseMultiClickOptions = PointerActionOptions & {
delay?: number;
button?: MouseButton;
};
export type World = 'main' | 'utility';