# How the Page Domain Manages Navigation and Frame Trees in Lightpanda

> Discover how Lightpanda's Page domain masterfully handles navigation and frame trees. Explore its hierarchical struct, async callbacks, and efficient frame management.

- Repository: [Lightpanda/browser](https://github.com/lightpanda-io/browser)
- Tags: internals
- Published: 2026-03-14

---

**The Page domain in Lightpanda uses a hierarchical Page struct that owns the full navigation lifecycle through async callbacks while maintaining parent-child frame relationships via lazy-sorted arrays and iframe element back-references.**

The Lightpanda browser implements the Chrome DevTools Protocol (CDP) Page domain through a core Zig struct that orchestrates every navigation request and maintains the hierarchical frame tree. This implementation handles everything from synthetic `about:blank` URLs to complex iframe hierarchies, ensuring proper document lifecycle events and thread-safe navigation scheduling.

## Navigation Lifecycle Implementation

### Initiating Navigation Requests

Navigation begins in `src/browser/Page.zig` through the `navigate` method, which serves as the primary entry point for all browsing context transitions.

```zig
pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void

```

This function, defined at lines 422–470, accepts a URL and a `NavigateOpts` configuration struct that specifies the navigation reason (address bar, script, or iframe initialization), HTTP method, and CDP correlation ID. The Page struct maintains ownership of the entire navigation state machine until the `load` event fires or an error occurs.

### Synthetic and Network Navigation Paths

Lightpanda distinguishes between synthetic URLs and real network requests early in the navigation flow. For `about:blank` and `blob:` URLs, the browser bypasses the network stack entirely. At lines 438–474, the code checks these schemes, sets the origin and URL directly, and injects a blank document without creating an `HttpClient`.

For standard HTTP and HTTPS URLs, the implementation proceeds to lines 521–564, where it instantiates an `HttpClient`, appends cookies and Referer headers, and dispatches the request asynchronously. This split path ensures that synthetic browsing contexts initialize immediately while network requests proceed through the full header and data callback chain.

### Async Callback Architecture

The Page domain implements navigation as a series of async callbacks that progressively build document state:

- **`pageHeaderDoneCallback`** (lines 317–335): Processes HTTP response headers, updates the page URL if redirects occurred, and initializes the `Location` object with the final origin.
- **`pageDataCallback`** (lines 448–506): Receives the first data chunk to determine MIME type (`text/html`, `text/plain`, or image), then initializes the appropriate parser state.
- **`pageDoneCallback`** (lines 507–580): Parses the complete response buffer, finalizes the document object model, executes static scripts, and fires the `load` event to signal completion.
- **`pageErrorCallback`** (lines 587–595): Sets the internal state to `.err` and generates a minimal error page when network failures or protocol errors occur.

### Deferred Navigation Scheduling

To prevent re-entrant navigation during JavaScript execution, the Page domain provides `scheduleNavigation` at lines 569–581. When scripts trigger navigation (e.g., via `window.location.href`), the browser creates a `QueuedNavigation` object and defers the actual `navigate` call to the next tick of the session’s task queue. This ensures that the current script context completes before the browsing context transitions.

## Frame Tree Hierarchy Management

### Parent-Child Relationships

Each `Page` instance maintains bidirectional links within the frame tree through three key fields defined in `src/browser/Page.zig`:

```zig
pub const Page = struct {
    parent: ?*Page = null,
    frames: std.ArrayList(*Page) = .{},
    frames_sorted: bool = true,
    iframe: ?*IFrame = null,
    // ...
};

```

The `parent` pointer identifies the root browsing context when `null`, while `frames` stores direct children. The `iframe` field provides a reverse link to the `<iframe>` DOM element that created the frame, enabling document-order sorting and DOM integration.

### IFrame Integration and Creation

When the HTML parser encounters an `<iframe>` element, it invokes `Page.iframeAddedCallback`. This function creates a fresh `Page` instance using the session’s arena allocator, assigns a unique frame ID via `session.nextFrameId()`, and registers the child in the parent’s `frames` array. The callback then resolves the iframe’s `src` attribute—handling relative URLs, `about:blank`, and named targets—and initiates navigation with reason `initialFrameNavigation`.

The implementation establishes bidirectional bindings by setting `page_frame.iframe` to the DOM element and `iframe._window` to the new page’s window object, ensuring that DOM operations can traverse between the element and its browsing context.

### Document-Order Sorting

Frame trees must maintain deterministic ordering for indexed access via `window.frames`. The `frames_sorted` boolean flag tracks whether the `frames` array matches DOM document order. When `Window.getFrame` (implemented in `src/browser/webapi/Window.zig`) detects an unsorted state, it performs a lazy sort using `compareDocumentPosition` on the underlying iframe elements:

```zig
std.mem.sort(*Page, frames, {}, struct {
    fn lessThan(_: void, a: *Page, b: *Page) bool {
        const iframe_a = a.iframe orelse return false;
        const iframe_b = b.iframe orelse return true;
        const pos = iframe_a.asNode().compareDocumentPosition(iframe_b.asNode());
        return (pos & 0x04) != 0;
    }
}.lessThan);

```

This approach avoids sorting overhead during rapid DOM mutations, only incurring the cost when script explicitly accesses frames by index.

### Navigation Safety in Subtrees

To prevent navigation conflicts during parent page transitions, the Page domain implements `isGoingAway()`. This method recursively checks the `_queued_navigation` field of the current page and all ancestors up to the root. If any page in the hierarchy has a pending navigation, the function returns `true`, signaling that child frames should abort new navigations because their browsing context is being replaced.

Target resolution for navigation sources (such as `target="_parent"` or `target="_top"`) is handled by `resolveTargetPage` at lines 885–902, which maps target names to the appropriate `*Page` instance or returns `null` for `_blank` targets.

## CDP Page Domain Integration

The CDP implementation in `src/cdp/domains/page.zig` forwards protocol commands directly to the core Page methods. When a client sends a **Navigate** or **Reload** command, the CDP domain constructs a `NavigateOpts` struct with the command ID attached to the `cdp_id` field, allowing the browser to correlate completion events with the originating client request.

```zig
var opts = NavigateOpts{
    .cdp_id = cmd.id,
    .reason = .address_bar,
    .method = .GET,
    .force = false,
};
try page.navigate(target_url, opts);

```

This architecture ensures that CDP-driven navigation behaves identically to user-initiated or script-initiated navigation, sharing the same callback lifecycle and frame tree updates.

## Summary

- The **Page struct** in `src/browser/Page.zig` serves as the central navigation controller, handling both synthetic URLs and HTTP requests through distinct code paths at lines 438–474 and 521–564.
- Navigation occurs via **async callbacks** (`pageHeaderDoneCallback`, `pageDataCallback`, `pageDoneCallback`, `pageErrorCallback`) that progressively build document state and fire lifecycle events like `load`.
- The **frame tree** uses a parent-pointer hierarchy with `std.ArrayList(*Page)` for children, supporting dynamic iframe insertion through `iframeAddedCallback` and bidirectional element links via `iframe: ?*IFrame`.
- **Document ordering** is maintained lazily using the `frames_sorted` flag and DOM `compareDocumentPosition` when accessed via `Window.getFrame`, minimizing sort overhead during DOM mutations.
- **Navigation safety** is enforced by `isGoingAway()`, which checks ancestor pages for pending navigations to prevent subtree transitions during parent replacement.
- **CDP commands** integrate directly with Page methods, using `NavigateOpts.cdp_id` to correlate responses with client request IDs in `src/cdp/domains/page.zig`.

## Frequently Asked Questions

### How does Lightpanda handle navigation triggered by JavaScript?

Lightpanda uses `scheduleNavigation` (lines 569–581) to defer navigation to the next tick of the session's task queue. When JavaScript modifies `window.location` or submits a form, the browser queues the navigation rather than executing it immediately, preventing re-entrant state changes during script execution.

### What happens when a new iframe element is inserted into the DOM?

The `Page.iframeAddedCallback` function creates a fresh `Page` instance, registers it in the parent’s `frames` array, and establishes bidirectional links between the iframe element and the new page window. It then immediately navigates the frame to its `src` URL or `about:blank` using `initialFrameNavigation` options.

### How does the frame tree maintain correct ordering for indexed access?

The frame tree uses a **lazy sorting** mechanism. When `Window.getFrame` detects that `frames_sorted` is false, it sorts the `frames` array using `compareDocumentPosition` on the underlying DOM nodes to match document order, then sets the flag to true until the next DOM mutation.

### What prevents a child frame from navigating while its parent is unloading?

The **`isGoingAway()`** method recursively checks the `_queued_navigation` field of the current page and all ancestors. If any ancestor has a pending navigation, the method returns true, signaling that the frame should not initiate new navigations because the browsing context is being replaced.