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

Multi-tab

Chrome opens with one tab. zendriver-rs treats every additional tab as a first-class Tab handle — the same type as the main tab, with the same query / input / evaluate surface. Tabs are tracked in a browser-wide registry that you can iterate, look up, or close from any clone of the Browser.

Opening tabs

Two constructors:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
// Open about:blank and return as soon as the registrar sees the new tab.
let blank = browser.new_tab().await?;

// Open a URL — equivalent to new_tab().await? then goto(url).await?
let live = browser.new_tab_at("https://example.com").await?;
live.wait_for_load().await?;
Ok(()) }
}

Browser::new_tab() and Browser::new_tab_at() both go through Target.createTarget at browser scope (no sessionId). Each returns a fully-initialised Tab:

  1. Page/DOM/Runtime/Network CDP domains enabled.
  2. Stealth bootstrap re-applied via the auto-attach observer chain.
  3. Isolated-world ready for evaluate() calls.

Internally, new_tab* polls the tab registry every 50 ms for up to 5 s waiting for the new target to register — typically returns within a few milliseconds. If the auto-attach observer crashes or is misconfigured, you'll get ZendriverError::TabNotFound after the 5 s window.

Iterating tabs

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
for tab in browser.tabs().await {
    println!("tab {}: {}", tab.target_id(), tab.url().await?);
}
Ok(()) }
}

Browser::tabs() returns a snapshot Vec<Tab> covering every currently-registered tab, including:

  • The main tab (the one Chrome opened with).
  • Tabs you opened via new_tab*.
  • Tabs page scripts opened via window.open(...) (auto-attach wires these into the registrar).

Order is unspecified — the registry is a HashMap keyed by sessionId. Tabs that close concurrently disappear from the snapshot on the next call.

Browser::tab_count() is the cheap len-read on the same registry — prefer it over browser.tabs().await.len() when you only need the count.

Activating a tab

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

Tab::activate() sends Target.activateTarget, which is what "clicking the tab in Chrome's tab strip" does. The activated tab becomes the visible tab in headed mode and the receiver of keyboard focus events.

Gotcha: inactive tabs don't receive input events

Chrome serializes physical input (mouse moves, key presses) through whichever tab currently has OS focus. CDP Input.dispatchMouseEvent and Input.dispatchKeyEvent calls route through the page's render process directly, so they do reach inactive tabs — clicks, typing, and the realistic Bezier-mouse path all work without activating first.

But events that flow back through the OS layer — e.g. fullscreen requests, clipboard reads, focus-trap behaviors that read document.hasFocus() — observe the OS-level active tab. If you hit a "works in active tab, breaks in background tab" issue, the cause is almost always document.hasFocus() returning false or a feature gated on document.visibilityState.

The fix is to activate the tab before the input sequence:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.new_tab_at("https://example.com").await?;
tab.activate().await?;
let btn = tab.find().css("button#go").one().await?;
btn.click().await?;
Ok(()) }
}

You can leave any of the other tabs inactive — activate only sets the OS focus to a single tab; it doesn't affect anyone else.

Closing tabs

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

Tab::close() consumes the Tab handle and sends Target.closeTarget. The registrar removes the entry from the browser-wide registry on the resulting Target.targetDestroyed event. Existing clones of the closed tab will error on their next CDP call with [ZendriverError::SessionClosed].

Closing every tab does not close the browser. To shut down the whole subprocess, call Browser::close() (see Quickstart).

End-to-end example

This example opens three tabs at distinct URLs, prints each tab's URL + title, then closes the whole browser (which tears down every tab):

//! Open three tabs at distinct URLs, iterate the [`Browser::tabs`] registry,
//! and print every tab's URL — the canonical P4 multi-tab smoke test.
//!
//! Demonstrates:
//!   - [`Browser::new_tab_at`] (open + navigate in one step).
//!   - [`Browser::tabs`] (snapshot of every live tab the registrar tracks,
//!     including the auto-attached `main_tab`).
//!   - [`Browser::tab_count`] (cheap len read on the same registry).
//!
//! Each opened tab is its own session — closing the [`Browser`] tears them
//! all down via the shared Connection drop path.

use zendriver::Browser;

#[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?;

    // `main_tab()` is the about:blank tab Chrome auto-opens at launch.
    // Drive it to a real URL so the printout is interesting.
    let main = browser.main_tab();
    main.goto("https://example.com").await?;
    main.wait_for_load().await?;

    // Open two more tabs at distinct origins; each call returns a fully
    // initialised Tab (Page/DOM/Runtime/Network domains enabled, stealth
    // applied, isolated world ready).
    let tab_b = browser
        .new_tab_at("data:text/html,<!doctype html><title>B</title><h1>tab B</h1>")
        .await?;
    tab_b.wait_for_load().await?;

    let tab_c = browser
        .new_tab_at("data:text/html,<!doctype html><title>C</title><h1>tab C</h1>")
        .await?;
    tab_c.wait_for_load().await?;

    // Pull the live snapshot from the registry and walk it.
    let tabs = browser.tabs().await;
    println!("tab_count = {}", browser.tab_count().await);
    for (i, tab) in tabs.iter().enumerate() {
        let url = tab.url().await?;
        let title = tab.title().await?;
        println!(
            "  [{i}] target={} url={url} title={title:?}",
            tab.target_id()
        );
    }

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

Run it with:

cargo run --example multi_tab -p zendriver

Expected output (target IDs vary):

tab_count = 3
  [0] target=B... url=data:text/html,... title="B"
  [1] target=C... url=data:text/html,... title="C"
  [2] target=A... url=https://example.com/ title="Example Domain"

Concurrency note

Tab is Clone + Send + Sync. You can spawn one worker per tab and drive them in parallel:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let urls = ["https://example.com", "https://example.org", "https://example.net"];
let mut handles = Vec::new();
for u in urls {
    let tab = browser.new_tab().await?;
    handles.push(tokio::spawn(async move {
        tab.goto(u).await?;
        tab.wait_for_load().await?;
        let title = tab.title().await?;
        Ok::<_, zendriver::ZendriverError>(title)
    }));
}
for h in handles {
    println!("{}", h.await.unwrap()?);
}
Ok(()) }
}

The transport actor serializes CDP frames across the WebSocket, so the underlying CDP traffic is sequenced — but every await point yields, letting other tab workers make progress. The aggregate throughput is limited by CDP RTT rather than your spawn count.