Scoping Locators with filter() and has
Repeating UI — table rows, product cards, notification items, list entries in a virtualized feed — produces locators that match ten elements when your test needs exactly one. Playwright refuses to guess which one you meant and throws a strict mode violation, and the usual reflex is to reach for nth(3), which binds the test to the render order of data you do not control. The durable answer is to describe the element by what it contains: filter({ hasText }) narrows by the text inside a candidate's subtree, and filter({ has }) narrows by whether a nested locator matches inside it. This page shows how to build those narrowing chains, how the four filter options differ, and how to combine them with and() and or() without reintroducing ambiguity.
Root cause: strict mode rejects an ambiguous candidate set
Every Playwright locator operates in strict mode, so any action or assertion that needs a single element fails when the query resolves to more than one node — the runner reports Error: strict mode violation: getByRole('row') resolved to 12 elements and lists the candidates it found. The mistake is treating that as a selector-precision problem and adding CSS specificity or an index, when the real issue is that the distinguishing signal lives inside the element rather than in its own tag, role, or attributes. filter() exists precisely for that case: it evaluates a predicate against each candidate's subtree and returns a locator over the survivors, which keeps the query semantic instead of positional. The technique sits alongside the other approaches in CSS & XPath Best Practices, under Reliable Selector Strategies for Playwright.
Minimal reproducible example
An orders table renders one row per order. Every row has the same role, the same class names, and a Cancel button that looks identical to eleven others. The first test fails; the second scopes down to the single row that matters.
import { test, expect } from '@playwright/test';
test('cancels the correct order', async ({ page }) => {
await page.goto('/orders');
// FAILS: strict mode violation — 12 rows carry a Cancel button.
// Error: strict mode violation: getByRole('button', { name: 'Cancel' })
// resolved to 12 elements
// await page.getByRole('button', { name: 'Cancel' }).click();
// Step 1 — gather the candidate set from the table, not the whole page.
const rows = page.getByRole('table', { name: 'Orders' }).getByRole('row');
// Step 2 — keep only rows whose subtree text contains the order number.
// hasText looks at the row and every descendant, so the order-id cell counts.
const row = rows.filter({ hasText: 'ORD-4821' });
// Step 3 — structural narrowing: only the row that also holds a Pending badge.
// The inner locator is created from `page` and is matched relative to each row.
const pendingRow = row.filter({ has: page.getByText('Pending', { exact: true }) });
// Step 4 — the chain now resolves to exactly one node, so the click is legal.
await pendingRow.getByRole('button', { name: 'Cancel' }).click();
await expect(pendingRow).toContainText('Cancelled');
});
The chain reads as a sentence — the Cancel button inside the pending row for order ORD-4821 — and it survives sorting, pagination, and the insertion of new orders above it, which an nth() index does not. That resilience is the same argument made for accessibility-first queries in Why getByRole Beats CSS Selectors in Modern Apps.
Filter options at a glance
filter() accepts four narrowing options plus a visibility switch, and choosing correctly between them is most of the skill. hasText and hasNotText test the candidate's rendered text; has and hasNot test whether a nested locator finds anything inside the candidate's subtree. All of them are additive — chaining two filter() calls is a logical AND — and all of them return a brand-new locator rather than mutating the original.
Two details about hasText catch people out. A string argument matches case-insensitively as a substring against normalized inner text, so hasText: 'total' also matches "Subtotal". Pass a regular expression when you need exactness or anchoring: filter({ hasText: /^Total: \$\d+\.\d{2}$/ }) is case-sensitive and anchored, which is what a currency assertion usually wants. The has option has its own contract: the inner locator is resolved relative to the outer candidate, and it must be built from page (or the same frame), not chained off the outer locator — passing a locator from a different frame raises Inner "has" locator must belong to the same frame. The inner locator is used only as a test; the resulting locator still points at the outer element.
Step-by-step fix
- Read the violation before changing anything. The strict mode error prints every candidate it resolved along with a suggested locator for each, such as
aka getByRole('row').filter({ hasText: 'ORD-4821' }). That list tells you exactly which attribute, text, or child differs between the candidates, and it is often faster than opening the app. Copying a suggestion verbatim is fine as a starting point, but read it first — the suggestion is generated from what is on screen at that instant, not from what is stable across runs. - Anchor the candidate set on the nearest stable container. Before filtering, scope the query to the region that owns the element:
page.getByRole('table', { name: 'Orders' }),page.getByRole('navigation'), or a component root exposed withdata-testid. Scoping first shrinks the candidate set, makes the eventual filter cheaper to evaluate, and stops an unrelated match elsewhere on the page from silently satisfying your filter. It also keeps chains readable when they are hoisted into the classes described in Page Object Model Design. - Narrow by content with
filter({ hasText }). Use it when the distinguishing signal is a value a user would read: an order number, an email address, a product name. Prefer a regular expression when the string is a prefix of something else, and remember that whitespace is normalized, so a label split across two spans still matches. Never filter on text that a translation layer or a formatting change would rewrite — filter on the identifier, not the human-facing adjective. - Narrow by structure with
filter({ has }). Use it when the signal is the presence of a child rather than a word: a row containing a checked checkbox, a card containing an image, a list item containing an enabled button. Build the inner locator frompage, keep it as specific as the child element deserves, and let Playwright evaluate it inside each candidate's subtree. Structural filters are the right tool when the visible text is identical across every candidate. - Subtract with
hasNotandhasNotText, then compose withand()andor(). The negative options remove candidates that contain something, which is the cleanest way to express "the row that is not disabled".locator.and()requires both locators to match the same element — useful for combining a role query with adata-testid.locator.or()matches either, which helps when a dialog renders as one of two components, but it can resolve to two nodes at once and reintroduce the very violation you were fixing. - Prove uniqueness, and keep
nth()as the last resort. Assertawait expect(chain).toHaveCount(1)while developing so an over-broad filter fails loudly instead of acting on the wrong node. Only when candidates are genuinely indistinguishable — repeated skeleton placeholders, identical grid cells — fall back tofirst(),last(), ornth(), and pair the index with a scoped ancestor so the index is meaningful within a small, ordered set rather than the whole document.
import { test, expect } from '@playwright/test';
test('composes filters over a product grid', async ({ page }) => {
await page.goto('/catalog');
const cards = page.getByTestId('product-grid').getByRole('listitem');
// Negative filter: everything except the sold-out cards.
const available = cards.filter({ hasNot: page.getByText('Sold out') });
// Structural filter: only cards that render a discount badge element.
const discounted = available.filter({ has: page.getByTestId('discount-badge') });
// and() requires ONE element to satisfy both locators simultaneously.
const addButton = discounted
.getByRole('button')
.and(page.getByTestId('add-to-cart'));
// Guard rail while developing: fail fast if the chain is still ambiguous.
await expect(discounted).toHaveCount(1);
await addButton.click();
await expect(page.getByRole('status')).toHaveText('Added to cart');
});
Troubleshooting variants
The filter matches nothing and the test times out
A filter that eliminates every candidate does not throw immediately — the locator simply never resolves, and you get Timeout 5000ms exceeded with the full chain printed under waiting for. Comment out the filters one at a time and call await locator.count() after each to see where the set collapses to zero. The usual causes are a hasText string that includes formatting the DOM does not contain (a thousands separator, a currency symbol rendered by a sibling element, a non-breaking space), or a has locator scoped so tightly that it never matches inside the candidate's subtree. Text extracted with innerText normalization differs from the raw markup, so compare against what textContent() returns, not against what you see in the page source.
The filter passes locally but is ambiguous in CI
A chain that resolves to one element against seeded local data can resolve to three against a shared CI database where duplicate records exist. Treat filter values as test data: create the record your test filters on inside the test or a fixture, and use a unique token — a timestamp or run identifier — in the value you match. The reverse also happens, where a row exists locally but not in CI, producing the timeout above. Data-driven ambiguity is one of the most common origins of the intermittent failures catalogued in Detecting and Fixing Flaky Playwright Tests.
The filter resolves before the list finishes rendering
Filters are re-evaluated on every retry of the action or assertion they feed, so a locator chain is not a snapshot — but a bare count() call is. Calling count() immediately after navigation returns whatever exists at that instant, often zero, because the list is still fetching. Gate on a web-first assertion first, such as await expect(rows).not.toHaveCount(0), or assert on a specific expected count, and let the assertion's retry loop absorb the render delay. The same reasoning applies to lists that re-mount during hydration, covered in Waiting Strategies for Dynamic React Components.
Verification
Verify a narrowing chain three ways before you trust it. First, assert the arity explicitly with await expect(chain).toHaveCount(1) in the test you are writing; if a future data change makes the filter ambiguous again, the failure names the count instead of surfacing as a mysterious click on the wrong row. Second, run the spec in UI mode and use the locator picker, which prints the resolved chain and highlights every match as you edit it — the workflow described in Debugging with Playwright Inspector and UI Mode. Third, open the recorded trace and inspect the action's locator entry: each filter() step appears in the resolved selector string, and the DOM snapshot at that moment shows the element that was actually acted on, which is the fastest way to confirm you narrowed to the node you intended. Reading those panels is covered in Analyzing Test Failures with the Playwright Trace Viewer.
For suites that extract rather than assert, the same chains feed row-level scraping — scope to the table, filter to the rows you want, then read cells — as shown in Extracting Tables and Lists to JSON with Playwright. And when you are weighing a filter chain against a hand-written expression, the trade-offs in CSS Selector vs getByRole: When to Use Each apply unchanged: the filter chain wins on readability and refactor safety, and the difference in execution cost is immaterial next to a single network round trip.
Frequently Asked Questions
What is the difference between filter({ has }) and the CSS :has() pseudo-class?
They express the same idea at different layers. Playwright's selector engine supports :has() inside a CSS string, so page.locator('tr:has(input:checked)') works, but the argument must be a CSS selector — it cannot be a role query, a text matcher, or another locator. filter({ has }) takes a full locator, so the inner test can itself be getByRole(), getByTestId(), or a chain of its own. Reach for :has() when the condition is purely structural CSS and for filter({ has }) whenever the inner condition benefits from Playwright's higher-level queries.
Does filter() run a second query against the page, and does it slow tests down?
filter() builds a new locator description rather than executing anything, so nothing is queried until an action or assertion resolves the chain. At that point Playwright evaluates the whole chain in a single pass inside the page, so a three-step chain is one round trip, not three. The cost is negligible compared with the auto-waiting the action performs anyway, and it is far cheaper than the alternative of fetching all candidates into the test process and looping over them in Node.
Why does my has locator throw about frames?
The locator passed to has or hasNot must belong to the same frame as the locator you are filtering, which is why it is created from page rather than chained off the outer locator. If you built the inner locator from a frameLocator() while the outer one came from page, Playwright raises Inner "has" locator must belong to the same frame. Create both from the same root, and note that the inner locator is always matched relative to each outer candidate, so it should be written as if the candidate were the document root.
Can I filter on something that is present in the DOM but hidden?
Yes, and that is a frequent source of surprise: hasText and has both match against nodes that exist in the DOM regardless of visibility, so a collapsed accordion panel or an off-screen menu item still satisfies the filter. Add filter({ visible: true }) to restrict the candidate set to visible elements, or make the inner locator itself visibility-aware. Auditing what is actually attached versus painted is usually quicker in a trace snapshot than by reading component source.