How `ref-map` and `ref-state` Manage Element References in ego-browser
The ref-map and ref-state modules in ego-browser work together to convert short-lived numeric "refs" (like @21) into stable Chrome DevTools Protocol handles, with RefMap storing the ref-to-backendNodeId mappings and RefState orchestrating lazy refresh when those mappings go stale.
In citrolabs/ego-lite, the ego-browser package abstracts DOM interaction through a lightweight reference system. Rather than exposing raw CDP backendNodeId values directly, the framework generates numeric refs that remain valid across script execution rounds. The ref-map.ts and ref-state.ts modules form the backbone of this system—one provides the data structure, the other manages its lifecycle.
What Are Refs in ego-browser?
Refs are temporary numeric identifiers prefixed with @ (e.g., @12, @34) that agents use to address page elements. Under the hood, each ref maps to a Chrome DevTools Protocol backendNodeId identifying a specific DOM node. These refs appear in snapshots and can be passed to operations like elementCenter, click, or callFunctionOn.
Because backendNodeId values become invalid after navigation or DOM mutations, refs are inherently short-lived. The RefMap and RefState system ensures callers can continue using ref strings transparently, automatically refreshing the underlying mappings when needed.
RefMap: The Storage Layer
Located in src/ref-map.ts, the RefMap class provides the concrete data structure for ref storage.
Core Responsibilities
- Maintains a
Map<string, RefEntry>keyed byrefId - Stores each ref's
backendNodeId, role, name, optional nth index, and optional frame context - Exposes CRUD operations:
add,addWithFrame,get,remove,clear - Provides
parseRef, a utility that extracts numeric refs from variants like"@12","ref=34", or plain digits
Key Implementation Detail
When snapshotRaw executes, the browser returns an array of ref objects. The function browserSnapshotRefsToRefMap in observe.ts transforms this array into populated RefMap entries:
import { browserSnapshotRefsToRefMap } from 'ego-browser';
// After taking a snapshot, populate the global RefMap
const snapshot = await browser.snapshot({ includeStableLocator: true });
browserSnapshotRefsToRefMap(browserRefMap, snapshot.refs);
// browserRefMap now contains mappings like "12" → { backendNodeId: 87, role: "button", ... }
RefState: The Lifecycle Orchestrator
Where RefMap is a passive container, RefState (in src/ref-state.ts) actively manages when and how the map gets populated.
Singleton and Registration Pattern
RefState exposes a single global instance:
// From ref-state.ts
export const browserRefMap = new RefMap();
It also accepts a snapshot callback via registerSnapshotForRefRefresh, allowing the lazy-refresh mechanism to trigger new snapshots without hard-coding dependencies.
Lazy Refresh via ensureRefMapForRef
The critical behavior is ensureRefMapForRef:
- Receives a ref string (e.g.,
"@5") - Detects if the string represents a ref via
parseRef - If the
RefMapis empty or the ref is missing, invokes the registeredsnapshotImplcallback to fetch fresh data - Returns only after the map contains valid entries
This ensures code like the following works even immediately after navigation:
import { resolveHandle } from 'ego-browser';
// RefMap is empty after page load, but this still works:
const { objectId } = await resolveHandle('@5');
// Internally: ensureRefMapForRef('@5') → triggers snapshot → resolves
How Resolution Works End-to-End
The complete flow from ref string to executable CDP handle involves three stages:
- Trigger — A helper like
elementCenter('@12')orresolveHandle('@5')is called - Ensure — The helper invokes
ensureRefMapForRef, which lazy-refreshes theRefMapif needed - Resolve —
resolveElementObjectIdlooks up thebackendNodeIdinbrowserRefMap, then queries CDP'sDOM.resolveNodeto obtain aRuntime.objectIdfor subsequent operations
The relevant code in src/driver/element-ops.ts follows this pattern:
// Simplified conceptual flow
import { browserRefMap, ensureRefMapForRef } from '../ref-state';
export async function resolveHandle(selector: string) {
// Step 2: Guarantee fresh refs
await ensureRefMapForRef(selector);
// Step 3: Lookup and resolve
const refId = parseRef(selector);
const entry = browserRefMap.get(refId);
const { objectId } = await cdp('DOM.resolveNode', {
backendNodeId: entry.backendNodeId
});
return { objectId };
}
Practical Usage Examples
Basic Snapshot and Ref Access
const snap = await ego.snapshot({ includeStableLocator: true });
console.log('Available refs:', snap.refs);
// → [{ ref: "12", backendNodeId: 87, role: "button", name: "Submit" }, ...]
// Use the ref directly
const center = await ego.elementCenter('@12');
await ego.click('@12');
Manual Handle Resolution with Lazy Refresh
import { resolveHandle } from 'ego-browser';
// Works even if you haven't manually taken a snapshot
const { objectId } = await resolveHandle('@3');
console.log('CDP Runtime objectId:', objectId);
Executing Custom CDP Calls via withHandle
import { withHandle } from 'ego-browser';
await withHandle('@7', async ({ objectId }) => {
await ego.cdp('Runtime.callFunctionOn', {
functionDeclaration: 'function() { this.scrollIntoView(); }',
objectId,
returnByValue: true,
});
});
Key Source Files and Their Roles
| File | Purpose |
|---|---|
src/ref-map.ts |
RefMap class, parseRef utility, ref entry structure |
src/ref-state.ts |
Singleton browserRefMap, ensureRefMapForRef, snapshot callback registration |
src/driver/observe.ts |
browserSnapshotRefsToRefMap, snapshot execution, refresher registration |
src/driver/element-ops.ts |
resolveHandle, elementCenter, withHandle and other ref-consuming operations |
Summary
RefMap(src/ref-map.ts) stores ref-to-backendNodeId mappings with CRUD operations and parsing utilitiesRefState(src/ref-state.ts) provides the singleton instance and implements lazy refresh throughensureRefMapForRef- Automatic recovery — Empty ref maps trigger fresh snapshots transparently, so callers never need manual refresh logic
- Clean abstraction — Agents use simple
@12strings while the framework handles CDP complexity
Frequently Asked Questions
What happens if I use a ref after navigation?
The ensureRefMapForRef function detects that the RefMap is empty or stale and automatically triggers a new snapshot via the registered callback. Your ref either resolves to the corresponding element in the new page state or fails cleanly if the element no longer exists.
Can I use refs across multiple browser instances?
No. The browserRefMap is a module-level singleton tied to a single CDP session. Each browser instance maintains its own ref namespace. Sharing refs between instances would cause resolution failures.
How do I detect if a string is a valid ref?
Use the parseRef utility from src/ref-map.ts. It returns the numeric ref ID for strings matching @12, ref=34, or 56 patterns, or null for non-ref selectors.
What's the performance cost of lazy refresh?
Lazy refresh adds one snapshot roundtrip only when the map is empty. In typical agent workflows where multiple operations follow a single snapshot, the cost is amortized. For performance-critical paths, explicitly call snapshotRaw before batch operations to populate the map proactively.
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 →