Documentation

Frames & iframes

Modern pages embed iframes for ads, payment widgets, consent banners, chat widgets, third-party maps, captchas, social-login popups — the list keeps growing. WRC's job is to make all of that feel like one page. Every frame on a page — same-origin, cross-origin, in-process, out-of-process, nested seven levels deep — is addressed by a single opaque frameId string. Every locator, every action, every Wait takes that id the same way. There's no second API for cross-origin frames.

TL;DR
  • A frame is just a frameId string. Pass it to .InFrame(...) on a locator, or to EvaluateInFrame, or any action's inFrame option, and the call runs inside that frame.
  • Every result that touches a frame returns the frameId it acted in — Wait, Click, Fill, Evaluate, all of them. Most discovery happens by-the-way, no separate "list frames" call needed.
  • Wait(...).InAllFrames() is the everyday way to find an element when you don't know which frame it's in. The result tells you both the frameId and the backendNodeId.
  • GetPages is the explicit, recursive map of the whole frame tree — useful for inspection, rarely needed in the hot path.
  • Each frame carries an AbsoluteRect in root-viewport coordinates, even when it's an OOPIF nested several levels deep. Coordinate clicks land where you'd expect.

The flat frame model

FrameInfo is small on purpose — there is no frame object with methods to call, only data you read and a frameId you pass to the next call:

FieldWhat it tells you
frameIdThe id you hand to .InFrame(...) / EvaluateInFrame / *Opts.inFrame.
urlWhatever URL the frame currently has loaded.
isOOPIFtrue if the frame runs in a separate process (cross-origin). Informational only — behaviour is identical across same-origin and OOPIF.
hasJSContexttrue once the frame has an execution context attached. EvaluateInFrame only works on frames with this flag set.
isLoadingtrue while the frame is navigating. Pair with Wait rather than polling this.
isVisiblefalse for display:none or zero-sized frames — quick skip for "frames the user can't see".
absoluteRectPosition and size in root-viewport coordinates, even for OOPIFs nested deep. This is what makes coordinate clicks work uniformly across frames.
relativeRectPosition relative to the parent frame (always 0,0 for the main frame).
childrenNested frames, recursively.

The point of that table is what's not on it: no proxy handle, no "contentWindow" pointer, no per-frame CDP target you have to attach to. The frameId is the whole thing.

Discovery via Wait — the everyday case

In practice, you almost never enumerate the frame tree just to find something. Most of the time you know "this consent button shows up somewhere on the page" and Wait(...).InAllFrames() finds it for you, telling you which frame it landed in as a side effect:

r, err := browser.Wait(ctx, wrc.CSS("button.accept-all").InAllFrames())
if err != nil {
  log.Fatal(err)
}
// r.FrameId is exactly the id you'd pass to any frame-scoped call.
// r.BackendNodeId is the matched node, valid only inside r.FrameId.
_, _ = browser.Click(ctx, wrc.Node(r.BackendNodeId).InFrame(r.FrameId))

That is the canonical pattern, and it's how the rest of this guide expects you to discover frame ids. Click, Fill, Drag, MoveTo, ScrollTo, Evaluate — all of them return the frameId of the frame they ran in. As you script a flow, you accumulate the ids of the frames you've touched without ever asking for them explicitly.

Discovery via GetPages — the explicit map

When you need the whole picture — every frame currently on the page, the URL each one has loaded, where they sit, whether they're ready — GetPages returns it as a recursive tree:

pages, _ := browser.GetPages(ctx)
for _, p := range pages {
  fmt.Println("page:", p.Url, "main:", p.FrameTree.FrameId)
  walk(&p.FrameTree, 0)
}

func walk(f *wrc.FrameInfo, depth int) {
  indent := strings.Repeat("  ", depth)
  fmt.Printf("%s- %s (oopif=%v, jsctx=%v) %s\n",
      indent, f.FrameId, f.IsOOPIF, f.HasJSContext, f.Url)
  for _, c := range f.Children {
      walk(c, depth+1)
  }
}

