# How to Implement Site-Specific Selectors That Survive DOM Changes in ego-browser

> Master ego-browser's layered locators to create robust site-specific selectors. Combine CSS speed with role-based fallbacks to survive DOM changes and ensure reliable automation.

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

---

**Use ego-browser's layered locator system with `loc=` expressions and role-based fallbacks: combine CSS selectors for speed with `role:name` selectors that query the regenerated Accessibility Tree when DOM references go stale.**

The `ego-browser` package in the citrolabs/ego-lite repository provides a resilient element resolution system designed for automation that persists across page mutations. Unlike brittle CSS-only selectors, ego-browser's **locator DSL** automatically degrades from fast DOM queries to stable Accessibility Tree (AX) lookups when dynamic content reshapes the page.

## Understanding the Layered Resolution Pipeline

Element lookup in ego-browser follows a three-stage pipeline implemented in [`src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/element-resolver.ts). This architecture deliberately separates fast-path resolution from recovery mechanisms.

### Stage 1: Snapshot Reference Resolution

When a selector is passed to any helper, `resolveElementCenter` first checks for a cached backend node ID in the form `@N`. These snapshot references provide sub-millisecond resolution when the DOM hasn't changed.

```typescript
// From element-resolver.ts lines 94-102
// If @N exists, try DOM.getBoxModel immediately
const boxModel = await cdp.DOM.getBoxModel({ backendNodeId: ref.backendNodeId });

```

If `DOM.getBoxModel` succeeds, the coordinates are returned immediately. If the backend node was detached—which happens after any structural DOM change—the code throws a **transient error** and triggers the fallback path rather than returning wrong coordinates.

### Stage 2: Parsed Locator Execution

When no valid snapshot reference exists, `parseLocator` (lines 332-383) tokenizes the selector string. It recognizes several prefixes:

| Prefix | Purpose | Example |
|--------|---------|---------|
| `loc=css:` | Standard CSS selector | `loc=css:div.g a` |
| `role:` | AX tree role + optional name | `role:button[name="Submit"]` |
| `loc=xpath:` | XPath expression | `loc=xpath://button[@id='go']` |
| `text=` | Text content matching | `text=Exact match text` |

The `loc=` prefix is rewritten internally to either a `css` or `xpath` locator, while `role:` constructs are handled separately in `resolveLocatorCenter` (lines 50-67).

### Stage 3: AX-Based Fallback Resolution

When the primary locator fails or the snapshot reference is stale, ego-browser queries the **Accessibility Tree** via `findBackendNodeIdByRoleName`. This function scans the full AX tree for nodes matching the requested role and accessible name, returning a fresh `backendNodeId` that corresponds to the current DOM state.

The AX tree is regenerated on every snapshot capture, making role-based selectors inherently stable across DOM mutations that would invalidate CSS paths.

## Building Resilient Site-Specific Selectors

The key technique for DOM-surviving selectors is the **fallback syntax**: comma-separated alternatives that ego-browser evaluates left-to-right.

```typescript
// Selector that prefers CSS but degrades to role-based matching
const selector = 'loc=css:div.g a,role:link[name="Next"]';

```

Execution flow for this selector:

1. Attempt `document.querySelectorAll('div.g a')` via CDP's `Runtime.evaluate`
2. If zero matches or the matched element's backend node is detached, proceed to next alternative
3. Query `Accessibility.getFullAXTree` and scan for `role="link"` with `name="Next"`
4. Return coordinates of the first matching accessible node

This pattern handles common failure modes:

- **Dynamic IDs/classes**: CSS selector breaks, role-name still matches
- **AJAX content replacement**: Backend node detached, fresh AX lookup succeeds
- **A/B test variations**: Layout changes structure, semantic role remains consistent

## Practical Implementation Examples

### Basic Script with Fallback Selector

```javascript
// Navigate and interact with Google search results
// The selector survives layout refreshes and infinite scroll
await ego.openOrReuseTab('https://www.google.com/search?q=ego-browser');

// Clicks first result; falls back to "Next" link role if carousel replaces results
await ego.click('loc=css:div.g a,role:link[name="Ego Browser"]');

```

### Custom Site Skill Definition

Site skills in ego-lite encapsulate domain-specific selectors. Define fallbacks directly in your tool implementation:

```javascript
// skills/ego-browser/learnings/google/tools/open-first.js
import { resolveElementCenter, click } from 'ego-browser';

export async function openFirst(cdp, sessionId, args) {
  // Multi-layer fallback: specific CSS → generic CSS → role-based
  const selector = [
    'loc=css:#search div.g:first-child a',  // Preferred: precise structure
    'loc=css:div.g a',                       // Backup: generic result link
    'role:link[name*="ego"]'                 // Last resort: semantic match
  ].join(',');
  
  const { x, y, sessionId: sid } = await resolveElementCenter(
    cdp, sessionId, {}, selector
  );
  
  await click(cdp, sid, x, y);
  return { success: true, clicked: selector };
}

```

### Handling Transient Errors with Explicit Retries

While ego-browser's built-in helpers (defined in [`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts)) automatically retry on transient failures, you can implement custom logic when you need explicit control over snapshot timing:

```typescript
import { 
  resolveElementCenter, 
  click, 
  takeSnapshot,
  ElementResolutionError 
} from 'ego-browser';

async function clickWithForcedResnapshot(
  cdp: CDP.Client,
  sessionId: string,
  selector: string,
  maxRetries: number = 3
): Promise<void> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const { x, y, sessionId: sid } = await resolveElementCenter(
        cdp, sessionId, {}, selector
      );
      return await click(cdp, sid, x, y);
      
    } catch (e) {
      if (e instanceof ElementResolutionError && e.transient && attempt < maxRetries) {
        // Force new AX tree generation before retry
        await takeSnapshot(cdp, sessionId);
        continue;
      }
      throw e;
    }
  }
}

```

The `takeSnapshot` call regenerates the Accessibility Tree and invalidates stale `@N` references, ensuring the next `resolveElementCenter` call operates on current DOM state.

## Selector Syntax Reference

Consult [`src/format.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/format.ts) for the complete selector DSL documentation used by the `help()` command. Key patterns for resilient automation:

| Pattern | Use Case |
|---------|----------|
| `role:button[name="Save"]` | Stable across restyling; matches accessible name exactly |
| `role:button[name*="Save"]` | Partial name match for localized variants |
| `role:link` | Broad role match when only element type matters |
| `loc=css:nav a[href="/dashboard"],role:link[name="Dashboard"]` | CSS preferred, role guarantees function |
| `@5,role:heading[level=1]` | Try cached ref #5, fallback to main heading |

## Performance Considerations

The AX tree fallback adds 50-200ms per resolution depending on page complexity. For performance-critical paths:

1. **Prefer CSS for stable pages**: Use `loc=css:` without fallback when targeting controlled environments
2. **Scope role queries**: Include specific names (`name="Exact"`) rather than broad `role:button` patterns
3. **Cache successful resolutions**: Let ego-browser's automatic `@N` reference caching work; avoid forcing fresh snapshots unnecessarily

## Summary

- **Use comma-separated alternatives**: `loc=css:...,role:...` provides speed with guaranteed resilience
- **Rely on AX tree regeneration**: Role-based selectors query state rebuilt on every snapshot, surviving any DOM mutation
- **Trust automatic retries**: Built-in helpers in [`src/helpers.ts`](https://github.com/citrolabs/ego-lite/blob/main/src/helpers.ts) handle transient failures and re-snapshotting without custom code
- **Reference source files**: [`element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/element-resolver.ts) contains the core parsing and fallback logic; [`format.ts`](https://github.com/citrolabs/ego-lite/blob/main/format.ts) documents the full selector syntax

## Frequently Asked Questions

### What happens when both CSS and role selectors fail?

ego-browser throws an `ElementResolutionError` with `kind: 'not-found'`. Built-in helpers retry with exponential backoff and automatic re-snapshotting. After the configured timeout (default 30s), the error propagates to your script.

### Can I use XPath instead of CSS in the `loc=` prefix?

Yes. `loc=xpath://button[@class='primary']` is valid and follows the same fallback rules. The `parseLocator` function in [`element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/element-resolver.ts) (line 332) recognizes both `css:` and `xpath:` sub-prefixes after `loc=`.

### How does ego-browser handle dynamic accessible names?

Use substring or pattern matching in the `name` attribute. `role:button[name*="Click"]` matches any button whose accessible name contains "Click". For regex patterns, implement a custom resolver that post-filters `Accessibility.getFullAXTree` results.

### Is the Accessibility Tree available on all sites?

The AX tree requires Chrome's accessibility features, which are enabled by default. Sites using `aria-hidden="true"` or `role="presentation"` may exclude elements from the tree. In these cases, rely on `loc=css:` or `text=` selectors without role fallbacks, or use `xpatch=` for structural matching.