microsoft/playwright · error · InvalidSelectorError

"${parts[0].name}" selector cannot be first

Error message

"${parts[0].name}" selector cannot be first

What it means

After parsing, if the very first part of the selector is a nested engine name (internal:has, internal:has-not, internal:and, internal:or, internal:chain, left-of, right-of, above, below, near) Playwright rejects it. These engines are relative — they need a base selector to qualify, they cannot stand alone as the first token.

Source

Thrown at packages/isomorphic/selectorParser.ts:87

            throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
          distance = unescaped[1];
        }
      } catch (e) {
        throw new InvalidSelectorError(`Malformed selector: ${part.name}=` + part.body);
      }
      const nested = { name: part.name, source: part.body, body: { parsed: parseSelector(innerSelector), distance } };
      const lastFrame = [...nested.body.parsed.parts].reverse().find(part => part.name === 'internal:control' && part.body === 'enter-frame');
      const lastFrameIndex = lastFrame ? nested.body.parsed.parts.indexOf(lastFrame) : -1;
      // Allow nested selectors to start with the same frame selector.
      if (lastFrameIndex !== -1 && selectorPartsEqual(nested.body.parsed.parts.slice(0, lastFrameIndex + 1), parts.slice(0, lastFrameIndex + 1)))
        nested.body.parsed.parts.splice(0, lastFrameIndex + 1);
      parts.push(nested);
      continue;
    }
    parts.push({ ...part, source: part.body });
  }
  if (kNestedSelectorNames.has(parts[0].name))
    throw new InvalidSelectorError(`"${parts[0].name}" selector cannot be first`);
  return {
    capture: parsedStrings.capture,
    parts
  };
}

// Splits a selector into per-frame chunks separated by "enter-frame" boundaries in non-piercing mode.
// In piercing mode, "enter-frame" tokens are preserved, so `chunks` holds a single chunk.
export function splitSelectorByFrame(selectorText: string, pierceByDefault?: boolean): { pierce: boolean, chunks: ParsedSelector[] } {
  const selector = parseSelector(selectorText);
  const chunks: ParsedSelector[] = [];
  let chunk: ParsedSelector = {
    parts: [],
  };
  let pierce = !!pierceByDefault;
  let pierceToken = false;
  let chunkStartIndex = 0;
  for (let i = 0; i < selector.parts.length; ++i) {

View on GitHub (pinned to c8fc3bf8d3)

Solutions

  1. Put a concrete selector (css/text/role) before the nested engine: 'div >> internal:has=...'.
  2. Use the locator API which enforces a base: page.locator('div').filter({ has: ... }).
  3. Reorder selector construction so relative engines always follow a base element.

Example fix

// before
await page.locator('internal:has=["span"]').click();

// after
await page.locator('div').filter({ has: page.locator('span') }).click();
Defensive patterns

Strategy: try-catch

Validate before calling

const NESTED = new Set(['internal:has','internal:has-not','internal:and','internal:or','internal:chain','left-of','right-of','above','below','near']);
function startsWithConcreteSelector(sel: string): boolean {
  const first = sel.split('>>')[0].trim();
  const name = first.split('=')[0].replace(/^\*/, '');
  return !NESTED.has(name);
}

Try / catch

try { await page.locator(sel).click(); }
catch (e) { if (isInvalidSelectorError(e) && /cannot be first/.test(e.message)) { sel = 'div >> ' + sel; } else throw e; }

Prevention

When it happens

Trigger: Writing a selector that starts with a combinator engine, e.g. internal:has=..., or near=[...], with no preceding concrete selector. Equivalent userland: calling .has()/.and() on nothing, or building a selector string that begins with a relative engine.

Common situations: Building selectors dynamically and prepending the wrong fragment; converting a chained locator to a string and dropping the base; refactors that left a relative engine at the head.

Related errors


AI-assisted analysis of microsoft/playwright@c8fc3bf8d3 (2026-08-12). Data as JSON: /api/errors/0a108c1b5c6858e0. Report an issue: GitHub.