Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Frames

A page is a tree of frames: one main frame (the top-level document) plus zero or more child frames (typically <iframe> elements). zendriver-rs exposes every frame as a first-class Frame handle with its own query and JS-evaluation surface, so you can drive iframe content with the same ergonomics as the top-level page.

The frame tree

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let main = tab.main_frame().await?;     // top-level document
let all  = tab.frames().await?;         // main + every attached child
println!("{} frames total", all.len());
for f in &all {
    println!("  - id={} main={} url={:?}",
             f.id(), f.is_main(), f.url().await);
}
Ok(()) }
}

Tab::main_frame() returns the top-level frame (lazily — the first call dispatches Page.getFrameTree and caches the result). Tab::frames() returns a snapshot of the main frame plus every attached child the tab currently tracks.

The registry observes Page.frameAttached / Page.frameDetached events, so the snapshot is current as of the last event drained from the session. Just-attached frames (within the same event loop tick as the parent's load event) may not appear until the event lands — poll briefly if you depend on a specific child being present immediately.

Looking up specific frames

Two convenience lookups for common cases:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
// By URL substring — useful for "the YouTube embed somewhere on this page".
if let Some(yt) = tab.frame_by_url("youtube.com").await? {
    yt.evaluate::<()>("document.querySelector('video').play()").await?;
}

// By name attribute — useful for legacy frame layouts that name their iframes.
if let Some(content) = tab.frame_by_name("content").await? {
    let el = content.find().css("h1").one().await?;
    println!("{}", el.inner_text().await?);
}
Ok(()) }
}

Tab::frame_by_url() does a substring match against each child frame's URL (useful when you don't know the exact path / query string). Tab::frame_by_name() reads the name attribute set by the parent <iframe name="...">.

Both return Option<Frame>. Iterate tab.frames().await? yourself for anything more elaborate.

Frame-scoped queries

A Frame has its own find / find_all / evaluate / evaluate_main — all scoped to that frame's document and execution context:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let main = tab.main_frame().await?;

let h1 = main.find().css("h1").one().await?;
println!("main frame h1 = {}", h1.inner_text().await?);

// Per-frame JS evaluation. Isolated world by default — same as
// Tab::evaluate.
let title: String = main.evaluate("document.title").await?;
println!("main frame title = {title}");
Ok(()) }
}

Frame::find() and Frame::evaluate() dispatch CDP calls bound to the frame's contextId, so the query runs against that frame's DOM, not the parent's. This is the lever for driving iframe content without hunting for cross-frame DOM access workarounds.

FindBuilder::in_frame

When you'd rather start the query from the [Tab] but target a specific Frame, use FindBuilder::in_frame():

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let yt = tab.frame_by_url("youtube.com").await?
    .ok_or_else(|| zendriver::ZendriverError::Other("no YT frame".into()))?;

let play = tab
    .find()
    .in_frame(&yt)            // re-target to the YT iframe
    .css(".ytp-play-button")
    .one()
    .await?;
play.click().await?;
Ok(()) }
}

This is precedence-equivalent to yt.find().css(...).one().await?. Use whichever reads better at the call site — in_frame shines when you're composing helpers that take a [Tab] and a Frame reference and want a single fluent chain.

Cross-origin iframes (OOPIFs)

A same-origin child iframe shares the parent's render process, so its DOM is reachable via the parent's CDP session. A cross-origin iframe (Out-Of-Process IFrame — OOPIF) gets its own render process, and Chrome exposes it as a separate CDP target that auto-attaches to the same browser connection.

zendriver's tab registrar wires OOPIF targets in automatically. From your code's perspective:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
tab.goto("https://example.com").await?;
// Same call as for same-origin frames — OOPIFs show up in the same
// `tab.frames()` snapshot.
for f in tab.frames().await? {
    println!("frame {} url={:?}", f.id(), f.url().await);
}

// And the same Frame::find call works regardless of which process
// hosts the iframe.
if let Some(ad) = tab.frame_by_url("doubleclick.net").await? {
    let _ = ad.find().css(".close").one().await?.click().await;
}
Ok(()) }
}

There's no special API and no attach_to_oopif-style call to remember. This is one of the major ergonomic wins over the WebDriver-derived libraries, where you usually have to manually switch frames or attach to the OOPIF's session by hand.

End-to-end example

This example loads a page that hosts a srcdoc iframe, enumerates every frame, then runs a query inside the child to prove that Frame::find resolves against the iframe's document (not the parent's):

//! Load a page hosting a `srcdoc` iframe, enumerate every [`Frame`] the tab
//! tracks, then run a frame-scoped query inside the child to prove that
//! `Frame::find` resolves against the iframe's own document — not the
//! parent.
//!
//! Demonstrates:
//!   - [`Tab::frames`] (snapshot of main + every attached child).
//!   - [`Frame::is_main`] / [`Frame::id`] / [`Frame::url`] (frame metadata).
//!   - [`Frame::find`] scoped to the iframe's document context.
//!
//! Uses `srcdoc` so the iframe stays same-origin and routes through the
//! standard same-session frame path (no OOPIF needed for a self-contained
//! example).
//!
//! `Page.frameAttached` is delivered asynchronously after navigation
//! completes — the loop below polls the registry briefly until the child
//! frame shows up.

use std::time::{Duration, Instant};

use zendriver::Browser;

const PAGE_HTML: &str = "data:text/html,\
<!doctype html><html><body>\
<h1>parent</h1>\
<iframe id='f' srcdoc=\"<button id='b'>hello from iframe</button>\"></iframe>\
</body></html>";

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();
    tab.goto(PAGE_HTML).await?;
    tab.wait_for_load().await?;

    // Wait for the child frame to register — polled because the
    // Page.frameAttached event fires after the parent's load completes.
    let deadline = Instant::now() + Duration::from_secs(5);
    let child = loop {
        let frames = tab.frames().await?;
        println!("frames so far: {}", frames.len());
        for f in &frames {
            println!(
                "  - id={} main={} url={:?}",
                f.id(),
                f.is_main(),
                f.url().await
            );
        }
        if let Some(child) = frames.into_iter().find(|f| !f.is_main()) {
            break child;
        }
        if Instant::now() >= deadline {
            return Err(zendriver::ZendriverError::Timeout(Duration::from_secs(5)));
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    };

    // Frame-scoped query: the button only exists in the iframe's document,
    // so this resolves only because `find` runs against the child's context.
    let btn = child.find().css("#b").one().await?;
    let text = btn.inner_text().await?;
    println!("iframe button text = {text:?}");

    browser.close().await?;
    Ok(())
}

Expected output:

frames so far: 2
  - id=... main=true url=Ok("data:text/html,...")
  - id=... main=false url=Ok("about:srcdoc")
iframe button text = "hello from iframe"

The srcdoc keeps the iframe same-origin so the example stays self-contained, but the API call shape is identical for cross-origin OOPIFs.

Frame lifecycle and auto-refresh

Frames navigate independently. When a child iframe navigates, its existing execution context is destroyed and a new one is created. Any Element handles you hold from the old context become stale.

zendriver's auto-refresh handles this transparently in most cases — the next method call on a stale handle re-resolves the original query against the new context. For repeated reads against a freshly-navigated iframe, just re-run the query against the Frame handle — the Frame itself stays valid across the navigation (the frameId is stable; only the contextId rotates).