microsoft/playwright · error · InvalidSelectorError

nth can only be the last locator when piercing frames…

Error message

nth can only be the last locator when piercing frames, while querying "${locator}"

What it means

In pierce-frames mode, the nth pseudo-part (>> nth=N) is only valid as the final part of a selector. If nth appears anywhere except the last position, InvalidSelectorError is thrown because the piercing resolver cannot reorder it.

Solutions

  1. Move nth to the end of the locator string.
  2. Re-record/re-generate the locator so nth is last.
  3. Wrap with frameLocator/locator API and call .first()/.nth() as a method on the final locator instead of an inline token.

Example fix

// before
page.locator('iframe >> button >> nth=0 >> span'); // nth not last
// after
page.frameLocator('iframe').locator('button').first().locator('span');
Defensive patterns

Strategy: validation

Validate before calling

// Ensure nth is only the last token when piercing
const parts = sel.split('>>').map(s => s.trim());
const nthIdx = parts.findIndex(p => p.startsWith('nth='));
if (nthIdx !== -1 && nthIdx !== parts.length - 1) throw new Error('nth must be last');

Type guard

null

Try / catch

null

Prevention

When it happens

Trigger: Using a piercing locator whose nth component is not last, e.g. locator('iframe >> button >> nth=0 >> span') with pierceFrames on, or chaining after nth.

Common situations: Authoring locators by hand with nth in the middle; generated locators from recorder placing nth before trailing parts; enabling piercing on existing nth-bearing locators.

Related errors


AI-assisted analysis of microsoft/playwright@500c9c822c (2026-08-12). Data as JSON: /api/errors/3354383261fc402b. Report an issue: GitHub.

Appendix: source

Thrown at packages/playwright-core/src/server/frameSelectors.ts:155

    for (const chunk of chunks) {
      visitAllSelectorParts(chunk, (part, nested) => {
        if (nested && part.name === 'internal:control' && part.body === 'enter-frame') {
          const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector);
          throw new InvalidSelectorError(`Frame locators are not allowed inside composite locators, while querying "${locator}"`);
        }
        if (nested && part.name === 'internal:control' && part.body === 'any-frame') {
          const locator = asLocator(this.frame._page.browserContext._browser.sdkLanguage(), selector);
          throw new InvalidSelectorError(`frameLocator() is not allowed inside composite locators, while querying "${locator}"`);
        }
      });
    }

    if (!anyFrame && !options.anyFrame) {
      const result = await this._resolveChainedSelector(this.frame, selector, options, chunks, scope, false /* noStall */);
      return result ? [result] : [];
    }

    const result: SelectorInFrame[] = [];
    const seenFrames = new Set<Frame>();
    for (const startFrame of await this._anyFrameCandidates(scope)) {
      const resolved = await this._resolveChainedSelector(startFrame, selector, options, chunks, startFrame === this.frame ? scope : undefined, startFrame !== this.frame /* noStall */);
      // Different starting frames may resolve into the same frame, e.g. with an aria-ref selector.
      if (!resolved || seenFrames.has(resolved.frame))
        continue;
      seenFrames.add(resolved.frame);
      result.push(resolved);
    }
    return result;
  }

  // All the frames the selector may start in: the frame subtree, restricted to the scope when given.
  private async _anyFrameCandidates(scope: ElementHandle | undefined): Promise<Frame[]> {
    const result: Frame[] = [];
    const collectSubtree = (frame: Frame) => {
      result.push(frame);
      for (const child of frame.childFrames())

View on GitHub (pinned to 500c9c822c)