# How the GStack Ref System Works: Stable Element Addressing Without DOM Mutation

> Discover GStack's stable ref system for element addressing. Assign human-readable IDs like @e1 without DOM mutation, enhancing CSS selector stability and CSP compatibility.

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

---

**GStack's ref system assigns stable, human-readable identifiers like `@e1` and `@c1` to page elements using the browser's ARIA accessibility tree, eliminating fragile CSS selectors while remaining fully compatible with Content Security Policy and modern JavaScript frameworks.**

The `garrytan/gstack` repository implements a browser automation framework that fundamentally changes how agents interact with web pages. Instead of relying on DOM mutation or complex XPath expressions, the **gstack ref system** creates an external mapping between simple reference strings and Playwright Locators. This architecture ensures reliable interactions across React, Vue, and Shadow DOM applications without violating security policies or breaking during hydration.

## How GStack Generates Element References

The ref generation process begins when the agent requests a snapshot of the current page. Rather than parsing the raw HTML or injecting tracking attributes, GStack queries the browser's native accessibility layer.

### Capturing the Accessibility Tree

When executing a snapshot command, the server calls Playwright's `page.accessibility.snapshot()` method to retrieve the complete ARIA tree. This returns a semantic representation of all interactive elements—buttons, links, form inputs, and landmarks—without requiring DOM manipulation.

In [`browse/src/tab-session.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/tab-session.ts), the system walks this tree and assigns sequential identifiers. Standard interactable elements receive **ref names** in the format `@e1`, `@e2`, `@e3`, proceeding through the tree in document order.

### Handling Cursor-Interactive Elements

Not all clickable elements appear in the ARIA tree. To address elements that only have CSS `cursor: pointer` without semantic roles, GStack uses the `-C` flag during snapshotting. These elements receive **cursor-interactive refs** formatted as `@c1`, `@c2`, etc., ensuring agents can interact with custom widgets and div-based buttons that lack proper ARIA attributes.

## The Ref-to-Locator Architecture

The mapping between reference strings and live page elements is stored on the `BrowserManager` instance as a `Map<string, RefEntry>`. Each `RefEntry` object contains three critical components:

- **Locator**: A Playwright Locator instance that uniquely identifies the element
- **Role**: The ARIA role of the element (e.g., `button`, `link`)
- **Name**: The accessible name or label of the element

This data structure lives in memory on the server side, completely external to the page's DOM. As documented in [`ARCHITECTURE.md`](https://github.com/garrytan/gstack/blob/main/ARCHITECTURE.md), this design adheres to the principle of zero DOM injection, preventing conflicts with Content Security Policy (CSP) and avoiding breakage during framework reconciliation.

## Resolving References with Staleness Detection

When an agent issues a command like `$B click @e3`, the resolution occurs through the `TabSession.resolveRef()` method implemented in [`browse/src/tab-session.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/tab-session.ts) (lines 84-106). This method implements a three-step validation process:

1. **Lookup**: Retrieves the `RefEntry` from the `refMap` using the `@e3` key
2. **Validation**: Verifies the element still exists by calling `await entry.locator.count()` 
3. **Execution**: Returns the Locator to Playwright for the actual interaction

If the reference is missing from the map or the `count()` check returns zero, GStack throws an explicit error informing the user to run a fresh `snapshot`. This **staleness detection** prevents agents from attempting to click elements that have been removed or re-rendered since the last snapshot.

```typescript
// Direct resolution pattern from the source
async function clickRef(session: TabSession, ref: string) {
  const { locator } = await session.resolveRef(ref);
  await locator.click();  // Playwright performs the click
}

```

## Lifecycle Management and Safety Features

The gstack ref system implements strict lifecycle rules to prevent ambiguous behavior across page transitions.

### Automatic Ref Clearing

On any main-frame navigation, the `refMap` is automatically cleared. This ensures that references from the previous page cannot be accidentally reused, as the `RefEntry` objects contain Locators tied to the original page context.

### Staleness Error Handling

When a reference becomes invalid due to DOM changes (but not navigation), the error message explicitly identifies the stale reference and its original properties:

```bash
$ B click @e3
Error: Ref @e3 (button "Save") is stale — element no longer exists. Run 'snapshot' for fresh refs.

```

