Problem
getByRole() matches elements a user cannot see. Playwright excludes anything hidden by display: none, visibility: hidden or aria-hidden from its role queries; Locator.byRole() applies no such filter, so hidden markup competes with the real element — and under strict mode it turns a query that would have worked into a throw.
const page = new Browser().newPage()
page.mainFrame.window.document.head.innerHTML = '<style>.closed { display: none }</style>'
page.mainFrame.window.document.body.innerHTML
= '<div class="closed"><button>Save</button></div><button>Save</button>'
page.getByRole('button', { name: 'Save' }).click()
// Error: 2 elements match getByRole("button"); use first(), last() or nth() to choose oneA real browser has one clickable Save button here. Playwright clicks it.
Why it matters
A closed modal, a collapsed menu, a tab panel that is not the active one — all of them leave their markup in the DOM. This is the ordinary shape of an app, not an edge case, so the failure lands on exactly the queries people write most.
It fails in both directions, and the quiet one is worse:
- Loudly, as above: two matches, strict mode throws, and the fix that suggests itself —
.first()— silently binds the test to the hidden element, since it comes first in document order. The test then passes while asserting on something invisible. - Quietly, when only the hidden copy exists: the locator resolves,
isVisible()is never consulted, and the assertion passes against markup no user could reach.
The information needed to fix it already exists. getComputedStyle() has resolved the cascade since 0.1.11, and BrowserPage._isRendered already walks the ancestor chain through it to answer exactly this question — byRole just does not ask.
Proposed scope
Filter byRole() matches to those an accessibility tree would expose:
- not
display: noneon the element or any ancestor - not
visibility: hiddenorcollapse - not
aria-hidden="true"on the element or any ancestor - not the
hiddenattribute
_isRendered covers the first two and the hidden attribute already; aria-hidden is the addition. Reusing it keeps one definition of "visible" rather than a second that can drift — which is how stacksjs/very-happy-dom#1600 happened.
Worth considering in the same pass: Playwright's includeHidden option, so the hidden case stays reachable deliberately rather than by accident.
Acceptance
- The reproduction above resolves one element and clicks it.
- A role query matches nothing when the only candidate is hidden, rather than returning it.
- Hiding an ancestor hides its descendants from role queries.
aria-hidden="true"excludes the subtree.- The other
getBy*helpers are considered too: they have the same problem and should not be left inconsistent withbyRole.