Skip to content

[Bug]: expect() renders a full-page aria snapshot on every poll — concurrent assertions on hidden elements saturate the page main thread (1.60 regression) #41098

Description

Version

1.60.0 (regression vs 1.59.1)

Environment

  • @playwright/test 1.60.0
  • Reproduced on bundled Firefox 150; the mechanism is injected JS, so Chromium and WebKit are affected too (in our CI the same tests timed out on all three browsers).
  • Node 18+, Linux.

Steps to reproduce

Minimal, self-contained repro with scripts: https://github.com/egfx-notifications/pw-concurrent-expect-repro

A page builds a normal-sized DOM (~3,000 nodes) plus N elements that start hidden and become visible after a short delay. The test then waits for all N with toBeVisible, either concurrently (Promise.all) or sequentially.

./scripts/1-install.sh
./scripts/2-measure-stock.sh      # sequential (control) vs concurrent
./scripts/3-patch-playwright.sh   # neutralise the per-poll aria snapshot
./scripts/4-measure-patched.sh

Expected

Awaiting many auto-retrying assertions concurrently should be roughly as fast as awaiting them sequentially.

Actual

The concurrent case is dramatically slower and scales linearly with DOM size (Firefox 150, N=40 assertions, targets hidden ~1.5s):

DOM (~nodes) concurrent (Promise.all) sequential (control)
~900 5.0 s ~1.8 s
~3,000 13.3 s ~1.8 s
~6,000 24.7 s ~1.8 s

On a real app page (a soccer standings table with 18 teams, asserting each row's logo becomes visible) this turned a ~0.8 s step into ~48 s.

Root cause

Introduced in #40390 (commit d8378c3). InjectedScript.expect() now calls _ariaSnapshotForExpect() on every poll attempt, eagerly, so a failure message is ready in case the matcher ends up failing:

async expect(element, options, elements) {
  const core = await this._expectCore(element, options, elements);
  const ariaSnapshot = this._ariaSnapshotForExpect(element, options); // every poll
  ...
}

For a not-yet-visible / missing element, _ariaSnapshotForExpect renders a full-page aria tree of document.body:

// Element missing or hidden — fall back to a full-page snapshot for context.
return this._renderAriaSnapshot(this.document.body, { mode: 'default' });

So when N auto-retrying assertions poll concurrently while their targets are still hidden, every poll of every assertion renders a full-page aria snapshot, saturating the page main thread — which in turn delays whatever makes the elements appear, so the assertions poll even longer.

Neutralising that single call (replacing const ariaSnapshot = this._ariaSnapshotForExpect(element, options); with const ariaSnapshot = void 0; in the shipped coreBundle.js) collapses the concurrent case back to the sequential time — verified by scripts 3/4 in the repro (concurrent ~13.3 s → ~1.9 s, nothing else changed). The call is still unconditional on main at the time of writing.

Suggested fix

Compute the aria snapshot lazily — only when the matcher reaches its final failure/timeout — rather than on every poll attempt.

Workaround (consumer side)

Avoid firing many auto-retrying assertions concurrently during a state transition: await them sequentially, or wait once for completion (e.g. a single toBeVisible on the last element) before asserting the rest in bulk.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions