fix(locator): compose first() and nth() on an already narrowed locator

LH
1d ago
+76-0

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 when descriptor.nth is set. A narrowed locator matches one element, and first() of a one-element set is that element.
  • nth(0) on a narrowed locator also returns it unchanged, for the same reason.
  • nth(k) with k > 0 on a narrowed locator throws a RangeError naming the index it already carries. Such a set matches nothing, and LocatorDescriptor carries a single nth, 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 testResult
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 PR6 passed / 6
Reverting only locator.ts, keeping the test3 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/tests here (the workspace needs a full pnpm install), so the new test file has not been executed by the repo's runner, and tsc --noEmit -p tsconfig.json has not been run on it. It mirrors the existing test's imports and Page construction exactly for that reason.
  • No browser run: the change is descriptor-level, and whether the runtime resolves nth: 2 against the full match list is unchanged by this PR.
  • The Python SDK (locator.py, same single nth) and the wire schema are untouched; a nth(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() and nth(0) on a narrowed locator return it unchanged.
  • nth(k) with k > 0 on a narrowed locator throws a RangeError instead 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 LocatorDescriptorSchema whenever it is written.