# How the Element-Ops Driver Handles ObjectId References for Element Manipulation in ego-lite

> Discover how the element-ops driver in ego-lite manages ObjectId references using CDP Runtime Object IDs and ensures automatic release to prevent memory leaks.

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

---

**The element-ops driver converts CSS selectors and `@ref` values into Chrome DevTools Protocol (CDP) Runtime Object IDs, then guarantees automatic release to prevent memory leaks in the remote runtime.**

This article breaks down the reference-management architecture inside **citrolabs/ego-lite**, a lightweight browser automation library. Understanding how `objectId` lifecycles work is essential for anyone building custom element helpers or debugging stale-reference errors in CDP-based automation.

## Resolving Selectors and @refs to objectId Values

The entry point for all element operations is `resolveHandle` in [`package/ego-browser/src/driver/element-ops.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/driver/element-ops.ts). This function ensures the internal **RefMap** is populated, then delegates resolution to the specialized resolver.

```ts
// element-ops.ts – resolveHandle
export async function resolveHandle(selectorOrRef) {
  await ensureRefMapForRef(selectorOrRef);
  return resolveElementObjectId(
    { sendRaw: cdp },
    undefined,
    browserRefMap,
    selectorOrRef,
  );
}

```

The heavy lifting occurs in **`resolveElementObjectId`** (lines 1499–1609 of [`package/ego-browser/src/element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/element-resolver.ts)). This function handles three resolution paths:

- **Numeric `@ref` values** – Looks up `browserRefMap` for cached metadata. If a `backendNodeId` exists, attempts `DOM.resolveNode` to obtain an `objectId`. Falls back to accessibility-role lookup when the node is stale.
- **Stored references** – Uses the RefMap entry from `ensureRefMapForRef` in [`package/ego-browser/src/driver/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/driver/ref-state.ts) to locate the element across snapshot boundaries.
- **Raw selectors** – Builds a JavaScript finder via `buildFindElementJs` and evaluates with `Runtime.evaluate`, returning the resulting `objectId` directly.

This unified path gives higher-level code a single, predictable interface regardless of how the element was originally located.

## Automatic Handle Release and Memory Safety

CDP remote objects are reference-counted by the browser. The element-ops driver provides **best-effort release** that silently handles already-disposed handles:

```ts
// element-ops.ts – releaseHandle
export async function releaseHandle(objectId, sessionId) {
  if (!objectId) return;
  try {
    await cdp("Runtime.releaseObject", { objectId }, sessionId);
  } catch {
    // The handle or session may already be invalid.
  }
}

```

The `catch` block prevents cascading failures when sessions terminate or elements are garbage-collected mid-operation. This defensive pattern appears throughout the ego-lite codebase.

## Scoped Usage Patterns: withHandle and resolveAndCall

Most consumers never call `resolveHandle` or `releaseHandle` directly. Instead, they use two higher-level helpers that enforce proper lifecycle management.

### withHandle: Acquire-Use-Release in a Single Block

`withHandle` wraps the full lifecycle in a `finally` block, guaranteeing release even when operations throw:

```ts
// element-ops.ts – withHandle
export async function withHandle(selectorOrRef, fn) {
  const handle = await resolveHandle(selectorOrRef);
  try {
    return await fn(handle);
  } finally {
    await releaseHandle(handle.objectId, handle.sessionId);
  }
}

```

This pattern powers complex operations like `fill()` in the file upload helpers, where multiple CDP calls execute against the same element before cleanup.

### resolveAndCall: One-Shot Function Invocation

For simple cases, **`resolveAndCall`** resolves, invokes `Runtime.callFunctionOn`, and returns metadata:

```ts
// element-ops.ts – resolveAndCall
export async function resolveAndCall(
  selectorOrRef,
  functionDeclaration,
  args = [],
) {
  return withHandle(selectorOrRef, async ({ objectId, sessionId }) => {
    const result = await cdp(
      "Runtime.callFunctionOn",
      {
        functionDeclaration,
        objectId,
        arguments: args.map((value) => ({ value })),
        returnByValue: true,
        awaitPromise: false,
      },
      sessionId,
    );
    if (result.exceptionDetails || result.result?.subtype === "error") {
      runtimeValue(result, functionDeclaration);
    }
    return { result, objectId, sessionId };
  });
}

```

Exceptions thrown by the injected function are detected via `exceptionDetails` or `subtype === "error"` and passed to `runtimeValue` for standardized error handling.

## Real-World Usage in Higher-Level Drivers

The keyboard, pointer, and file drivers all delegate to these primitives. In [`package/ego-browser/src/driver/keyboard.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/driver/keyboard.ts), the `focus()` helper is a one-liner:

