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:
Alex Rudenko
2026-01-29 17:55:04 +01:00
committed by GitHub
co-authored by google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> OrKoN
parent f5ddb3c9df
commit b042a80281
5 changed files with 204 additions and 57 deletions
+7
View File
@@ -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>
+2 -2
View File
@@ -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&lt;[ActionOptions](./puppeteer.actionoptions.md)&gt;
Readonly&lt;[LocatorFillOptions](./puppeteer.locatorfilloptions.md)&gt;
</td><td>
+59
View File
@@ -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));
}
+49
View File
@@ -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', () => {