fix(locator): compose first() and nth() on an already narrowed locator
What is wrong
A Locator is described by one wire index, nth (schema), and both narrowing helpers write over it:
first(): Locator {
return this.nth(0);
}
nth(index: number): Locator {
const parsedDescriptor = LocatorDescriptorSchema.safeParse({ ...this.descriptor, nth: index });
...
}
So a locator that is already narrowed loses the element it was narrowed to:
page.locator("li").nth(2).first() // sends { selector: "li", nth: 0 } — the first li, not the third
page.locator("li").nth(1).nth(1) // sends { selector: "li", nth: 1 } — still the second li
In Playwright both calls narrow the elements the locator already matches, so nth(2).first() stays on the third li. Here a helper that receives an already narrowed locator and calls .first() silently acts on a different element, with no error — the worst shape for a bug in a browser driver.
What changed
packages/sdk-ts/src/locator.ts, two guards, no protocol change:
first()returns the locator unchanged whendescriptor.nthis set. A narrowed locator matches one element, andfirst()of a one-element set is that element.nth(0)on a narrowed locator also returns it unchanged, for the same reason.nth(k)withk > 0on a narrowed locator throws aRangeErrornaming the index it already carries. Such a set matches nothing, andLocatorDescriptorcarries a singlenth, so "matches nothing" cannot be expressed on the wire. The choice here is between refusing and silently acting on another element; this PR refuses.
Nothing else moved: an unnarrowed locator still narrows exactly as before, and the index is still validated by LocatorDescriptorSchema whenever it is written.
One question for the maintainers: if you would rather have Playwright's "matches nothing" than a throw, that needs the descriptor to express an empty match (a second index, or an explicit none), which spans packages/protocol and both SDKs. Say which you prefer and I will follow it — the first() half is independent either way.
Test evidence
New packages/sdk-ts/tests/locatorNarrowing.test.ts, in the style of the existing clientLocatorOptions.test.ts (a Page over a command client that throws on any RPC, so only descriptor construction is exercised). Six cases: first() on an unnarrowed locator, first() on nth(2), nth(0) on nth(2), nth(1) on nth(2) refusing, nth(3) on an unnarrowed locator, and the schema still rejecting a negative index.
Run against the SDK's Locator class in an isolated Vitest harness (Vitest 5.0.5, Node 22): the file imports the real locator.ts verbatim, with only its import specifiers pointed at local copies of LocatorDescriptorSchema / StagehandMethods / the type module.
| Source under test | Result |
|---|---|
main (9bb60e2) | 3 failed / 3 passed — the three composition cases (expected 0 to be 2, expected 1 to be 2, and "expected function to throw") |
| This PR | 6 passed / 6 |
Reverting only locator.ts, keeping the test | 3 failed / 3 passed again, the same three |
oxfmt --check is clean on both files.
Not verified
- I could not run the package's own
vitest run --root ../.. packages/sdk-ts/testshere (the workspace needs a fullpnpm install), so the new test file has not been executed by the repo's runner, andtsc --noEmit -p tsconfig.jsonhas not been run on it. It mirrors the existing test's imports andPageconstruction exactly for that reason. - No browser run: the change is descriptor-level, and whether the runtime resolves
nth: 2against the full match list is unchanged by this PR. - The Python SDK (
locator.py, same singlenth) and the wire schema are untouched; anth(2).first()chain through the Python SDK still replaces the index.
Summary by cubic
Fixes locator narrowing so first() and nth() compose on an already narrowed locator instead of overwriting its index. Previously page.locator("li").nth(2).first() sent nth: 0 and acted on the first li; now it keeps the third one.
first()andnth(0)on a narrowed locator return it unchanged.nth(k)withk > 0on a narrowed locator throws aRangeErrorinstead of silently retargeting another element, since the wire descriptor cannot express an empty match.- Unnarrowed locators narrow exactly as before; the index is still validated by
LocatorDescriptorSchemawhenever it is written.