# How the Ego-Browser Snapshot and Ref System Persists Across Heredoc Rounds

> Discover how ego-browser's snapshot and ref system maintains self-healing ref maps across heredoc rounds for seamless DOM reference reuse without manual calls.

- Repository: [CitroLabs/ego-lite](https://github.com/citrolabs/ego-lite)
- Tags: internals
- Published: 2026-08-16

---

**Ego-Browser maintains a self-healing ref map across heredoc rounds by storing snapshot callbacks that automatically regenerate the `browserRefMap` whenever a stale `@N` reference is accessed, allowing JavaScript execution blocks to reuse DOM references without manual snapshot calls.**

The ego-browser snapshot and ref system enables persistent DOM interaction across multiple heredoc execution rounds in the citrolabs/ego-lite runtime. Unlike traditional browser automation that loses state between script executions, this architecture maintains a live browser instance with an in-memory reference map that automatically refreshes when needed. Understanding this mechanism is essential for building reliable automation scripts that span multiple stdin heredoc blocks.

## The Heredoc Execution Model and Persistent Runtime

When ego-browser executes user-supplied JavaScript, it streams code via heredoc on **stdin** while keeping the same browser runtime alive between blocks. This persistence means that global state—including the `browserRefMap`—survives across execution rounds. The system leverages **Chrome DevTools Protocol (CDP)** to maintain element references using `backendNodeId` values stored in ref entries.

## Snapshot Generation and Ref Population

### Capturing DOM State with snapshotRaw()

In [`src/driver/observe.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/observe.ts), the `snapshotRaw()` function orchestrates DOM observation by calling `ego.snapshot()`. This returns an object containing `content` and `refs`, where each ref includes a `backendNodeId`, `role`, `name`, and other accessibility metadata (lines 49‑63). These refs are indexed as `@N` identifiers that serve as stable handles to DOM elements.

### Building the In-Memory Ref Map

The `browserSnapshotRefsToRefMap()` function in [`src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts) (lines 309‑326) clears the global `browserRefMap` and populates it with new entries. Each entry maps a `backendNodeId` to its associated metadata, creating a lookup table that resolves `@N` strings to actual CDP node identifiers. This map lives in memory for the duration of the browser runtime session.

## The Self-Healing Ref Resolution Loop

The system implements a lazy refresh mechanism that ensures refs remain valid without requiring manual snapshot calls between heredoc rounds.

### Registering the Refresh Callback

During initialization in [`src/driver/observe.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/observe.ts) (line 65), `registerSnapshotForRefRefresh(() => snapshotRaw())` stores a callback that can regenerate the snapshot and ref map on demand. This callback is preserved in the global state managed by [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts), surviving across heredoc executions.

### Lazy Map Rehydration via ensureRefMapForRef()

When any helper function receives a selector or ref argument, it first calls `ensureRefMapForRef()` from [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) (lines 12‑24). If the argument matches the ref pattern (`@123`) and the `browserRefMap` is empty, this function automatically executes the stored snapshot callback. This lazy initialization ensures the map is always current before element resolution occurs.

### Resolving Elements via CDP

The resolver functions in [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts)—including `resolveElementCenter()` and `resolveElementObjectId()`—perform the actual DOM interaction. They lookup the `backendNodeId` in `browserRefMap` (lines 70‑78) and fetch element data via CDP commands like `DOM.getBoxModel` or `DOM.resolveNode` (lines 101‑115, 166‑186). If a node has become stale, the system falls back to role and name lookups to maintain robustness.

## Cross-Round Persistence in Practice

Because the browser runtime persists between heredoc executions, the ref map remains available in memory. When a new heredoc block begins and references an `@N` ref from a previous round, the `ensureRefMapForRef()` trigger detects the empty map and invokes `snapshotRaw()` to rebuild state. This creates a seamless experience where refs survive indefinitely across execution boundaries without user intervention.

## Code Example: Refs Across Heredoc Rounds

```javascript
// First heredoc block - generate refs
await page.snapshot();               // Populates browserRefMap
const btn = await page.$('button');  // Returns "@42"
await btn.click();                   // Uses the ref immediately

// Second heredoc block - refs remain valid
await page.elementCenter('@42');     // Triggers ensureRefMapForRef(),
                                     // automatically refreshes map,
                                     // then resolves the element

```

Internally, the refresh mechanism wires together the observer and resolver:

```typescript
// src/driver/observe.ts
import { registerSnapshotForRefRefresh } from "./ref-state.js";
registerSnapshotForRefRefresh(() => snapshotRaw());

// src/ref-state.ts
export async function ensureRefMapForRef(selectorOrRef: unknown) {
  if (typeof selectorOrRef === "string" && parseRef(selectorOrRef) && browserRefMap.map.size === 0) {
    await snapshotImpl();  // Executes stored callback
  }
}

```

## Summary

- **Persistent runtime**: The browser instance stays alive across heredoc rounds, preserving global state.
- **Snapshot generation**: `snapshotRaw()` in [`src/driver/observe.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/observe.ts) captures DOM state and extracts refs with `backendNodeId` values.
- **Ref map storage**: `browserSnapshotRefsToRefMap()` populates the global `browserRefMap` in [`src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts).
- **Lazy refresh**: `ensureRefMapForRef()` in [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) automatically triggers snapshot regeneration when accessing refs with an empty map.
- **CDP resolution**: [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts) converts refs to DOM nodes via Chrome DevTools Protocol, with stale-node fallbacks.

## Frequently Asked Questions

### How does ego-browser handle stale element references across heredoc rounds?

When a ref is resolved via `resolveElementCenter()` or `resolveElementObjectId()` in [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts), the system attempts to fetch the node using the stored `backendNodeId` via CDP. If the node no longer exists, the resolver falls back to searching by role and name attributes (lines 166‑186), ensuring robustness even when the DOM changes between execution rounds.

### What triggers the automatic snapshot refresh in the ref system?

The `ensureRefMapForRef()` function in [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) triggers the refresh. When a helper receives an `@N` ref string and detects that `browserRefMap.map.size === 0`, it executes the callback registered by `registerSnapshotForRefRefresh()`, which invokes `snapshotRaw()` to rebuild the ref map.

### Where is the ref map stored between heredoc executions?

The ref map is stored as a global `browserRefMap` instance managed in [`src/browser-runtime.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/browser-runtime.ts) and [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts). Because ego-browser maintains the same JavaScript runtime across heredoc stdin blocks, this global state persists in memory without serialization to disk.

### Can I use numeric selectors instead of @N refs in ego-browser?

Yes. While the system optimizes for `@N` refs generated by `snapshotRaw()`, helpers like `elementCenter()` and `resolveElementCenter()` accept both CSS selectors and ref strings. If you pass a standard selector, the system bypasses the ref map resolution and queries the DOM directly via CDP.