This explicit error handling prompts the AI agent to capture a new snapshot rather than failing silently or retrying invalid operations.

### No DOM Injection Requirements

Unlike selector-based systems that inject `data-testid` attributes or unique IDs, GStack's refs exist entirely outside the browser context. This approach works seamlessly with:
- **Content Security Policy** restrictions that prohibit inline scripts
- **React and Vue hydration** that would strip foreign attributes
- **Shadow DOM** boundaries that isolate component internals

## Practical Usage Examples

### Taking a Snapshot and Viewing Refs

The `-i` flag displays the annotated element list alongside standard output:

```bash
$ B snapshot -i
@e1  button  "Submit"
@e2  link    "Learn more"
@c1  div     "Custom widget"

```

### Executing Actions by Reference

Once refs are established, agents can execute commands without constructing selectors:

```bash
$ B click @e2

```

Internally, this resolves to:
- `resolveRef('@e2')` → `locator = getByRole('link', { name: 'Learn more' }).nth(0)`
- `locator.click()`

### Handling Stale References

If the page mutates between snapshot and action:

```bash
$ B click @e3

# Error: Ref @e3 is stale — element no longer exists. Run 'snapshot' for fresh refs.

```

## Summary

- **The gstack ref system** uses Playwright's `page.accessibility.snapshot()` to generate stable `@e` and `@c` identifiers from the ARIA tree, avoiding DOM mutation entirely.
- **Reference resolution** occurs through `TabSession.resolveRef()` in [`browse/src/tab-session.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/tab-session.ts), which validates element existence via `locator.count()` before returning the Locator.
- **Staleness protection** ensures that deleted or navigated elements trigger explicit errors, prompting fresh snapshots rather than silent failures.
- **Security compatibility** is maintained by keeping ref mappings external to the page, working correctly under CSP and across Shadow DOM boundaries.
- **Key implementation files** include [`browse/src/tab-session.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/tab-session.ts) for resolution logic, [`browse/src/browser-manager.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/browser-manager.ts) for the ref map storage, and [`ARCHITECTURE.md`](https://github.com/garrytan/gstack/blob/main/ARCHITECTURE.md) for design rationale.

## Frequently Asked Questions

### What is the difference between @e and @c refs in GStack?

**@e refs** (element refs) are assigned to standard interactive elements present in the ARIA accessibility tree such as buttons, links, and form inputs. **@c refs** (cursor refs) are assigned to elements discovered via the `-C` snapshot flag that have `cursor: pointer` CSS but lack ARIA roles, enabling interaction with custom JavaScript widgets that don't expose semantic information.

### How does GStack handle references after page navigation?

GStack automatically clears the entire `refMap` on any main-frame navigation. This prevents stale Locators from being reused across page loads. If an agent attempts to use a ref after navigation, `TabSession.resolveRef()` will throw a "ref not found" error, requiring a fresh `snapshot` command to generate new references for the current page state.

### Why does GStack use the accessibility tree instead of CSS selectors?

The accessibility tree provides semantic, stable identifiers that remain consistent across framework re-renders and DOM mutations. According to the [`ARCHITECTURE.md`](https://github.com/garrytan/gstack/blob/main/ARCHITECTURE.md) documentation, this approach avoids the brittleness of CSS selectors that break when classes change or components restructure. Additionally, using Playwright Locators derived from ARIA roles and names ensures compatibility with applications using Shadow DOM or strict Content Security Policies that would prevent DOM-injected tracking attributes.

### Where is the ref system implemented in the GStack codebase?

The core implementation resides in [`browse/src/tab-session.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/tab-session.ts), specifically the `resolveRef()` method (lines 84-106) and the `refMap` storage. The `BrowserManager` class in [`browse/src/browser-manager.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/browser-manager.ts) maintains the mapping and forwards resolution requests. Command implementations in [`browse/src/write-commands.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/write-commands.ts) (for click, fill, etc.) and [`browse/src/read-commands.ts`](https://github.com/garrytan/gstack/blob/main/browse/src/read-commands.ts) (for getText, isVisible) consume these refs through the resolution API. The design rationale is documented in [`ARCHITECTURE.md`](https://github.com/garrytan/gstack/blob/main/ARCHITECTURE.md) under "The ref system" section.