```ts
await resolveAndCall(selector, "function(){this.focus();}");

```

(see lines 69–71). The `fill()` helper opens a handle with `withHandle` and sequences multiple `callFunctionOn` invocations—clearing the field, setting the value, dispatching events—before automatic release.

## Practical Examples for Custom Helpers

### Click an Element via resolveAndCall

```ts
await resolveAndCall(
  "loc=css:button.submit",
  "function(){ this.click(); }"
);

```

The selector resolves to an `objectId`, `click()` executes in page context, and the handle releases automatically.

### Extract Text Content with withHandle

```ts
const text = await withHandle("@12", async ({ objectId, sessionId }) => {
  const { result } = await cdp(
    "Runtime.callFunctionOn",
    {
      functionDeclaration: "function(){ return this.textContent; }",
      objectId,
      returnByValue: true,
      awaitPromise: false,
    },
    sessionId,
  );
  return result?.value;
});

```

This demonstrates direct CDP access while retaining automatic cleanup.

### Build a Reusable scrollIntoView Helper

```ts
export async function scrollIntoView(selector) {
  await withHandle(selector, async ({ objectId, sessionId }) => {
    await cdp(
      "Runtime.callFunctionOn",
      {
        functionDeclaration: "function(){ this.scrollIntoView({block: 'center'}); }",
        objectId,
        returnByValue: true,
        awaitPromise: false,
      },
      sessionId,
    );
  });
}

```

All three patterns rely on identical lifecycle management from [`element-ops.ts`](https://github.com/citrolabs/ego-lite/blob/main/element-ops.ts).

## Architecture Benefits

- **Consistency** – Every element operation shares one resolution and release path.
- **Safety** – Automatic release prevents reference leaks in long-running sessions.
- **Resilience** – Stale `backendNodeId` entries trigger automatic fallback to role/name lookup, enabling transparent retry in `waitForSelector` loops.

## Summary

- **`resolveHandle`** in [`element-ops.ts`](https://github.com/citrolabs/ego-lite/blob/main/element-ops.ts) is the single entry point for converting selectors and `@ref` values to CDP `objectId` handles.
- **Resolution logic** resides in `resolveElementObjectId` ([`element-resolver.ts`](https://github.com/citrolabs/ego-lite/blob/main/element-resolver.ts), lines 1499–1609), with fallback paths for stale references.
- **Automatic release** via `releaseHandle` and `withHandle` prevents memory leaks; errors during release are swallowed to avoid cascading failures.
- **Higher-level helpers** like `resolveAndCall` provide ergonomic wrappers for common CDP patterns.
- **RefMap state** in [`ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/ref-state.ts) maintains the mapping between symbolic `@ref` values and concrete browser nodes.

## Frequently Asked Questions

### What happens if a referenced element is removed from the DOM before release?

The `releaseHandle` function silently ignores errors from `Runtime.releaseObject`, so removed elements do not cause exceptions. However, any subsequent operation using that `objectId` will fail, which is why `resolveElementObjectId` includes fallback logic—retrying with accessibility role lookup when `DOM.resolveNode` reports a stale node.

### How does ego-lite distinguish between a CSS selector and a numeric @ref?

The `resolveElementObjectId` function checks the format of the input string. Numeric values prefixed conventionally with `@` trigger RefMap lookup via `ensureRefMapForRef`. All other strings are treated as selectors and evaluated with `Runtime.evaluate` using generated finder JavaScript.

### Can I use withHandle for multiple operations on the same element?

Yes. The callback passed to `withHandle` receives the full handle object (`{ objectId, sessionId }`) and can issue multiple CDP calls before returning. The handle releases automatically in the `finally` block after the callback completes, even if it throws.

### Where is the RefMap populated and maintained?

`browserRefMap` and `ensureRefMapForRef` are defined in [`package/ego-browser/src/driver/ref-state.ts`](https://github.com/citrolabs/ego-lite/blob/main/package/ego-browser/src/driver/ref-state.ts). The map stays synchronized with page snapshots, enabling stable references across navigations and DOM mutations.