A few things drop out of the tree that are surprisingly useful in scripts:

  • isOOPIF lets you spot third-party iframes (consent banners, ads, payment widgets) without parsing URLs. But don't branch on it for behaviour — the rest of the API works the same regardless.
  • hasJSContext is the only practical "is this frame ready enough to receive EvaluateInFrame?" flag. A freshly navigated frame can be in the tree before its execution context is attached.
  • isVisible is a quick filter for "frames the user can actually see" — skip the rest when you're rendering an agent observation.

Acting in a frame

Every locator has the same two frame modifiers, and they're how you say "this call targets that frame":

// Target a specific frame.
_, _ = browser.Click(ctx, wrc.CSS("button").InFrame(iframeId))

// Fan out across every frame.
_, _ = browser.Wait(ctx, wrc.CSS("input.search").InAllFrames())

A handful of actions also accept an inFrame option that wins over whatever the locator already had — Click, Fill, and the Select* variants. Handy when you want to reuse a base locator across several frames:

base := wrc.CSS("button.submit")  // no frame baked in
_, _ = browser.ClickWith(ctx, base, wrc.ClickOpts{InFrame: frameA})
_, _ = browser.ClickWith(ctx, base, wrc.ClickOpts{InFrame: frameB})

For everything else (MoveTo, ScrollTo, Drag*), the frame comes from the locator — bake it in with .InFrame(...) or .InAllFrames().

For arbitrary JS inside a specific frame, EvaluateInFrame is the direct route (see Evaluation for the return-value semantics).

The frame surface is intentionally uniform: same-origin or cross-origin, top-level or three levels deep — same calls, same return shape, no extra setup.

Coordinate clicks across frames

AbsoluteRect is in root-viewport coordinates, no matter how many OOPIF boundaries the frame is buried behind. That means coordinate-based actions (Click(At(x, y)), MoveTo(At(x, y))) work uniformly without you having to translate positions through any frame chain by hand:

pages, _ := browser.GetPages(ctx)
target := findFrame(&pages[0].FrameTree, "https://challenges.cloudflare.com")
if target == nil {
  log.Fatal("frame not found")
}

// Click the centre of the frame in root-viewport coordinates.
cx := target.AbsoluteRect.X + target.AbsoluteRect.Width/2
cy := target.AbsoluteRect.Y + target.AbsoluteRect.Height/2
_, _ = browser.Click(ctx, wrc.At(cx, cy))

That works for any frame, any depth, any origin — the chromium plumbing that would otherwise force you into per-frame coordinate transforms is just gone.

Multiple tabs and popups — today vs. tomorrow

GetPages is plural for a reason: the underlying browser context can hold more than one page (additional tabs, popups, target=_blank windows). Today, a session implicitly targets one page — the one it was rented with. Other pages show up in GetPages so you can see they exist, but switching the session to drive a different tab is not yet wired up.

That doesn't change much in practice: most automation work happens on a single page across its full frame tree, which is exactly what WRC supports cleanly. Multi-page (switch active tab / drive a popup) is on the roadmap — when it lands, the surface will extend rather than replace what's documented here.

Gotchas

  • Frame ids don't outlive their document. A navigation in a frame mints a new frameId. Re-discover via Wait(...).InAllFrames() or a fresh GetPages after anything that could reload the frame.
  • backendNodeIds are per-document. A Node(id) you got from a Wait result only makes sense paired with the FrameId from the same result. Don't carry a backendNodeId across navigations.
  • hasJSContext: false means EvaluateInFrame will fail. If you're getting odd errors right after a frame appears in the tree, wait for the actual element you care about with Wait instead of polling GetPages.
  • AllFrames ≠ a frameId. It is the sentinel "ALL_FRAMES" string. .InAllFrames() and inFrame: AllFrames use it; EvaluateInFrame does not accept it (use Wait(...).InAllFrames() first to discover which frame to evaluate in — covered in Evaluation).
  • Don't branch behaviour on isOOPIF. It's informational only. The API works identically whether the frame is cross-origin or not; branching on the flag tangles your code without changing what you can do.
See also