mirror of
https://github.com/hanzoai/puppeteer.git
synced 2026-09-24 17:02:14 +00:00
perf: optimize Locator.fill performance for large text (#14620)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: OrKoN <[email protected]>
This commit is contained in:
committed by
GitHub
co-authored by
google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
OrKoN
parent
f5ddb3c9df
commit
b042a80281
@@ -1088,6 +1088,13 @@ Generic launch options that can be passed when launching any browser.
|
||||
</td></tr>
|
||||
<tr><td>
|
||||
|
||||
<span id="locatorfilloptions">[LocatorFillOptions](./puppeteer.locatorfilloptions.md)</span>
|
||||
|
||||
</td><td>
|
||||
|
||||
</td></tr>
|
||||
<tr><td>
|
||||
|
||||
<span id="locatorscrolloptions">[LocatorScrollOptions](./puppeteer.locatorscrolloptions.md)</span>
|
||||
|
||||
</td><td>
|
||||
|
||||
@@ -13,7 +13,7 @@ class Locator {
|
||||
fill<ElementType extends Element>(
|
||||
this: Locator<ElementType>,
|
||||
value: string,
|
||||
options?: Readonly<ActionOptions>,
|
||||
options?: Readonly<LocatorFillOptions>,
|
||||
): Promise<void>;
|
||||
}
|
||||
```
|
||||
@@ -61,7 +61,7 @@ options
|
||||
|
||||
</td><td>
|
||||
|
||||
Readonly<[ActionOptions](./puppeteer.actionoptions.md)>
|
||||
Readonly<[LocatorFillOptions](./puppeteer.locatorfilloptions.md)>
|
||||
|
||||
</td><td>
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
sidebar_label: LocatorFillOptions
|
||||
---
|
||||
|
||||
# LocatorFillOptions interface
|
||||
|
||||
### Signature
|
||||
|
||||
```typescript
|
||||
export interface LocatorFillOptions extends ActionOptions
|
||||
```
|
||||
|
||||
**Extends:** [ActionOptions](./puppeteer.actionoptions.md)
|
||||
|
||||
## Properties
|
||||
|
||||
<table><thead><tr><th>
|
||||
|
||||
Property
|
||||
|
||||
</th><th>
|
||||
|
||||
Modifiers
|
||||
|
||||
</th><th>
|
||||
|
||||
Type
|
||||
|
||||
</th><th>
|
||||
|
||||
Description
|
||||
|
||||
</th><th>
|
||||
|
||||
Default
|
||||
|
||||
</th></tr></thead>
|
||||
<tbody><tr><td>
|
||||
|
||||
<span id="typingthreshold">typingThreshold</span>
|
||||
|
||||
</td><td>
|
||||
|
||||
`optional`
|
||||
|
||||
</td><td>
|
||||
|
||||
number
|
||||
|
||||
</td><td>
|
||||
|
||||
The number of characters to type before switching to a faster fill-out method.
|
||||
|
||||
</td><td>
|
||||
|
||||
`100`
|
||||
|
||||
</td></tr>
|
||||
</tbody></table>
|
||||
@@ -65,6 +65,18 @@ export interface ActionOptions {
|
||||
* @public
|
||||
*/
|
||||
export type LocatorClickOptions = ClickOptions & ActionOptions;
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface LocatorFillOptions extends ActionOptions {
|
||||
/**
|
||||
* The number of characters to type before switching to a faster fill-out
|
||||
* method.
|
||||
*
|
||||
* @defaultValue `100`
|
||||
*/
|
||||
typingThreshold?: number;
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@@ -400,9 +412,10 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
|
||||
#fill<ElementType extends Element>(
|
||||
this: Locator<ElementType>,
|
||||
value: string,
|
||||
options?: Readonly<ActionOptions>,
|
||||
options?: Readonly<LocatorFillOptions>,
|
||||
): Observable<void> {
|
||||
const signal = options?.signal;
|
||||
const typingThreshold = options?.typingThreshold ?? 100;
|
||||
const cause = new Error('Locator.fill');
|
||||
return this._wait(options).pipe(
|
||||
this.operators.conditions(
|
||||
@@ -453,68 +466,87 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
|
||||
)
|
||||
.pipe(
|
||||
mergeMap(inputType => {
|
||||
const fillDirectly = (): Observable<void> => {
|
||||
return from(handle.focus()).pipe(
|
||||
mergeMap(() => {
|
||||
return from(
|
||||
handle.evaluate((input, newValue) => {
|
||||
const element = input as HTMLElement;
|
||||
const currentValue = element.isContentEditable
|
||||
? element.innerText
|
||||
: (element as HTMLInputElement).value;
|
||||
if (currentValue === newValue) {
|
||||
return;
|
||||
}
|
||||
if (element.isContentEditable) {
|
||||
element.innerText = newValue;
|
||||
} else {
|
||||
(element as HTMLInputElement).value = newValue;
|
||||
}
|
||||
element.dispatchEvent(
|
||||
new Event('input', {bubbles: true}),
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new Event('change', {bubbles: true}),
|
||||
);
|
||||
}, value),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
switch (inputType) {
|
||||
case 'select':
|
||||
return from(handle.select(value).then(noop));
|
||||
case 'contenteditable':
|
||||
case 'typeable-input':
|
||||
return from(
|
||||
(
|
||||
handle as unknown as ElementHandle<HTMLInputElement>
|
||||
).evaluate((input, newValue) => {
|
||||
const currentValue = input.isContentEditable
|
||||
? input.innerText
|
||||
: input.value;
|
||||
if (value.length < typingThreshold) {
|
||||
return from(
|
||||
(
|
||||
handle as unknown as ElementHandle<HTMLInputElement>
|
||||
).evaluate((input, newValue) => {
|
||||
const element = input as HTMLElement;
|
||||
const currentValue = element.isContentEditable
|
||||
? element.innerText
|
||||
: (input as HTMLInputElement).value;
|
||||
|
||||
// Clear the input if the current value does not match the filled
|
||||
// out value.
|
||||
if (
|
||||
newValue.length <= currentValue.length ||
|
||||
!newValue.startsWith(input.value)
|
||||
) {
|
||||
if (input.isContentEditable) {
|
||||
input.innerText = '';
|
||||
} else {
|
||||
input.value = '';
|
||||
// Clear the input if the current value does not match the filled
|
||||
// out value.
|
||||
if (
|
||||
newValue.length <= currentValue.length ||
|
||||
!newValue.startsWith(currentValue)
|
||||
) {
|
||||
if (element.isContentEditable) {
|
||||
element.innerText = '';
|
||||
} else {
|
||||
(input as HTMLInputElement).value = '';
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
const originalValue = input.isContentEditable
|
||||
? input.innerText
|
||||
: input.value;
|
||||
|
||||
// If the value is partially filled out, only type the rest. Move
|
||||
// cursor to the end of the common prefix.
|
||||
if (input.isContentEditable) {
|
||||
input.innerText = '';
|
||||
input.innerText = originalValue;
|
||||
} else {
|
||||
input.value = '';
|
||||
input.value = originalValue;
|
||||
}
|
||||
return newValue.substring(originalValue.length);
|
||||
}, value),
|
||||
).pipe(
|
||||
mergeMap(textToType => {
|
||||
return from(handle.type(textToType));
|
||||
}),
|
||||
);
|
||||
// If the value is partially filled out, only type the rest. Move
|
||||
// cursor to the end of the common prefix.
|
||||
if (element.isContentEditable) {
|
||||
element.innerText = '';
|
||||
element.innerText = currentValue;
|
||||
} else {
|
||||
(input as HTMLInputElement).value = '';
|
||||
(input as HTMLInputElement).value = currentValue;
|
||||
}
|
||||
return newValue.substring(currentValue.length);
|
||||
}, value),
|
||||
).pipe(
|
||||
mergeMap(textToType => {
|
||||
if (!textToType) {
|
||||
return of(undefined);
|
||||
}
|
||||
return from(handle.type(textToType));
|
||||
}),
|
||||
);
|
||||
}
|
||||
return fillDirectly();
|
||||
case 'other-input':
|
||||
return from(handle.focus()).pipe(
|
||||
mergeMap(() => {
|
||||
return from(
|
||||
handle.evaluate((input, value) => {
|
||||
(input as HTMLInputElement).value = value;
|
||||
input.dispatchEvent(
|
||||
new Event('input', {bubbles: true}),
|
||||
);
|
||||
input.dispatchEvent(
|
||||
new Event('change', {bubbles: true}),
|
||||
);
|
||||
}, value),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return fillDirectly();
|
||||
case 'unknown':
|
||||
throw new Error(`Element cannot be filled out.`);
|
||||
}
|
||||
@@ -716,7 +748,7 @@ export abstract class Locator<T> extends EventEmitter<LocatorEvents> {
|
||||
fill<ElementType extends Element>(
|
||||
this: Locator<ElementType>,
|
||||
value: string,
|
||||
options?: Readonly<ActionOptions>,
|
||||
options?: Readonly<LocatorFillOptions>,
|
||||
): Promise<void> {
|
||||
return firstValueFrom(this.#fill(value, options));
|
||||
}
|
||||
|
||||
@@ -541,6 +541,55 @@ describe('Locator', function () {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should work for large text', async () => {
|
||||
const {page} = await getTestState();
|
||||
await page.setContent(html` <textarea></textarea> `);
|
||||
const largeText = 'a'.repeat(1000);
|
||||
await page.locator('textarea').fill(largeText);
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
return document.querySelector('textarea')?.value.length === 1000;
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should work for large text in contenteditable', async () => {
|
||||
const {page} = await getTestState();
|
||||
await page.setContent(html` <div contenteditable="true"></div> `);
|
||||
const largeText = 'a'.repeat(1000);
|
||||
await page.locator('div').fill(largeText);
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
return (
|
||||
(document.querySelector('div') as HTMLElement).innerText.length ===
|
||||
1000
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with a custom typing threshold', async () => {
|
||||
const {page} = await getTestState();
|
||||
await page.setContent(html` <input /> `);
|
||||
const text = 'abc';
|
||||
// threshold is 10, so it should type it.
|
||||
await page.locator('input').fill(text, {typingThreshold: 10});
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
return (document.querySelector('input') as HTMLInputElement).value;
|
||||
}),
|
||||
).toBe(text);
|
||||
|
||||
await page.setContent(html` <input /> `);
|
||||
// threshold is 2, so it should fill it directly.
|
||||
await page.locator('input').fill(text, {typingThreshold: 2});
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
return (document.querySelector('input') as HTMLInputElement).value;
|
||||
}),
|
||||
).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Locator.race', () => {
|
||||
|
||||
Reference in New Issue
Block a user