How the ego-lite Snapshot System Rebuilds the RefMap: A Technical Deep Dive
The ego-lite browser harness rebuilds the RefMap by clearing stale entries, fetching a fresh DOM tree via Chrome DevTools Protocol (CDP), and populating a new mapping of numeric snapshot references to backend node IDs with auxiliary metadata.
The ego-lite testing framework creates semantic snapshots after every navigation or DOM-changing operation to maintain stable element references. Central to this process is the RefMap, a specialized structure that links human-readable numeric references like @1 or @23 to underlying CDP backend node IDs. Understanding how the system reconstructs this map on every snapshot is crucial for debugging element resolution failures and writing resilient browser automation scripts.
The Three-Phase RefMap Rebuild Process
According to the citrolabs/ego-lite source code, the rebuild happens in three distinct phases coordinated by the browser runtime.
Phase 1: Clear Stale References
Before processing a new snapshot, the runtime must invalidate previous mappings. The RefMap instance is completely emptied to prevent stale references from contaminating the new snapshot.
In src/ref-map.ts, the clear() method removes all stored entries:
// src/ref-map.ts
clear() {
this.map.clear();
}
This method is invoked via refState.clear() in src/ref-state.ts, ensuring that any temporary refs from previous snapshots—such as @99 from an earlier page state—are discarded before the new DOM is processed.
Phase 2: Collect DOM Nodes via CDP
The runtime fetches the current DOM state using CDP commands. It calls DOM.getDocument and DOM.describeNode (as implemented in src/browser-runtime.ts) to retrieve the complete node tree.
During this collection phase, the runtime walks the returned tree and assigns a fresh numeric ID to each element that possesses a stable selector. This assignment happens within an internal helper function, populateRefMap(node, refMap), which recursively traverses the DOM hierarchy to identify trackable elements.
Phase 3: Populate with Fresh Metadata
For every node discovered during the CDP walk, the runtime invokes refMap.addWithFrame() to store the relationship between the new numeric reference and the backend identifier. This method, defined in src/ref-map.ts, stores not just the backendNodeId but also auxiliary data including the element's role, name, optional nth index, and frameId for cross-frame support.
// src/ref-map.ts
addWithFrame(refId, backendNodeId, role, name, nth = undefined, frameId = undefined) {
this.map.set(refId, {
backendNodeId,
role,
name,
nth,
selector: undefined,
frameId,
});
}
This metadata enables downstream helpers like elementResolver (in src/element-resolver.ts) to resolve @ref strings accurately against the fresh map.
Snapshot Workflow Orchestration
The BrowserRuntime class coordinates these phases into a cohesive workflow. In src/browser-runtime.ts, the takeSnapshot() method acts as the entry point:
- Start:
BrowserRuntime.takeSnapshot()initiates CDP communication to fetch the DOM tree. - Reset:
refState.clear()wipes the existing RefMap so stale refs are discarded. - Walk: The internal
populateRefMaphelper assigns new@NIDs and populates entries viaaddWithFrame. - Finish: The method returns a Snapshot object containing the newly-built RefMap, which agents use for subsequent operations.
Because each snapshot rebuilds the map from scratch, any temporary refs from previous states become automatically invalid. When a stale ref is encountered—such as trying to click @23 after the DOM has changed—the runtime detects the ElementResolutionError, triggers a fresh snapshot, and rebuilds the RefMap before retrying the operation.
Practical Implementation Examples
To take a snapshot and inspect the underlying RefMap structure:
// Example: taking a snapshot and using a ref
import { snapshot } from "ego-browser";
// Trigger a snapshot – the RefMap is rebuilt here
const snap = await snapshot(); // Internally clears old map + repopulates it
// The snapshot contains a map you can inspect (for debugging)
console.log(snap.refMap.get("23")); // => { backendNodeId: 456, role: "button", … }
// Use a ref in a later helper call; the runtime will resolve it against the fresh map
await click("@23"); // elementResolver resolves @23 → backendNodeId
The system also handles automatic recovery when references become stale:
// Example: automatic re‑snapshot when a stale ref is encountered
try {
await click("@99"); // @99 was from an older snapshot
} catch (e) {
// The error is a transient ElementResolutionError → the runtime retries:
// 1️⃣ take a new snapshot (clears & rebuilds RefMap)
// 2️⃣ resolve the ref again
}
Key Source Files
The RefMap rebuild process spans several core modules in the citrolabs/ego-lite repository:
src/ref-map.ts: Defines theRefMapclass withclear(),addWithFrame(), and lookup methods.src/ref-state.ts: Holds the currentRefMapinstance and manages its lifecycle between snapshots.src/browser-runtime.ts: Coordinates CDP communication and triggers the rebuild workflow viatakeSnapshot().src/element-resolver.ts: Resolves@refstrings by querying the freshly built RefMap.src/driver/observe.ts: Exposes the publicsnapshot()helper that agents invoke to trigger the rebuild process.
Summary
- The RefMap rebuilds from scratch on every snapshot to ensure references always point to live DOM nodes.
- Three phases drive the process: clearing stale entries via
refMap.clear(), collecting fresh DOM data via CDPDOM.getDocument, and populating metadata viaaddWithFrame(). - Stale references auto-invalidate because the map is cleared before each rebuild, forcing the runtime to take a fresh snapshot when encountering outdated
@refvalues. - Cross-frame support is built into the population phase through the
frameIdparameter inaddWithFrame(). - Entry points for triggering rebuilds include
BrowserRuntime.takeSnapshot()and the publicsnapshot()export in the observe driver.
Frequently Asked Questions
What triggers a RefMap rebuild in ego-lite?
A rebuild triggers automatically whenever snapshot() is called, which occurs after navigation events, DOM mutations, or explicit agent requests. The BrowserRuntime.takeSnapshot() method coordinates the process by first clearing the existing map in refState before collecting new CDP data.
How does ego-lite handle stale references from previous snapshots?
Stale references become invalid immediately after a rebuild because refMap.clear() empties all entries before populating new ones. If an agent attempts to use an outdated @ref string, the elementResolver throws an ElementResolutionError, prompting the runtime to automatically take a fresh snapshot and rebuild the RefMap before retrying the operation.
What is the relationship between @ref strings and backend node IDs?
The @ref string (e.g., @23) acts as a human-readable alias for a numeric refId stored in the RefMap. This refId maps to a backendNodeId—the actual identifier used by the Chrome DevTools Protocol to interact with specific DOM nodes. The RefMap maintains this indirection layer to provide stable references despite DOM changes.
Can I manually inspect the RefMap during test execution?
Yes. When you call await snapshot(), the returned Snapshot object exposes the refMap property, which you can query using snap.refMap.get("23") to inspect the stored metadata including backendNodeId, role, name, and frameId for debugging purposes.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →