microsoft/playwright · error · InvalidSelectorError

" " is only allowed as the first selector token, while…

Error message

"${part.body}" is only allowed as the first selector token, while parsing selector ${selectorText}

What it means

splitSelectorByFrame rejects a selector where the pierce-frames / no-pierce-frames control token appears anywhere except position 0. Piercing applies to the whole selector, so the control token is only meaningful as the very first token.

Solutions

  1. Move the pierce/no-pierce control token to the absolute start of the selector string.
  2. Prefer the frame-piercing API option rather than authoring the control token by hand.
  3. If you do not need cross-frame piercing, remove the token entirely.

Example fix

// before
await page.locator('div >> internal:control=pierce-frames >> span').click();

// after
await page.locator('internal:control=pierce-frames >> div >> span').click();
Defensive patterns

Strategy: validation

Validate before calling

function pierceTokenIsFirst(sel: string): boolean {
  const parts = sel.split('>>').map(p => p.trim());
  const nonFirstPierce = parts.slice(1).some(p => /internal:control=(pierce|no-pierce)-frames/.test(p));
  return !nonFirstPierce;
}

Try / catch

try { await page.locator(sel).click(); }
catch (e) { if (isInvalidSelectorError(e) && /only allowed as the first/.test(e.message)) { sel = movePierceToFront(sel); } else throw e; }

Prevention

When it happens

Trigger: Authoring a selector like 'div >> internal:control=pierce-frames' or placing pierce mode after other tokens. The pierce/no-pierce token must be the leading token.

Common situations: Dynamically concatenating a pierce prefix in the wrong order; refactoring frame-piercing selectors and moving the token; mixing manual pierce tokens with locator chaining.

Related errors


AI-assisted analysis of microsoft/playwright@de214f440b (2026-09-01). Data as JSON: /api/errors/361565cfc2784c7b. Report an issue: GitHub.

Appendix: source

Thrown at packages/isomorphic/selectorParser.ts:121

// Matches in any frame of the subtree, instead of the frame itself. Only allowed as the first token.
export const kAnyFrameSelector = 'internal:control=any-frame';

// Splits a selector into per-frame chunks separated by "enter-frame" boundaries.
// The optional leading "any-frame" token is consumed and reported separately.
export function splitSelectorByFrame(selectorText: string): { anyFrame: boolean, chunks: ParsedSelector[] } {
  const selector = parseSelector(selectorText);
  const chunks: ParsedSelector[] = [];
  let chunk: ParsedSelector = {
    parts: [],
  };
  let anyFrame = false;
  let chunkStartIndex = 0;
  for (let i = 0; i < selector.parts.length; ++i) {
    const part = selector.parts[i];
    if (part.name === 'internal:control' && part.body === 'any-frame') {
      // The starting frame applies to the whole selector, so the token only makes sense as the very first one.
      if (i !== 0)
        throw new InvalidSelectorError(`"${part.body}" is only allowed as the first selector token, while parsing selector ${selectorText}`);
      anyFrame = true;
      chunkStartIndex = i + 1;
      continue;
    }
    if (part.name === 'internal:control' && part.body === 'enter-frame') {
      const lastPart = chunk.parts[chunk.parts.length - 1];
      if (!lastPart || (lastPart.name === 'internal:control' && lastPart.body === 'enter-frame'))
        throw new InvalidSelectorError('Selector cannot start with entering frame, select the iframe first');
      chunks.push(chunk);
      chunk = { parts: [] };
      chunkStartIndex = i + 1;
      continue;
    }
    if (selector.capture === i)
      chunk.capture = i - chunkStartIndex;
    chunk.parts.push(part);
  }
  if (!chunk.parts.length) {

View on GitHub (pinned to de214f440b)