How gstack Detects Stale @ref References in Single Page Applications (SPAs)

gstack detects ref staleness in Single Page Applications by validating Playwright Locator counts at runtime before command execution and automatically clearing reference maps whenever the main frame navigates, including SPA route changes triggered by history API calls.

In the garrytan/gstack repository, the @ref system maps shorthand identifiers like @e3 or @c1 to Playwright Locator objects during a snapshot. Because SPAs mutate the DOM via pushState, replaceState, or asynchronous updates without full page reloads, these locators can become invalid between commands. The TabSession class implements a two-layer defense strategy to detect ref staleness in Single Page Applications and prevent silent failures.

Runtime Locator Validation Before Command Execution

The first mechanism validates stored locators immediately before they are used. In browse/src/tab-session.ts, the resolveRef method checks whether the element still exists in the DOM by calling count() on the stored Playwright Locator.

If the locator’s count() returns 0, the element has been removed from the DOM, indicating a stale reference. The system throws a descriptive error instructing the user to run a fresh snapshot:

// browse/src/tab-session.ts – lines 92‑100
const count = await entry.locator.count();
if (count === 0) {
  throw new Error(
    `Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` +
    `Run 'snapshot' for fresh refs.`
  );
}

This live-validation approach catches stale refs caused by DOM mutations that do not trigger navigation events, such as JavaScript removing elements during a route transition.

Automatic Ref Clearing on SPA Navigation

The second mechanism treats any main-frame navigation as a potential invalidation point for all stored references. In SPAs, navigation events include history API calls (pushState, replaceState), hash changes, and programmatic route updates that do not reload the page.

The onMainFrameNavigated() hook in browse/src/tab-session.ts is invoked on every main-frame navigation, regardless of whether it originated from an explicit goto, back, forward, reload command, or a browser-emitted SPA route change:

// browse/src/tab-session.ts – lines 74‑78
onMainFrameNavigated(): void {
  this.clearRefs();                 // wipe stale @ref entries
  this.activeFrame = null;          // drop frame that may have detached
  this.loadedHtml = null;           // forget any load‑html replay state
}

This method wipes the refMap, resets frame context, and discards cached replay data, ensuring no stale references persist across navigation boundaries.

Detecting Detached Iframe Contexts

For commands scoped to iframes, getActiveFrameOrPage() implements an additional staleness check. If the active frame detaches from the DOM (common in SPAs that dynamically inject or remove iframe containers), the session detects the detachment via isDetached() and clears refs before falling back to the main page:

// browse/src/tab-session.ts – lines 59‑63
if (this.activeFrame?.isDetached()) {
  this.activeFrame = null;
  this.clearRefs();                 // purge stale iframe refs
}

This prevents stale iframe-scoped references from leaking into subsequent commands after the frame’s parent container is destroyed by a route change.

Practical Examples of Staleness Detection

When a command attempts to interact with an element that was removed by a subsequent SPA update, gstack fails fast with a clear error message:

await bm.handleWriteCommand('click', ['@e3'], bm); 
// → Error: Ref @e3 (button "Submit") is stale — element no longer exists. Run 'snapshot' for fresh refs.

After a programmatic SPA navigation, the next snapshot automatically triggers onMainFrameNavigated(), clearing the stale reference map:

await bm.page.evaluate(() => history.pushState({}, '', '/new‑route')); // SPA navigation
await bm.handleWriteCommand('snapshot', [], bm);                      // triggers clearRefs()

If an iframe detaches during a session, the next command detects the stale context and resets the frame state:

// Inside an iframe‑scoped command
const frame = await bm.getActiveFrameOrPage();  // clears refs if iframe is gone

Summary

  • Live-validation via resolveRef checks locator.count() before every command, throwing explicit errors for DOM-removed elements.
  • Navigation hooks via onMainFrameNavigated automatically clear all refs on main-frame navigation, including SPA history API changes.
  • Iframe detection via isDetached() purges stale frame-scoped references when containers are dynamically removed.
  • Fail-fast design ensures developers receive immediate feedback rather than silent failures on outdated element handles.

Frequently Asked Questions

What causes an @ref to become stale in gstack?

An @ref becomes stale when its underlying Playwright Locator points to an element that no longer exists in the DOM. In SPAs, this commonly occurs after route changes that replace component trees, after innerHTML mutations, or when iframes detach from the document.

How does gstack handle navigation in Single Page Applications?

gstack registers navigation listeners in browser-manager.ts that invoke TabSession.onMainFrameNavigated() for every main-frame navigation event. This includes traditional page loads and SPA-style navigation via history.pushState, ensuring the reference map is cleared regardless of how the URL changes.

What happens when a command tries to use a stale @ref?

The resolveRef method detects the stale state when locator.count() returns 0, then throws an Error with the message: Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. Run 'snapshot' for fresh refs. This prevents the command from executing against an invalid handle.

Does gstack detect staleness in iframe references?

Yes. The getActiveFrameOrPage() method checks this.activeFrame?.isDetached() before returning a frame context. If the iframe has detached due to SPA DOM mutations, the method clears all refs via clearRefs() and falls back to the main page, preventing stale iframe-scoped locators from causing errors.

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 →