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

> gstack detects stale @ref references in SPAs by validating Playwright Locator counts at runtime and clearing reference maps on navigation. Learn how gstack ensures reliable SPA testing.

- Repository: [Garry Tan/gstack](https://github.com/garrytan/gstack)
- Tags: how-to-guide
- Published: 2026-05-15

---

**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`](https://github.com/garrytan/gstack/blob/main/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`:

```ts
// 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`](https://github.com/garrytan/gstack/blob/main/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:

```ts
// 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:

```ts
// 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:

```ts
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:

```ts
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:

```ts
// 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`](https://github.com/garrytan/gstack/blob/main/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.