How the GStack Ref System Works: Stable Element Addressing Without DOM Mutation
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, 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, 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 (lines 84-106). This method implements a three-step validation process:
- Lookup: Retrieves the
RefEntryfrom therefMapusing the@e3key - Validation: Verifies the element still exists by calling
await entry.locator.count() - 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.
// 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:
$ 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:
$ 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:
$ 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:
$ 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@eand@cidentifiers from the ARIA tree, avoiding DOM mutation entirely. - Reference resolution occurs through
TabSession.resolveRef()inbrowse/src/tab-session.ts, which validates element existence vialocator.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.tsfor resolution logic,browse/src/browser-manager.tsfor the ref map storage, andARCHITECTURE.mdfor 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 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, specifically the resolveRef() method (lines 84-106) and the refMap storage. The BrowserManager class in browse/src/browser-manager.ts maintains the mapping and forwards resolution requests. Command implementations in browse/src/write-commands.ts (for click, fill, etc.) and browse/src/read-commands.ts (for getText, isVisible) consume these refs through the resolution API. The design rationale is documented in ARCHITECTURE.md under "The ref system" section.
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 →