Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fast-text-locator-lookups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@browserbasehq/stagehand-extension": patch
"@browserbasehq/stagehand": patch
"@browserbasehq/stagehand-python": patch
"@browserbasehq/stagehand-go": patch
---

Speed up `text=` locator `count()` and `nth()` on large repeated lists by matching text in a single linear pass and resolving only the requested index.
148 changes: 17 additions & 131 deletions packages/extension/dom/locatorScripts/counts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { countXPathMatches } from "./xpathResolver.js";
import { getOpenOrClosedShadowRoot } from "./shadowRoots.js";
import { collectInnermostTextMatches, textUsedForMatch } from "./textMatches.js";

export interface TextMatchSample {
tag: string;
Expand Down Expand Up @@ -64,145 +65,30 @@ export function countCssMatchesPrimary(selectorRaw: string): number {
}
}

const classNameOf = (element: Element): string => {
try {
const value = (element as HTMLElement).className;
return typeof value === "string" ? value : "";
} catch {
return "";
}
};

export function countTextMatches(rawNeedle: string): TextMatchResult {
const needle = String(rawNeedle ?? "");
if (!needle) {
return { count: 0, sample: [], error: null };
}

const needleLc = needle.toLowerCase();
const skipTags = new Set([
"SCRIPT",
"STYLE",
"TEMPLATE",
"NOSCRIPT",
"HEAD",
"TITLE",
"LINK",
"META",
"HTML",
"BODY",
]);

const shouldSkip = (node: Element | null | undefined): boolean => {
if (!node) return false;
const tag = node.tagName?.toUpperCase() ?? "";
return skipTags.has(tag);
};

const extractText = (element: Element): string => {
try {
if (shouldSkip(element)) return "";
const inner = (element as HTMLElement).innerText;
if (typeof inner === "string" && inner.trim()) return inner.trim();
} catch {
// ignore
}
try {
const text = element.textContent;
if (typeof text === "string") return text.trim();
} catch {
// ignore
}
return "";
};

const matches = (element: Element): boolean => {
const text = extractText(element);
return !!text && text.toLowerCase().includes(needleLc);
};

const seen = new WeakSet<Node>();
const queue: Node[] = [];

const enqueue = (node: Node | null | undefined) => {
if (!node || seen.has(node)) return;
seen.add(node);
queue.push(node);
};

const walkerFor = (root: Node): TreeWalker | null => {
try {
const doc = root instanceof Document ? root : ((root as Element)?.ownerDocument ?? document);
return doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
} catch {
return null;
}
};

const matchesList: Array<{
element: Element;
tag: string;
id: string;
className: string;
text: string;
}> = [];

enqueue(document);

while (queue.length) {
const root = queue.shift();
if (!root) continue;

if (root instanceof Element && matches(root)) {
matchesList.push({
element: root,
tag: root.tagName ?? "",
id: root.id ?? "",
className: (root as HTMLElement).className ?? "",
text: extractText(root),
});
}

const walker = walkerFor(root);
if (!walker) continue;

let node: Node | null;
while ((node = walker.nextNode())) {
if (!(node instanceof Element)) continue;

if (matches(node)) {
matchesList.push({
element: node,
tag: node.tagName ?? "",
id: node.id ?? "",
className: (node as HTMLElement).className ?? "",
text: extractText(node),
});
}

const shadowRoot = getOpenOrClosedShadowRoot(node);
if (shadowRoot) enqueue(shadowRoot);
}
}

const innermost: typeof matchesList = [];
for (const item of matchesList) {
const el = item.element;
let skip = false;
for (const other of matchesList) {
if (item === other) continue;
try {
if (el.contains(other.element)) {
skip = true;
break;
}
} catch {
// ignore containment errors
}
}
if (!skip) innermost.push(item);
}

const count = innermost.length;
const sample = innermost.slice(0, 5).map((item) => ({
tag: item.tag,
id: item.id,
class: item.className,
text: item.text,
const matches = collectInnermostTextMatches(needle);
const sample = matches.slice(0, 5).map((element) => ({
tag: element.tagName ?? "",
id: element.id ?? "",
class: classNameOf(element),
text: textUsedForMatch(element),
}));

return { count, sample, error: null };
return { count: matches.length, sample, error: null };
}

export function countXPathMatchesMainWorld(rawXp: string): number {
Expand Down
131 changes: 3 additions & 128 deletions packages/extension/dom/locatorScripts/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resolveXPathAtIndex } from "./xpathResolver.js";
import { getOpenOrClosedShadowRoot } from "./shadowRoots.js";
import { collectInnermostTextMatches } from "./textMatches.js";

const parseTargetIndex = (value: unknown): number => {
const num = Number(value ?? 0);
Expand Down Expand Up @@ -65,135 +66,9 @@ export function resolveCssSelector(selectorRaw: string, targetIndexRaw?: number)
export function resolveTextSelector(rawNeedle: string, targetIndexRaw?: number): Element | null {
const needle = String(rawNeedle ?? "");
if (!needle) return null;
const needleLc = needle.toLowerCase();
const targetIndex = parseTargetIndex(targetIndexRaw);

const skipTags = new Set([
"SCRIPT",
"STYLE",
"TEMPLATE",
"NOSCRIPT",
"HEAD",
"TITLE",
"LINK",
"META",
"HTML",
"BODY",
]);

const shouldSkip = (node: Element | null | undefined): boolean => {
if (!node) return false;
const tag = node.tagName?.toUpperCase() ?? "";
return skipTags.has(tag);
};

const extractText = (node: Element): string => {
try {
if (shouldSkip(node)) return "";
const inner = (node as HTMLElement).innerText;
if (typeof inner === "string" && inner.trim()) return inner.trim();
} catch {
// ignore
}
try {
const text = node.textContent;
if (typeof text === "string") return text.trim();
} catch {
// ignore
}
return "";
};

const matches = (node: Element): boolean => {
const text = extractText(node);
return !!text && text.toLowerCase().includes(needleLc);
};

const seen = new WeakSet<Node>();
const queue: Node[] = [];
const matchesList: Array<{
element: Element;
tag: string;
id: string;
className: string;
text: string;
}> = [];

const enqueue = (node: Node | null | undefined) => {
if (!node || seen.has(node)) return;
seen.add(node);
queue.push(node);
};

const walkerFor = (root: Node): TreeWalker | null => {
try {
const doc = root instanceof Document ? root : ((root as Element)?.ownerDocument ?? document);
return doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
} catch {
return null;
}
};

enqueue(document);

while (queue.length) {
const root = queue.shift();
if (!root) continue;

if (root instanceof Element && matches(root)) {
matchesList.push({
element: root,
tag: root.tagName ?? "",
id: root.id ?? "",
className: (root as HTMLElement).className ?? "",
text: extractText(root),
});
}

const walker = walkerFor(root);
if (!walker) continue;

let node: Node | null;
while ((node = walker.nextNode())) {
if (!(node instanceof Element)) continue;

if (matches(node)) {
matchesList.push({
element: node,
tag: node.tagName ?? "",
id: node.id ?? "",
className: (node as HTMLElement).className ?? "",
text: extractText(node),
});
}

const shadowRoot = getOpenOrClosedShadowRoot(node);
if (shadowRoot) enqueue(shadowRoot);
}
}

const innermost: typeof matchesList = [];
for (const item of matchesList) {
const el = item.element;
let skip = false;
for (const other of matchesList) {
if (item === other) continue;
try {
if (el.contains(other.element)) {
skip = true;
break;
}
} catch {
// ignore containment errors
}
}
if (!skip) {
innermost.push(item);
}
}

const target = innermost[targetIndex];
return target?.element ?? null;
const matches = collectInnermostTextMatches(needle, targetIndex + 1);
return matches[targetIndex] ?? null;
}

export function resolveXPathMainWorld(rawXp: string, targetIndexRaw?: number): Element | null {
Expand Down
Loading
Loading