# How `ref-map` and `ref-state` Manage Element References in ego-browser

> Learn how ego-browser uses ref-map and ref-state to manage element references. Discover how they convert numeric refs to stable handles for efficient DOM manipulation.

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

---

**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`](https://github.com/citrolabs/ego-lite/blob/main/ref-map.ts) and [`ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/observe.ts) transforms this array into populated `RefMap` entries:

```javascript
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`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts)) actively manages **when and how** the map gets populated.

### Singleton and Registration Pattern

`RefState` exposes a single global instance:

```typescript
// 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:

```javascript
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. **Resolve** — `resolveElementObjectId` 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`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/element-ops.ts) follows this pattern:

```javascript
// 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

```javascript
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

```javascript
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`

```javascript
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`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts) | `RefMap` class, `parseRef` utility, ref entry structure |
| [`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts) | Singleton `browserRefMap`, `ensureRefMapForRef`, snapshot callback registration |
| [`src/driver/observe.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/observe.ts) | `browserSnapshotRefsToRefMap`, snapshot execution, refresher registration |
| [`src/driver/element-ops.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/driver/element-ops.ts) | `resolveHandle`, `elementCenter`, `withHandle` and other ref-consuming operations |

## Summary

- **`RefMap`** ([`src/ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts)) stores ref-to-backendNodeId mappings with CRUD operations and parsing utilities
- **`RefState`** ([`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/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`](https://github.com/citrolabs/ego-lite/blob/main/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.