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

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. 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.

// 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.

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

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

// 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) automatically retry on transient failures, you can implement custom logic when you need explicit control over snapshot timing:

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 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 handle transient failures and re-snapshotting without custom code
  • Reference source files: element-resolver.ts contains the core parsing and fallback logic; 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 (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.

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 →