microsoft/playwright · error · InvalidSelectorError
"${part.body}" is only allowed as the first selector token,
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.
Source
Thrown at packages/isomorphic/selectorParser.ts:110
}
// 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) {
const part = selector.parts[i];
if (part.name === 'internal:control' && (part.body === 'pierce-frames' || part.body === 'no-pierce-frames')) {
// Piercing 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}`);
pierce = part.body === 'pierce-frames';
pierceToken = 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');
if (pierce) {
chunk.parts.push(part);
continue;
}
chunks.push(chunk);
chunk = { parts: [] };
chunkStartIndex = i + 1;
continue;
}View on GitHub (pinned to c8fc3bf8d3)
Solutions
- Move the pierce/no-pierce control token to the absolute start of the selector string.
- Prefer the frame-piercing API option rather than authoring the control token by hand.
- 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
- Put pierce-frames/no-pierce-frames only at the very start of a selector.
- Prefer the frameLocator/pierce API over hand-authoring control tokens.
- Avoid dynamically inserting control tokens mid-selector.
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
- Selector cannot start with entering frame, select the iframe
- Selector cannot be empty when piercing frames, while parsing
- Can not *-capture inside a frame-piercing selector, while pa
- "${parts[0].name}" selector cannot be first
- Selector cannot end with entering frame, while parsing selec
AI-assisted analysis of microsoft/playwright@c8fc3bf8d3 (2026-08-12).
Data as JSON: /api/errors/361565cfc2784c7b.
Report an issue: GitHub.