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 by refId
  • 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:

  1. Receives a ref string (e.g., "@5")
  2. Detects if the string represents a ref via parseRef
  3. If the RefMap is empty or the ref is missing, invokes the registered snapshotImpl callback to fetch fresh data
  4. 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:

  1. Trigger — A helper like elementCenter('@12') or resolveHandle('@5') is called
  2. Ensure — The helper invokes ensureRefMapForRef, which lazy-refreshes the RefMap if needed
  3. ResolveresolveElementObjectId looks up the backendNodeId in browserRefMap, then queries CDP's DOM.resolveNode to obtain a Runtime.objectId for 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 utilities
  • RefState (src/ref-state.ts) provides the singleton instance and implements lazy refresh through ensureRefMapForRef
  • Automatic recovery — Empty ref maps trigger fresh snapshots transparently, so callers never need manual refresh logic
  • Clean abstraction — Agents use simple @12 strings 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →