# How @N Element References Are Resolved and Mapped Across Page Snapshots in ego-lite

> Discover how ego-lite resolves @N element references by mapping numeric IDs to backendNodeIds. Learn about automatic fallback for stale DOM nodes.

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

---

**@N element references in ego-lite work by mapping numeric IDs to stored `backendNodeId` values in a snapshot-scoped `RefMap`, with automatic fallback to role/name matching if the DOM node becomes stale.**

The **@N** syntax is a shorthand notation used throughout **ego-lite** to reference DOM elements captured in the most recent page snapshot. This system enables reliable element targeting for AI-driven browser automation without requiring complex CSS or XPath selectors.

## The RefMap: Core Data Structure for @N References

When `snapshot()` is invoked, the runtime traverses the DOM and accessibility tree to build a **RefMap** instance called `browserRefMap` in [[`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-state.ts). This map serves as the authoritative source for all @N reference resolution.

### Storing Element References

The `RefMap.addWithFrame()` method records each element with the following fields:

| Field | Purpose |
|-------|---------|
| `backendNodeId` | Chrome DevTools Protocol (CDP) backend node identifier |
| `role` / `name` | Accessibility role and accessible name (fallback lookup) |
| `nth` | Position when multiple elements match the same selector |
| `frameId` | Target CDP session/frame for iframe traversal |

The reference ID is simply the element's insertion index. The first stored element becomes **@1**, the second **@2**, and so on.

## Resolving @N References to CDP Commands

Element resolution happens in [[`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.ts). The process follows this sequence:

1. **`parseRef()` (lines 70–76)** extracts the numeric ID from strings like `"@3"` or `ref=3`

2. **Map lookup** retrieves the stored entry via `refMap.get(refId)`

3. **Direct CDP invocation** uses the stored `backendNodeId` for commands like `DOM.getBoxModel` or `DOM.resolveNode` (lines 81–90 and 167–178)

4. **Stale node fallback** triggers `findBackendNodeIdByRoleName()` if the original `backendNodeId` no longer exists, ensuring predictable errors rather than silent mis-clicks

```ts
const refId = parseRef(selectorOrRef);        // "@3" → 3
const entry = refMap.get(refId);              // fetch stored data
const result = await send(cdp, "DOM.getBoxModel",
            { backendNodeId: entry.backendNodeId }, effectiveSessionId);

```

## Cross-Snapshot Reference Mapping Behavior

** References are strictly snapshot-scoped.** The `RefMap` is completely rebuilt on every snapshot call—previous numeric IDs carry no meaning across snapshot boundaries.

If automation code attempts to resolve a reference while `browserRefMap` is empty (common after navigation), the helper layer in [[`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/helpers.ts) automatically triggers a fresh snapshot before proceeding. This design guarantees that **every @N reference always points to a valid, current element**.

## Practical Usage Examples

```ts
// 1️⃣ Take a snapshot – the runtime builds the RefMap internally
await snapshot();   // now @1 … @N are valid

// 2️⃣ Click the element recorded as @4
await click("@4");

// 3️⃣ Resolve the center of @2 for coordinate-based actions
const {x, y, sessionId} = await resolveElementCenter(
    cdp, undefined, browserRefMap, "@2");

// 4️⃣ Navigation invalidates references – snapshot refreshes the map
await navigate("https://example.com");
await snapshot();   // fresh @1…@M for the new page
await click("@1");   // refers to the new page's first recorded element

```

## Key Implementation Files

| File | Responsibility |
|------|----------------|
| [[`src/ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-map.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-map.ts) | `RefMap` class definition and `parseRef()` helper |
| [[`src/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/ref-state.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/ref-state.ts) | Global `browserRefMap` instance management |
| [[`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.ts) | Resolution logic: `resolveElementCenter()`, `resolveElementObjectId()` |
| [[`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts)](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/helpers.ts) | Auto-snapshot trigger when resolving empty-map references |
| [`src/element-resolver.test.mjs`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.test.mjs) | Test coverage for reference lifecycle and fallback behavior |

## Summary

- **@N syntax** maps to sequential indices in a snapshot-specific `RefMap`
- **Resolution path**: parse → map lookup → CDP command with `backendNodeId`
- **Stale node handling** falls back to role/name matching for safe failures
- **Snapshot scoping** ensures references never outlive their validity window
- **Automatic refresh** guarantees valid references through implicit snapshot calls

## Frequently Asked Questions

### What happens if I use @N after the page changes?

The reference resolution will fail with a retryable error. According to the ego-lite source code, if the stored `backendNodeId` is stale, the system attempts `findBackendNodeIdByRoleName()` to re-locate by accessibility attributes before raising a transient error. For reliable automation, always call `snapshot()` after significant DOM changes.

### Can @N references work across iframes?

Yes. The `RefMap` stores `frameId` with each entry, and [`element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/element-resolver.ts) uses `effectiveSessionId` to route CDP commands to the correct frame context when resolving cross-frame @N references.

### Why are reference IDs not persistent across snapshots?

The `RefMap` is deliberately re-created on every snapshot to prevent stale element references. This design choice in [`ref-map.ts`](https://github.com/citrolabs/ego-lite/blob/main/ref-map.ts) ensures that numeric IDs always correspond to currently-captured DOM state, eliminating an entire class of automation bugs from outdated references.

### How do I debug which element @N refers to?

Inspect `browserRefMap` after snapshotting to see the underlying `backendNodeId`, `role`, and `name` fields. The test suite in `element-resolver.test.mjs` demonstrates programmatic access to these entries for verification purposes.