skip to content

Frame Traversal

A frame locator steps into an iframe and everything chained after it stays inside, with no switch and no switch back. Asked because it is a clean break from driver-style context switching.

on this pageshow

explore

questions

4

In Playwright, how do you click a button that renders inside an iframe?

level: juniorimportance: must knowfreq 74%

answer

  1. A second document inside the page
  2. Enter once, then keep chaining
  3. Nothing global changes, no switch back
  4. One frame locator per nesting level

basics

~20 s

Call page.frameLocator with a selector for the iframe, then chain the element locator onto it. Everything after the frame locator resolves inside that iframe, and locators built from page are unaffected, so there is nothing to switch back.

solid answer

~40 s

`page.frameLocator('#composer')` returns a `FrameLocator` -- a view into that iframe. Chain any locator onto it, such as `.getByRole('button', { name: 'Comment' })`, and the search runs inside the iframe's document instead of the top document. The chain is lazy: nothing resolves until you act or assert, so Playwright waits for the iframe element to appear, for its document to be ready, and for the target inside it, which means a lazily injected embed needs no explicit wait. Entering a frame changes nothing globally -- `page.getByRole(...)` still searches the main frame on the next line, so there is no switch back. For nested iframes, chain one frame locator per level. If you already hold a locator for the iframe element, `locator.contentFrame()` gives you the same `FrameLocator`.

code

typescript · 12 lines
typescript
import { test, expect } from '@playwright/test';

test('adds a comment from the rich-text composer', async ({ page }) => {
  await page.goto('/issues/PROJ-142');

  const composer = page.frameLocator('iframe[title="Comment composer"]');
  await composer.getByRole('textbox', { name: 'Comment body' }).fill('Cannot reproduce.');
  await composer.getByRole('button', { name: 'Comment' }).click();

  // Straight back outside the iframe - no switch needed.
  await expect(page.getByText('Comment added')).toBeVisible();
});

go deeper

for a junior

Know that elements inside an iframe are invisible to page-level locators, and that page.frameLocator plus a chained locator is the way in. Say the chain reads outermost first.

for a middle

Explain that the frame locator is lazy and re-resolved on every use, so one action waits for the iframe element, its document and the target element without any explicit wait step.

for a senior

Show where the frame boundary belongs in a page object: expose the composer as one reusable frame locator instead of repeating the iframe selector across twenty tests.

for a principal

Own the boundary argument. A third-party embed you do not control deserves a single seam in the suite so a vendor markup change is one edit, not a day of triage.

