From 1d8c27856229c92cd92cd4ae47d17c26592cdf03 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 19 Jun 2026 14:54:26 +0100 Subject: [PATCH] feat(locator): introduce Locator.waitForFunction() Waits until a predicate, called with the matching element as its first argument, returns a truthy value. The locator is re-resolved on every poll, so it tolerates the element being re-rendered. Useful for synchronization inside helpers without asserting. Closes https://github.com/microsoft/playwright/issues/41317 --- docs/src/api/class-locator.md | 47 +++++++++++ packages/playwright-client/types/types.d.ts | 84 +++++++++++++++++++ .../playwright-core/src/client/channels.d.ts | 6 +- packages/playwright-core/src/client/frame.ts | 2 +- .../playwright-core/src/client/locator.ts | 13 +++ .../playwright-core/src/protocol/validator.ts | 4 +- .../playwright-core/src/server/channels.d.ts | 6 +- .../src/server/dispatchers/frameDispatcher.ts | 7 +- packages/playwright-core/src/server/frames.ts | 39 +++++++-- packages/playwright-core/types/types.d.ts | 84 +++++++++++++++++++ packages/protocol/spec/frame.yml | 6 +- tests/page/locator-wait-for-function.spec.ts | 80 ++++++++++++++++++ utils/generate_types/overrides.d.ts | 6 ++ 13 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 tests/page/locator-wait-for-function.spec.ts diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 59a98671aec5e..02c95efa0ddbb 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -2794,3 +2794,50 @@ orderSent.WaitForAsync(); ### option: Locator.waitFor.timeout = %%-input-timeout-js-%% * since: v1.16 + +## async method: Locator.waitForFunction +* since: v1.62 + +Returns when [`param: expression`] returns a truthy value, called with the matching element as a first argument, and [`param: arg`] as a second argument. + +This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is re-resolved on each retry, so it tolerates the element being re-rendered while waiting. + +If [`param: expression`] returns a [Promise], this method will wait for the promise to resolve before checking its value. + +If [`param: expression`] throws or rejects, this method throws. + +**Usage** + +Wait for an attribute to appear: + +```js +const toggle = page.getByRole('button', { name: 'Menu' }); +await toggle.click(); +await toggle.waitForFunction(element => element.hasAttribute('aria-expanded')); +``` + +Passing argument to [`param: expression`]: + +```js +await page.getByTestId('status').waitForFunction((element, value) => { + return element.textContent === value; +}, 'Ready'); +``` + +### param: Locator.waitForFunction.expression = %%-evaluate-expression-%% +* since: v1.62 + +### param: Locator.waitForFunction.expression = %%-js-evaluate-pagefunction-%% +* since: v1.62 + +### param: Locator.waitForFunction.arg +* since: v1.62 +- `arg` ?<[EvaluationArgument]> + +Optional argument to pass to [`param: expression`]. + +### option: Locator.waitForFunction.timeout = %%-wait-for-function-timeout-%% +* since: v1.62 + +### option: Locator.waitForFunction.timeout = %%-wait-for-function-timeout-js-%% +* since: v1.62 diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index bfdaf7a20406b..8a92838ed566a 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -12983,6 +12983,90 @@ export interface Locator { * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression). */ evaluateAll(pageFunction: PageFunctionOn): Promise; + /** + * Returns when + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) returns + * a truthy value, called with the matching element as a first argument, and + * [`arg`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-arg) as a second argument. + * + * This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is + * re-resolved on each retry, so it tolerates the element being re-rendered while waiting. + * + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * returns a [Promise], this method will wait for the promise to resolve before checking its value. + * + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * throws or rejects, this method throws. + * + * **Usage** + * + * Wait for an attribute to appear: + * + * ```js + * const toggle = page.getByRole('button', { name: 'Menu' }); + * await toggle.click(); + * await toggle.waitForFunction(element => element.hasAttribute('aria-expanded')); + * ``` + * + * Passing argument to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression): + * + * ```js + * await page.getByTestId('status').waitForFunction((element, value) => { + * return element.textContent === value; + * }, 'Ready'); + * ``` + * + * @param pageFunction Function to be evaluated in the page context. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression). + * @param options + */ + waitForFunction(pageFunction: PageFunctionOn, arg: Arg, options?: { + timeout?: number; + }): Promise; + /** + * Returns when + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) returns + * a truthy value, called with the matching element as a first argument, and + * [`arg`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-arg) as a second argument. + * + * This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is + * re-resolved on each retry, so it tolerates the element being re-rendered while waiting. + * + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * returns a [Promise], this method will wait for the promise to resolve before checking its value. + * + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * throws or rejects, this method throws. + * + * **Usage** + * + * Wait for an attribute to appear: + * + * ```js + * const toggle = page.getByRole('button', { name: 'Menu' }); + * await toggle.click(); + * await toggle.waitForFunction(element => element.hasAttribute('aria-expanded')); + * ``` + * + * Passing argument to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression): + * + * ```js + * await page.getByTestId('status').waitForFunction((element, value) => { + * return element.textContent === value; + * }, 'Ready'); + * ``` + * + * @param pageFunction Function to be evaluated in the page context. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression). + * @param options + */ + waitForFunction(pageFunction: PageFunctionOn, options?: { + timeout?: number; + }): Promise; /** * **NOTE** Always prefer using [Locator](https://playwright.dev/docs/api/class-locator)s and web assertions over * [ElementHandle](https://playwright.dev/docs/api/class-elementhandle)s because latter are inherently racy. diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 3d116fac27996..e199f8e3b2950 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -2972,13 +2972,17 @@ export type FrameWaitForFunctionParams = { arg: SerializedArgument, timeout: number, pollingInterval?: number, + selector?: string, + strict?: boolean, }; export type FrameWaitForFunctionOptions = { isFunction?: boolean, pollingInterval?: number, + selector?: string, + strict?: boolean, }; export type FrameWaitForFunctionResult = { - handle: JSHandleChannel, + handle?: JSHandleChannel, }; export type FrameWaitForSelectorParams = { selector: string, diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index c6b9695ff997d..5699713a6be52 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -484,7 +484,7 @@ export class Frame extends ChannelOwner implements api.Fr arg: serializeArgument(arg), timeout: this._timeout(options), }); - return JSHandle.from(result.handle) as any as structs.SmartHandle; + return JSHandle.from(result.handle!) as any as structs.SmartHandle; } async title(): Promise { diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 89c892c540b12..297b2e6c5c837 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -20,6 +20,7 @@ import { escapeForTextSelector } from '@isomorphic/stringUtils'; import { isString } from '@isomorphic/rtti'; import { monotonicTime } from '@isomorphic/time'; import { ElementHandle } from './elementHandle'; +import { serializeArgument } from './jsHandle'; import { DisposableStub } from './disposable'; import type { ExpectResult, Frame } from './frame'; @@ -390,6 +391,18 @@ export class Locator implements api.Locator { await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options, timeout: this._frame._timeout(options) }); } + async waitForFunction(pageFunction: structs.PageFunctionOn, arg?: Arg, options?: TimeoutOptions): Promise { + await this._frame._channel.waitForFunction({ + selector: this._selector, + strict: true, + expression: String(pageFunction), + isFunction: typeof pageFunction === 'function', + arg: serializeArgument(arg), + timeout: this._frame._timeout(options), + pollingInterval: 100, + }); + } + async _expect(expression: string, options: FrameExpectParams): Promise { return this._frame._expect(expression, { diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 4a9b428e56fc3..54009cf996717 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1627,9 +1627,11 @@ scheme.FrameWaitForFunctionParams = tObject({ arg: tType('SerializedArgument'), timeout: tFloat, pollingInterval: tOptional(tFloat), + selector: tOptional(tString), + strict: tOptional(tBoolean), }); scheme.FrameWaitForFunctionResult = tObject({ - handle: tChannel(['ElementHandle', 'JSHandle']), + handle: tOptional(tChannel(['ElementHandle', 'JSHandle'])), }); scheme.FrameWaitForSelectorParams = tObject({ selector: tString, diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 01047a43f6491..62e46e447b35e 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -2975,13 +2975,17 @@ export type FrameWaitForFunctionParams = { arg: SerializedArgument, timeout: number, pollingInterval?: number, + selector?: string, + strict?: boolean, }; export type FrameWaitForFunctionOptions = { isFunction?: boolean, pollingInterval?: number, + selector?: string, + strict?: boolean, }; export type FrameWaitForFunctionResult = { - handle: JSHandleChannel, + handle?: JSHandleChannel, }; export type FrameWaitForSelectorParams = { selector: string, diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index f8b58d74ed22a..9ae0d8ffbebbb 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -255,7 +255,12 @@ export class FrameDispatcher extends Dispatcher { - return { handle: ElementHandleDispatcher.fromJSOrElementHandle(this, await this._frame.waitForFunctionExpression(progress, params.expression, params.isFunction, parseArgument(params.arg), params)) }; + const handle = await this._frame.waitForFunctionExpression(progress, params.expression, params.isFunction, parseArgument(params.arg), params); + if (params.selector !== undefined) { + handle.dispose(); + return {}; + } + return { handle: ElementHandleDispatcher.fromJSOrElementHandle(this, handle) }; } async title(params: channels.FrameTitleParams, progress: Progress): Promise { diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 1628143cb22a6..e1950db84336f 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1598,30 +1598,49 @@ export class Frame extends SdkObject { return { matches, received }; } - async waitForFunctionExpression(progress: Progress, expression: string, isFunction: boolean | undefined, arg: any, options: { pollingInterval?: number }, world: types.World = 'main'): Promise> { + async waitForFunctionExpression(progress: Progress, expression: string, isFunction: boolean | undefined, arg: any, options: { pollingInterval?: number, selector?: string, strict?: boolean }, world: types.World = 'main'): Promise> { if (typeof options.pollingInterval === 'number') assert(options.pollingInterval > 0, 'Cannot poll with non-positive interval: ' + options.pollingInterval); expression = js.normalizeEvaluationExpression(expression, isFunction); - return this.retryWithProgressAndTimeouts(progress, [100], async () => { - const context = world === 'main' ? await progress.race(this.mainContext()) : await progress.race(this.utilityContext()); - const injectedScript = await progress.race(context.injectedScript()); - const handle = await progress.race(injectedScript.evaluateHandle((injected, { expression, isFunction, polling, arg }) => { + if (options.selector !== undefined) + progress.log(`waiting for ${this._asLocator(options.selector)}`); + return this.retryWithProgressAndBackoff(progress, async (progress, continuePolling) => { + let injectedScript: js.JSHandle; + let info: SelectorInfo | undefined; + if (options.selector !== undefined) { + const resolved = await progress.race(this.selectors.resolveInjectedForSelector(options.selector, { strict: options.strict, mainWorld: true })); + if (!resolved) + return continuePolling; + injectedScript = resolved.injected; + info = resolved.info; + } else { + const context = world === 'main' ? await progress.race(this.mainContext()) : await progress.race(this.utilityContext()); + injectedScript = await progress.race(context.injectedScript()); + } + const handle = await progress.race(injectedScript.evaluateHandle((injected, { info, expression, isFunction, polling, arg }) => { let evaledExpression: any; const predicate = (): R => { + const args = [arg]; + if (info) { + const element = injected.querySelector(info.parsed, document, info.strict); + if (!element) + return undefined as any; + args.unshift(element); + } // NOTE: make sure to use `globalThis.eval` instead of `self.eval` due to a bug with sandbox isolation // in firefox. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1814898 let result = evaledExpression ?? globalThis.eval(expression); if (isFunction === true) { evaledExpression = result; - result = result(arg); + result = result(...args); } else if (isFunction === false) { result = result; } else { // auto detect. if (typeof result === 'function') { evaledExpression = result; - result = result(arg); + result = result(...args); } } return result; @@ -1652,12 +1671,14 @@ export class Frame extends SdkObject { next(); return { result, abort: () => aborted = true }; - }, { expression, isFunction, polling: options.pollingInterval, arg })); + }, { info, expression, isFunction, polling: options.pollingInterval, arg })); try { return await progress.race(handle.evaluateHandle(h => h.result)); } catch (error) { // Note: it is important to await "abort()" to prevent any side effects - // after this method returns. + // after this method returns. We intentionally do not race against progress + // here - it is already resolved/aborted, and the abort must run to completion. + // eslint-disable-next-line progress/await-must-use-progress await handle.evaluate(h => h.abort()).catch(() => {}); throw error; } finally { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index bfdaf7a20406b..8a92838ed566a 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12983,6 +12983,90 @@ export interface Locator { * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-evaluate-all-option-expression). */ evaluateAll(pageFunction: PageFunctionOn): Promise; + /** + * Returns when + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) returns + * a truthy value, called with the matching element as a first argument, and + * [`arg`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-arg) as a second argument. + * + * This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is + * re-resolved on each retry, so it tolerates the element being re-rendered while waiting. + * + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * returns a [Promise], this method will wait for the promise to resolve before checking its value. + * + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * throws or rejects, this method throws. + * + * **Usage** + * + * Wait for an attribute to appear: + * + * ```js + * const toggle = page.getByRole('button', { name: 'Menu' }); + * await toggle.click(); + * await toggle.waitForFunction(element => element.hasAttribute('aria-expanded')); + * ``` + * + * Passing argument to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression): + * + * ```js + * await page.getByTestId('status').waitForFunction((element, value) => { + * return element.textContent === value; + * }, 'Ready'); + * ``` + * + * @param pageFunction Function to be evaluated in the page context. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression). + * @param options + */ + waitForFunction(pageFunction: PageFunctionOn, arg: Arg, options?: { + timeout?: number; + }): Promise; + /** + * Returns when + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) returns + * a truthy value, called with the matching element as a first argument, and + * [`arg`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-arg) as a second argument. + * + * This is a generic way to wait for an element to reach a custom condition without asserting it. The locator is + * re-resolved on each retry, so it tolerates the element being re-rendered while waiting. + * + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * returns a [Promise], this method will wait for the promise to resolve before checking its value. + * + * If [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression) + * throws or rejects, this method throws. + * + * **Usage** + * + * Wait for an attribute to appear: + * + * ```js + * const toggle = page.getByRole('button', { name: 'Menu' }); + * await toggle.click(); + * await toggle.waitForFunction(element => element.hasAttribute('aria-expanded')); + * ``` + * + * Passing argument to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression): + * + * ```js + * await page.getByTestId('status').waitForFunction((element, value) => { + * return element.textContent === value; + * }, 'Ready'); + * ``` + * + * @param pageFunction Function to be evaluated in the page context. + * @param arg Optional argument to pass to + * [`pageFunction`](https://playwright.dev/docs/api/class-locator#locator-wait-for-function-option-expression). + * @param options + */ + waitForFunction(pageFunction: PageFunctionOn, options?: { + timeout?: number; + }): Promise; /** * **NOTE** Always prefer using [Locator](https://playwright.dev/docs/api/class-locator)s and web assertions over * [ElementHandle](https://playwright.dev/docs/api/class-elementhandle)s because latter are inherently racy. diff --git a/packages/protocol/spec/frame.yml b/packages/protocol/spec/frame.yml index eb0917e05e476..2ff4909e77f07 100644 --- a/packages/protocol/spec/frame.yml +++ b/packages/protocol/spec/frame.yml @@ -720,8 +720,12 @@ Frame: timeout: float # When present, polls on interval. Otherwise, polls on raf. pollingInterval: float? + # When present, first argument is the matched element. + selector: string? + strict: boolean? returns: - handle: JSHandle + # Missing when "selector" is present. + handle: JSHandle? flags: snapshot: true pause: true diff --git a/tests/page/locator-wait-for-function.spec.ts b/tests/page/locator-wait-for-function.spec.ts new file mode 100644 index 0000000000000..1c1489d1218bd --- /dev/null +++ b/tests/page/locator-wait-for-function.spec.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test as it, expect } from './pageTest'; + +it('should wait for an attribute to appear', async ({ page }) => { + await page.setContent(''); + await page.evaluate(() => setTimeout(() => document.querySelector('#toggle')!.setAttribute('aria-expanded', 'true'), 100)); + await page.locator('#toggle').waitForFunction(element => element.hasAttribute('aria-expanded')); +}); + +it('should return immediately when already truthy', async ({ page }) => { + await page.setContent('
yes
'); + expect(await page.locator('#target').waitForFunction(element => element.textContent === 'yes')).toBe(undefined); +}); + +it('should accept ElementHandle arguments', async ({ page }) => { + await page.setContent('
value
'); + const handle = await page.$('#b'); + await page.locator('#a').waitForFunction((element, other) => other.textContent === 'value', handle); +}); + +it('should accept string expression', async ({ page }) => { + await page.setContent('
yes
'); + await page.locator('#target').waitForFunction(`element => element.textContent === 'yes'`); +}); + +it('should resolve a promise returned by the predicate', async ({ page }) => { + await page.setContent('
yes
'); + await page.locator('#target').waitForFunction(async element => element.textContent === 'yes'); +}); + +it('should wait for element to appear and survive rerender', async ({ page }) => { + await page.setContent('nothing here'); + await page.evaluate(() => { + let count = 0; + let prev: Element | null = null; + const tick = () => { + ++count; + const next = document.createElement('div'); + next.id = 'target'; + next.textContent = String(count); + if (prev) + prev.remove(); + document.body.appendChild(next); + prev = next; + if (count < 3) + window.builtins.setTimeout(tick, 500); + }; + window.builtins.setTimeout(tick, 500); + }); + await page.locator('#target').waitForFunction(element => element.textContent === '3'); +}); + +it('should throw when predicate throws', async ({ page }) => { + await page.setContent('
no
'); + const error = await page.locator('#target').waitForFunction(() => { + throw new Error('oh my'); + }).catch(e => e); + expect(error.message).toContain('oh my'); +}); + +it('should throw on strict mode violation', async ({ page }) => { + await page.setContent('
1
2
'); + const error = await page.locator('div.x').waitForFunction(() => true).catch(e => e); + expect(error.message).toContain('strict mode violation'); +}); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index e2ec1db55e21d..ebe7b56269de8 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -200,6 +200,12 @@ export interface Locator { evaluateHandle(pageFunction: PageFunctionOn): Promise>; evaluateAll(pageFunction: PageFunctionOn, arg: Arg): Promise; evaluateAll(pageFunction: PageFunctionOn): Promise; + waitForFunction(pageFunction: PageFunctionOn, arg: Arg, options?: { + timeout?: number; + }): Promise; + waitForFunction(pageFunction: PageFunctionOn, options?: { + timeout?: number; + }): Promise; elementHandle(options?: { timeout?: number; }): Promise>;