can1357/oh-my-pi · error · ToolError

Element id is stale. Run tab.observe() again.

Error message

Element id ${id} is stale. Run tab.observe() again.

What it means

After finding the cached handle, the worker verifies el.isConnected inside the page. A disconnected element means the DOM node was removed/replaced since observe() (navigation, re-render, hydration), so the cache is cleared and this ToolError is thrown. The same message is also thrown if checking isConnected itself fails (e.g. the execution context was destroyed by a navigation).

Solutions

  1. Run tab.observe() again immediately before the action to rebuild ids, then retry with the fresh id.
  2. Minimize the delay between observe and action; perform actions right after observing.
  3. If navigations are expected, wait for load after navigation and re-observe before interacting.
  4. For flaky re-rendering pages, target stable selectors/aria-refs rather than cached ids.

Example fix

// before
const id = (await tab.observe()).elements[3].id;
await clickApplyButton(); // triggers re-render
await tab.click(id); // stale
// after
const id = (await tab.observe()).elements[3].id;
await clickApplyButton();
await tab.waitForLoad?.();
const fresh = (await tab.observe()).elements.find(e => e.name === "the element");
await tab.click(fresh.id);
Defensive patterns

Strategy: retry

Validate before calling

const connected = await tab.evaluate((el) => el.isConnected, id);
if (!connected) await tab.observe();

Try / catch

try {
  await tab.click(id);
} catch (err) {
  if (err.message.startsWith("Element id") && err.message.includes("stale")) {
    const fresh = await tab.observe();
    const el = fresh.elements.find(e => e.name === expectedName);
    if (el) return tab.click(el.id);
  }
  throw err;
}

Prevention

When it happens

Trigger: Acting on an element id after the page navigated or the framework re-rendered and detached the node; evaluation context destroyed mid-check (SPA client-side navigation); element removed by an async update between observe and action.

Common situations: React/Vue re-render replacing list items; SPA route change wiping old nodes; login/session redirect between observe and click; long delay between observe and action letting the page update itself.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/f6c9451b6cf66e25. Report an issue: GitHub.

Appendix: source

Thrown at packages/coding-agent/src/tools/browser/tab-worker.ts:2128

	): Promise<HTTPResponse> {
		const page = this.#requirePage();
		const predicate: (response: HTTPResponse) => boolean | Promise<boolean> =
			typeof pattern === "function"
				? pattern
				: pattern instanceof RegExp
					? response => pattern.test(response.url())
					: response => response.url().includes(pattern);
		return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout, signal }))) as HTTPResponse;
	}

	async #resolveCachedHandle(id: number): Promise<ElementHandle> {
		const handle = this.#elementCache.get(id);
		if (!handle) throw new ToolError(`Unknown element id ${id}. Run tab.observe() to refresh the element list.`);
		try {
			const isConnected = (await handle.evaluate(el => el.isConnected)) as boolean;
			if (!isConnected) {
				this.#clearElementCache();
				throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
			}
		} catch (err) {
			if (err instanceof ToolError) throw err;
			this.#clearElementCache();
			throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
		}
		return handle;
	}

	async #resolveAriaRef(id: string): Promise<ElementHandle> {
		const ref = parseAriaRefSelector(id) ?? id.trim();
		const handle = await resolveAriaRefHandle(this.#requirePage(), ref);
		if (!handle) {
			throw new ToolError(
				`Unknown ARIA ref ${JSON.stringify(ref)}. Run tab.ariaSnapshot() to refresh refs (they renumber each snapshot).`,
			);
		}
		return handle;

View on GitHub (pinned to 9690622007)