## The problem an iframe creates An `<iframe>` embeds a whole second document inside the page. On an issue tracker that is typically the rich-text comment composer, a metrics panel on the issue detail view, or a partner board embed. The DOM inside that document is not reachable from the top document, so a locator built from `page` -- `page.getByRole('button', { name: 'Comment' })` -- will never see it, however long it waits. Playwright's answer is the **frame locator**. `page.frameLocator(selector)` takes a selector that matches the **iframe element** in the current document and returns a `FrameLocator`: a view into the document that iframe hosts. Anything chained onto it resolves inside that document. ```ts const composer = page.frameLocator('iframe[title="Comment composer"]'); await composer.getByRole('textbox', { name: 'Comment body' }).fill('Cannot reproduce on staging.'); await composer.getByRole('button', { name: 'Comment' }).click(); ``` ## Chaining is the whole API - The chain reads **outermost first**: the page, then the iframe, then the element. - Everything after the frame locator -- `getByRole`, `getByText`, `locator`, `filter`, `nth` -- searches inside the iframe rather than the page. - A `FrameLocator` is **not a promise**. Nothing is queried when you build it, so you never `await` the frame locator itself; you await the action or assertion at the end of the chain. - It is reusable: store it once and hang many locators off it. Every use re-resolves the iframe from the selector. - Entering a frame changes **nothing globally**. `page.getByRole(...)` still searches the main frame on the very next line. ## What the chain waits for Because the chain is lazy, the waiting happens at the end and covers the whole path: 1. the iframe element matching your selector appears in the current document, 2. that iframe has a document to search, 3. the element you chained appears inside it and passes the action's actionability checks. A composer iframe injected a second after the issue page paints therefore needs no explicit wait step -- `composer.getByRole('button').click()` waits through all three under the one action timeout. The same re-resolution is why a chain survives the embed being torn down and rebuilt while the call is in flight: the selector is evaluated again, not a captured handle. ## Nested iframes There is one frame locator **per level**; you do not pass a compound selector to a single call. ```ts await page .frameLocator('#board-embed') .frameLocator('iframe[name="card-preview"]') .getByRole('link', { name: 'PROJ-142' }) .click(); ``` ## Three ways to name the frame | Expression | Starts from | Reach for it when | |---|---|---| | `page.frameLocator('#composer')` | a selector for the iframe element | the normal case | | `page.locator('#composer').contentFrame()` | a `Locator` you already hold | you already built or narrowed the iframe locator | | `page.frameLocator()` with no selector (Playwright 1.63) | any frame in the subtree | you would rather not name the iframe at all | ## Why there is no switch back Playwright keeps no "current frame" state on the page. Which document a locator searches is a property of **that locator chain**, not of the session, and that has consequences worth stating in an interview: - Two chains can address two different frames in adjacent lines with no ordering rules between them. - A helper that accepts a `FrameLocator` composes exactly like one that accepts a `Locator`, so page objects can hand out a frame-scoped root. - A failed action cannot leave the test "stuck inside" a frame, so there is no cleanup step and no leaked state between tests. ## Where it goes wrong - Pointing a frame locator at something that is not an iframe fails with an error saying an `<iframe>` was expected, printing the element it found instead -- usually the wrapper `<div>` around the embed. - A selector matching several iframes makes the frame locator ambiguous; name the specific embed, or index the iframe locator before converting it. - Assuming a page-level locator can still see inside after you entered a frame. It never could -- nothing was switched, so nothing was restored.

  • How would you reach a link inside an iframe that is itself inside another iframe?
    Chain one frame locator per level: `page.frameLocator('#board-embed').frameLocator('iframe[name="card-preview"]').getByRole('link', { name: 'PROJ-142' })`. There is no compound selector form; each call steps down exactly one document, and the whole chain still resolves lazily at the action.
  • What happens if the selector you pass to page.frameLocator matches a div rather than an iframe?
    The action fails on timeout with an error saying an `<iframe>` was expected, and the message prints the element that was actually matched -- commonly the wrapper `div` around the embed. The fix is to target the iframe element itself, not its container.
  • Does the test need to wait for the iframe to load before creating the frame locator?
    No. Building a `FrameLocator` queries nothing. The wait happens at the action or assertion and covers the iframe appearing, its document becoming available, and the target element inside it, all under the one action timeout.

It is a postal address, not a doorway you walk through: each locator carries the whole route, so no one has to walk back out.

saying these in an interview costs you the question

  • Saying you must switch back out of the frame after acting
  • Adding a manual wait for the iframe before building the frame locator
  • Expecting page.getByRole to find elements inside an iframe
  • Awaiting the frame locator itself as if it were a promise
  • Passing one compound selector instead of chaining per nesting level
open as a page

What do Playwright's locator.contentFrame() and frameLocator.owner() do?

level: middleimportance: should knowfreq 38%

basics

~20 s

They convert between the two views of an iframe. locator.contentFrame() turns a Locator for the iframe element into a FrameLocator for the document inside it, and frameLocator.owner() turns a FrameLocator back into a Locator for the iframe element.

open as a page

A Playwright test stores page.frame('composer') and later fails with a detached-frame error after the issue view re-renders. Why, and what should it use instead?

level: seniorimportance: should knowfreq 34%

basics

~20 s

A Frame object is a reference to one live frame. When the issue view removes and re-creates the iframe, that frame is detached and every call through it fails. A frame locator re-resolves its selector on each use instead.

open as a page