How the Element-Ops Driver Handles ObjectId References for Element Manipulation in ego-lite
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. This function ensures the internal RefMap is populated, then delegates resolution to the specialized resolver.
// 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). This function handles three resolution paths:
- Numeric
@refvalues – Looks upbrowserRefMapfor cached metadata. If abackendNodeIdexists, attemptsDOM.resolveNodeto obtain anobjectId. Falls back to accessibility-role lookup when the node is stale. - Stored references – Uses the RefMap entry from
ensureRefMapForRefinpackage/ego-browser/src/driver/ref-state.tsto locate the element across snapshot boundaries. - Raw selectors – Builds a JavaScript finder via
buildFindElementJsand evaluates withRuntime.evaluate, returning the resultingobjectIddirectly.
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:
// 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:
// 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:
// 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, the focus() helper is a one-liner:
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
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
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
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.
Architecture Benefits
- Consistency – Every element operation shares one resolution and release path.
- Safety – Automatic release prevents reference leaks in long-running sessions.
- Resilience – Stale
backendNodeIdentries trigger automatic fallback to role/name lookup, enabling transparent retry inwaitForSelectorloops.
Summary
resolveHandleinelement-ops.tsis the single entry point for converting selectors and@refvalues to CDPobjectIdhandles.- Resolution logic resides in
resolveElementObjectId(element-resolver.ts, lines 1499–1609), with fallback paths for stale references. - Automatic release via
releaseHandleandwithHandleprevents memory leaks; errors during release are swallowed to avoid cascading failures. - Higher-level helpers like
resolveAndCallprovide ergonomic wrappers for common CDP patterns. - RefMap state in
ref-state.tsmaintains the mapping between symbolic@refvalues 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. The map stays synchronized with page snapshots, enabling stable references across navigations and DOM mutations.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →