skip to content

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%

answer

  1. One is a reference, one a description
  2. The lookup happened once, long ago
  3. A re-render replaces the frame entirely
  4. Re-resolution is what survives the swap

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.

solid answer

~40 s

`page.frame('composer')` is a synchronous lookup over the frames attached right now: it returns `null` if the composer has not attached yet, and it never waits. What it hands back is a `Frame` bound to that specific frame, so a re-render that swaps the `<iframe>` leaves the object detached -- `frame.isDetached()` is `true` and calls reject saying the frame was detached. A `FrameLocator` from `page.frameLocator('iframe[name="composer"]')` holds no reference: it re-resolves the selector at every action, waits for the embed to appear, and picks up the replacement automatically. Keep `Frame` objects for inspection -- `page.frames()` to see what actually attached and each frame's `url()` -- and drive the UI through frame locators.

code

typescript · 8 lines
typescript
// Fragile: a reference to one live frame, looked up once.
const frame = page.frame('composer');          // null if not attached yet
await frame!.getByRole('textbox').fill('note'); // fails once the panel re-renders

// Durable: re-resolved from the selector on every call.
const composer = page.frameLocator('iframe[name="composer"]');
await composer.getByRole('textbox', { name: 'Comment body' }).fill('note');
await composer.getByRole('button', { name: 'Comment' }).click();

go deeper

for a junior

Take away the rule: look frames up as you use them, and prefer page.frameLocator with a selector over storing a Frame object anywhere in the test.

for a middle

Explain the mechanism: page.frame is a synchronous snapshot that can return null, while a frame locator re-resolves its selector at every action and so waits and recovers.

for a senior

Diagnose it. Recognise the detached-frame error as a re-render race, confirm with page.frames(), and land the fix as one shared frame locator rather than patching the one failing test.

for a principal

Treat it as a suite-wide reliability class, not a bug. Ban stored frame references in review, since this failure mode reappears as flake every time an embed is re-rendered.

## Two objects, two lifetimes Playwright exposes an embedded document twice, and the difference between them is a lifetime: - **`Frame`** -- a live object for one attached frame, obtained from `page.frames()` or `page.frame(nameOrOptions)`. It is a reference to *that* frame, the one that existed when you looked. - **`FrameLocator`** -- a lazy description built from a selector, obtained from `page.frameLocator(selector)` or `locator.contentFrame()`. It holds no reference at all; it re-resolves the selector every time you use it. A test that captures the first and then acts through it minutes later is holding a reference to something the application is free to destroy. ## What page.frame() actually does `page.frame('composer')` is a **synchronous** lookup over the frames currently attached to the page. It matches on the frame's name or on its URL, and: 1. it never waits -- if the composer iframe has not attached yet, the call returns `null` immediately; 2. it returns the frame that matched *now*, not a subscription to future frames; 3. the name it matches is `frame.name()`, which is the iframe's `name` attribute, falling back to `id` when `name` is empty; 4. that name is computed once, when the frame is created, and does not update if the attribute changes later. `page.frames()` is the raw list, and it **includes the main frame**, so `page.frames()[0]` is the page itself and any index you hard-code shifts the moment an ad slot or preview pane attaches. ## Why the handle goes stale Re-rendering the issue view -- switching tabs, saving a comment, a route change that rebuilds the panel -- removes the old `<iframe>` element and appends a new one. The old frame is detached: `frame.isDetached()` becomes `true`, and calls made through it reject with an error stating the frame was detached. Nothing rebinds it, because the object was never a description of "the composer"; it was a pointer to one particular frame. The failure is nastier than it looks in CI: - it is timing-dependent, so it passes locally and fails under load, - the error names a detached frame rather than the element you were after, - retries often mask it, which turns it into a flake rather than a bug. ## What a frame locator does instead `page.frameLocator('iframe[name="composer"]')` re-resolves at every action. If the embed was torn down and rebuilt, the next call finds the new one; if it has not appeared yet, the call waits for it under the action timeout. Playwright's own behaviour here is explicit: a chained action survives the iframe being removed and re-added, and survives the iframe navigating, while the call is in flight. | | `Frame` from `page.frame()` | `FrameLocator` | |---|---|---| | Resolution | once, at lookup time | on every use | | Missing frame | returns `null` at once | waits under the action timeout | | Frame replaced | detached; calls fail | picks up the new frame | | Identified by | name or URL of a live frame | a selector for the iframe element | ## When a Frame object is still right `Frame` is not a rudiment -- it answers questions a frame locator cannot: - **Inspection.** `page.frames()` tells you how many documents the issue view actually attached and what each one's `url()` is, which is the fastest way to see whether a third-party embed loaded the URL you expected. - **Identity.** Comparing frames, or checking `frame.isDetached()` while debugging a teardown race. - **URL matching.** `page.frame({ url: /partner-board/ })` finds a frame by where it came from when the markup gives you no stable selector. The rule of thumb: **inspect with `Frame`, interact with `FrameLocator`**. ## Fixing the test 1. Replace the captured frame with a frame locator built from a stable selector on the iframe element -- a `title`, a `name`, a test id on the embed. 2. Keep that frame locator as a single named constant, so the recovery is one edit and every test inherits it. 3. If you genuinely need the `Frame`, look it up immediately before use rather than storing it, and treat `null` as "not attached yet" rather than as a failure. 4. Where you must prove the embed exists first, assert on the iframe element itself with `expect(frameLocator.owner()).toBeVisible()` -- an assertion that retries, unlike a one-shot lookup.

  • What does page.frame('composer') return if the iframe has not attached yet?
    `null`, immediately. The lookup is synchronous over the frames currently attached and has no waiting behaviour, which is why a test that runs it too early crashes on a null dereference rather than waiting the way a locator would.
  • Is page.frames() still useful once you drive everything through frame locators?
    Yes, for inspection. It lists every attached frame including the main frame, so it answers how many documents the page really created and what URL each one loaded -- the quickest check when a third-party embed silently renders the wrong page.
  • Why is frame.name() an unreliable key for a frame added by client-side rendering?
    It is computed once when the frame is created, from the `name` attribute, falling back to `id` when `name` is empty, and it does not update if the attribute changes later. Code that sets the name after insertion will not be matched.

saying these in an interview costs you the question

  • Believing Playwright rebinds a Frame object when the iframe is replaced
  • Expecting page.frame to wait for a frame to attach
  • Indexing page.frames() by position and forgetting the main frame
  • Storing a Frame at test start and reusing it throughout
  • Blaming flaky CI rather than the captured frame reference