How the Ego-Browser Locator Facade API Supports Chained Calls: Immutable Selector Composition Explained

The ego-browser locator facade supports chained calls through immutable selector composition, where every method returns a new façade instance with an updated selector string rather than mutating the original object.

The ego-lite repository provides a lightweight browser automation framework with a Playwright-inspired API. Its locator façade enables expressive element selection through method chaining, making test scripts readable and maintainable. This article explains the architectural patterns that make this chaining possible, drawn directly from the citrolabs/ego-lite source code.

Immutable Façade Design Pattern

The core mechanism resides in src/helpers.ts, where createLocator (lines 20-31) implements a factory that returns plain objects with chainable methods. Each method follows the same contract: validate inputs, compose a new selector, return a new façade.

// From src/helpers.ts - simplified core pattern
function createLocator(selector) {
  return {
    // Every method returns a NEW createLocator call
    nth(index) {
      const newSelector = nthSelector(selector, index);
      return createLocator(newSelector);  // ← fresh instance
    },
    locator(subSelector) {
      const newSelector = scopedSelector(selector, subSelector);
      return createLocator(newSelector);  // ← fresh instance
    },
    // ... additional chainable methods
  };
}

This pure functional approach eliminates hidden state. The original selector string captured in closure remains untouched, while each chain step accumulates complexity in a new string passed to the next instance.

Selector Composition Through Internal Pseudo-Selectors

The façade encodes chain operations as internal pseudo-selectors prefixed to the selector string. Helper functions in src/helpers.ts handle this encoding:

Helper Function Lines Purpose
nthSelector 13-15 Wraps selector with internal:nth=N; prefix
scopedSelector 31-58 Combines parent and child selectors with semicolon delimiter
// Selector evolution through a chain
'#container'                          // initial locator
'internal:nth=2;#container'           // after .nth(2)
'internal:nth=2;#container;button'    // after .locator('button')

The semicolon-delimited format allows the query parser in src/locator-query.ts to split and interpret each segment, executing the operations in order against the DOM.

Chainable Methods in the Façade API

The ego-browser locator façade mirrors Playwright's locator API, exposing methods that fall into three categories:

Navigation methods (return new façades):

  • first() → prefixes with internal:last; (interpreted as first match)
  • nth(index) → validates with Number.isInteger and non-negative check
  • locator(selector) → scopes to descendant elements
  • filter(options) → applies attribute or structural filters
  • getByText(text), getByRole(role, options) → semantic selectors

Action methods (terminal, execute via driver):

  • click(), fill(value), evaluate(fn), textContent(), etc.

Property methods (terminal, return values):

  • count(), isVisible(), boundingBox(), etc.

Early validation occurs before façade creation. For example, nth() rejects non-integers immediately rather than deferring errors to execution time.

Driver Resolution of Composed Selectors

The accumulated selector string ultimately reaches src/driver/locator.ts, which coordinates with src/locator-query.ts to execute queries. The flow:

  1. Parse the semicolon-delimited selector into operation segments
  2. Translate pseudo-selectors (internal:*) to executable JavaScript
  3. Execute against Chrome DevTools Protocol (CDP) for element resolution
  4. Apply the terminal action or return the requested property

Because the façade has already composed the full selector by chain's end, the driver treats the query as a single deterministic operation rather than multiple round-trip selections.

Practical Chaining Examples

// Example 1: Index-based access within a container
await page
  .locator('#product-list')
  .nth(2)                    // third item (zero-indexed)
  .locator('.add-to-cart')
  .click();

// Example 2: Filtering before interaction
await page
  .locator('button')
  .filter({ has: { attribute: 'data-testid', value: 'submit' } })
  .first()
  .click();

// Example 3: Deep semantic chaining
await page
  .getByRole('navigation')
  .getByRole('link', { name: 'Settings' })
  .click();

// Example 4: Complex multi-step selection
await page
  .locator('form#login')
  .locator('input')
  .filter({ has: { attribute: 'type', value: 'password' } })
  .fill('securePassword123');

Each intermediate façade is discarded after its successor is created. The garbage collector reclaims these short-lived objects, while only the final façade participates in the driver call.

Comparison with Mutative Alternatives

Approach Pattern Drawback Addressed by Ego-Browser
Mutative faсade loc.first().nth(2) modifies internal state Race conditions in async code, unexpected shared state
Builder with build() new Locator().first().nth(2).build() Verbose API, easy to forget terminal call
Ego-browser's immutable façade Each step returns ready-to-use instance Clean API, safe reassignment, predictable behavior

The immutable approach enables reusable base locators:

// Define once
const formLocator = page.locator('#user-form');

// Branch to different fields without interference
await formLocator.locator('input[name="email"]').fill('test@example.com');
await formLocator.locator('input[name="phone"]').fill('555-0123');
// formLocator remains unmodified

Source File Reference Map

File Responsibility
package/ego-browser/src/helpers.ts createLocator factory and selector helpers (lines 20-31)
package/ego-browser/src/driver/locator.ts CDP driver for selector resolution
package/ego-browser/src/locator-query.ts Query generation for CSS/XPath/Accessibility Tree
package/ego-browser/src/index.ts Public API export (page.locator)
package/ego-browser/src/helpers.test.mjs Test coverage for chaining behavior

Summary

  • The ego-browser locator facade API enables chained calls through immutable selector composition, where every method returns a new façade instance.
  • The createLocator factory in src/helpers.ts implements this pattern using closure-captured selector strings and fresh object returns.
  • Internal pseudo-selectors (internal:nth=, internal:last;) encode chain operations in a parseable string format.
  • Early validation in methods like nth() provides immediate feedback on incorrect arguments.
  • Terminal methods delegate to src/driver/locator.ts and src/locator-query.ts for CDP-based element resolution and action execution.
  • This design yields a pure, stateless API compatible with Playwright-style automation patterns.

Frequently Asked Questions

What makes the ego-browser locator façade immutable?

Each public method creates a new façade instance via createLocator() rather than modifying the original object's properties. The selector string is captured in closure and never reassigned; instead, helper functions generate updated strings for the next instance. This immutability prevents side effects when locators are reused or shared across async contexts.

How does the chaining syntax avoid mutating the original locator?

The createLocator factory returns a plain object whose methods immediately call createLocator again with modified arguments. For example, calling .nth(2) invokes nthSelector() to build a prefixed string, then createLocator(newSelector) produces a fresh object. The original object and its selector remain unchanged in memory, though no longer referenced in the chain.

What happens if I call .nth() with an invalid argument?

The façade validates numeric arguments before creating any new instance. In nth() and similar methods, Number.isInteger(index) and non-negative checks execute immediately; if validation fails, the method throws without invoking createLocator. This fail-fast behavior surfaces errors at call site rather than during later driver execution.

Can I reuse a base locator after chaining from it?

Yes—this is a key benefit of immutability. Since chained methods never modify the source façade, you can declare a base locator and branch multiple independent queries from it. Each branch receives its own selector string and façade instance, while the original remains available for additional chains or reuse in subsequent test steps.

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 →