# How Ego‑Lite's Reference System Provides Stable Element References Across Script Runs

> Discover how Ego-Lite ensures stable element references across script runs with its RefMap, browserRefMap, and resolveElement helpers. Learn about this robust architecture.

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

---

**Ego‑Lite maintains stable element references through a three‑component architecture: `RefMap` stores numeric IDs mapped to `backendNodeId`, `browserRefMap` with `ensureRefMapForRef` lazily refreshes snapshots when needed, and `resolveElement*` helpers perform two‑step resolution with automatic fallback to role/name lookup.**

The [ego‑lite](https://github.com/citrolabs/ego-lite) browser automation framework solves a critical problem for autonomous agents: how to identify the same DOM element after navigation, DOM mutations, or entirely separate script executions. Unlike brittle CSS selectors that break on markup changes, ego‑lite's **stable element reference system** assigns numeric identifiers (`@1`, `@2`, …) that persist across script runs through snapshot regeneration and intelligent resolution fallbacks.

## Core Components of the Reference System

Three tightly‑coupled modules work together to provide reference stability.

### RefMap: The Numeric-to-Element Registry

Located in [[`package/ego-browser/src/ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-map.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-map.ts), `RefMap` maintains the mapping between numeric reference IDs and element metadata. Each entry stores not just the Chrome DevTools Protocol (**CDP**) `backendNodeId`, but also the element's **role**, **name**, **nth** position, and **frame** information.

```ts
// ref-map.ts – storing a ref with full context
this.map.set(refId, {
  backendNodeId,
  role,
  name,
  nth,
  selector: undefined,
  frameId,
});

```

The `parseRef` utility handles multiple input formats, stripping `@` or `ref=` prefixes to extract the numeric ID:

```ts
// ref-map.ts – flexible ref parsing
export function parseRef(input) {
  const trimmed = String(input || "").trim();
  const candidate = trimmed.startsWith("@") ? trimmed.slice(1) :
                    trimmed.startsWith("ref=") ? trimmed.slice(4) :
                    trimmed;
  return candidate && /^\d+$/.test(candidate) ? candidate : null;
}

```

### Global State and Lazy Snapshot Refresh

[[`package/ego-browser/src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-state.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-state.ts) exports `browserRefMap`, a **singleton** holding the current snapshot's refs. The critical function `ensureRefMapForRef` implements **lazy regeneration**: when a script uses a reference but the map is empty (post‑navigation, for instance), it automatically triggers a fresh snapshot.

```ts
// ref-state.ts – lazy snapshot with re-entrancy guard
export async function ensureRefMapForRef(selectorOrRef) {
  if (ensuring) return;                          // prevent concurrent snapshots
  if (typeof selectorOrRef !== "string") return;
  if (!parseRef(selectorOrRef)) return;          // not a ref, skip
  if (browserRefMap.map.size > 0) return;        // already populated
  if (!snapshotImpl) return;                     // no callback registered
  
  ensuring = true;
  try { 
    await snapshotImpl();                        // rebuild the map
  } finally { 
    ensuring = false; 
  }
}

```

The `ensuring` flag prevents duplicate snapshot attempts during concurrent operations.

### Two-Step Element Resolution

[[`package/ego-browser/src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.ts) contains `resolveElementCenter` and `resolveElementObjectId`, which turn a reference into an actionable CDP node. The resolution strategy prioritizes speed, then stability:

1. **Fast path**: Use the stored `backendNodeId` directly via `DOM.getBoxModel` or `DOM.resolveNode`
2. **Fallback path**: If the node is stale (removed by DOM changes), search by **role** and **name** using `findBackendNodeIdByRoleName`

```ts
// element-resolver.ts – two-step ref resolution
const refId = parseRef(selectorOrRef);
if (refId) {
  const entry = refMap.get(refId);
  if (!entry) throw new ElementResolutionError(`Unknown ref: ${refId}`, "transient");
  
  // Try stored backendNodeId first (fastest)
  if (entry.backendNodeId !== undefined) { … }
  
  // Fallback to role/name lookup (stable across DOM mutations)
  const backendNodeId = await findBackendNodeIdByRoleName(entry.role, entry.name, entry.nth);
}

```

## How References Survive Across Script Runs

The system achieves **cross‑script stability** through three mechanisms.

### Snapshot Retention Between Scripts

When a script finishes, ego‑lite **retains the current `RefMap`**. The next script execution reuses `browserRefMap` unless it's empty, in which case `ensureRefMapForRef` triggers regeneration. This means `@7` in script A typically refers to the same logical element in script B.

### Consistent ID Assignment Within Task-Spaces

The runtime **re‑captures the page before each new script round**. While numeric IDs are rebuilt on every snapshot, the **traversal order** (top‑to‑bottom through the accessibility tree) remains deterministic. Elements with stable roles, names, and DOM positions receive consistent reference numbers across snapshots.

### Automatic Recovery from Navigation

Page loads clear the `RefMap` (since `backendNodeId`s are invalidated). However, agents don't need explicit waits: the first reference usage after navigation automatically invokes `snapshotImpl()` via `ensureRefMapForRef`, repopulating the map with fresh entries for the new page.

## Practical Usage Examples

### Creating and Reusing a Stable Reference

```ts
// 1️⃣ Initialize and capture snapshot
await egobrowser.observe();           // populates RefMap with @1, @2, @3...

// 2️⃣ Interact using numeric reference
const buttonRef = await egobrowser.click("@12");  // returns "@12" for chaining

// 3️⃣ Reuse in subsequent calls – no re-query needed
await egobrowser.moveMouse("@12");
await egobrowser.type("@12", "search query");

```

### Post‑Navigation Recovery

```ts
// After page navigation, refs are cleared but auto‑regenerate
await egobrowser.click("@5");         // Map empty → ensureRefMapForRef triggers snapshot
                                      // New @5 points to element with same role/name/nth

```

### Manual Snapshot Control

```ts
// Force regeneration after known DOM mutations
await egobrowser.snapshot();          // Explicit refresh, keeps workflow predictable

```

## Implementation Files and Responsibilities

| File | Responsibility |
|------|----------------|
| [[`ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/ref-map.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-map.ts) | `RefMap` class, `parseRef()` utility, entry data structure |
| [[`ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/ref-state.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-state.ts) | Global `browserRefMap`, `ensureRefMapForRef()`, snapshot callback registration |
| [[`element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/element-resolver.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.ts) | `resolveElementCenter()`, `resolveElementObjectId()`, fallback search logic |
| [[`driver/observe.ts`](https://github.com/citrolabs/ego-lite/blob/main/driver/observe.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/driver/observe.ts) | Snapshot capture, accessibility tree traversal, `RefMap` population |

## Summary

- **`RefMap`** implements the numeric-to-element registry with role/name/frame metadata for resilient identification
- **`browserRefMap` + `ensureRefMapForRef`** provide **lazy, guarded snapshot regeneration** that automatically recovers from navigation and clears
- **`resolveElement*`** functions execute **two‑step resolution**: `backendNodeId` fast path with automatic **role/name fallback**
- References persist across script runs through **snapshot retention** and **deterministic ID assignment** within task-spaces
- The system requires **no explicit synchronization** from agents—empty maps trigger automatic refresh on first reference use

## Frequently Asked Questions

### What happens if a referenced element is deleted from the DOM?

The `resolveElement*` helpers detect stale `backendNodeId`s and automatically fall back to `findBackendNodeIdByRoleName`, which searches the current accessibility tree by role, name, and nth position. If the element has been replaced by another with matching attributes, the reference resolves to the new instance; if no match exists, an `ElementResolutionError` is thrown with code `"transient"`.

### Do reference numbers stay identical forever?

No—numeric IDs are **reassigned on every snapshot**. However, within a task-space, elements with stable roles, names, and DOM positions typically receive consistent numbers because the accessibility tree traversal order is deterministic. For cross‑script stability, agents should rely on the **reference mechanism itself** (the runtime refreshes automatically) rather than hard‑coding specific numbers.

### How does ego‑lite handle references across iframe boundaries?

The `RefMap.addWithFrame` method stores `frameId` alongside each entry. During resolution, the system routes CDP commands to the correct frame context, enabling **stable references for elements inside iframes** without requiring explicit frame switching in agent scripts.

### Is there a performance cost to the lazy snapshot refresh?

`ensureRefMapForRef` executes only when `browserRefMap.map.size === 0`, guarded by the `ensuring` flag to prevent duplicate work. The snapshot itself involves one CDP `Accessibility.getFullAXTree` call and tree traversal. In practice, this overhead is negligible compared to the network latency of browser automation, and it eliminates the need for manual `waitForSelector` calls.