diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts
index a955c9427..3a77e223a 100644
--- a/packages/react-grab/e2e/element-context.spec.ts
+++ b/packages/react-grab/e2e/element-context.spec.ts
@@ -139,5 +139,141 @@ test.describe("Element Context Fallback", () => {
expect(clipboard).toContain("long-dom-element");
expect(clipboard.length).toBeLessThanOrEqual(510);
});
+
+ test("should include descendant text for syntax highlighted code blocks", async ({
+ reactGrab,
+ }) => {
+ await reactGrab.page.evaluate(() => {
+ const wrapper = document.createElement("div");
+ Object.assign(wrapper.style, {
+ position: "fixed",
+ top: "200px",
+ left: "200px",
+ width: "600px",
+ height: "160px",
+ zIndex: "999",
+ });
+
+ const codeBlock = document.createElement("pre");
+ codeBlock.className = "shiki shiki-themes github-light github-dark";
+ codeBlock.tabIndex = 0;
+ codeBlock.innerHTML = `
+
+ git add .github/workflows/react-doctor.yml
+ git commit -m "Add React Doctor to CI"
+ git push
+
+ `;
+
+ wrapper.appendChild(codeBlock);
+ document.body.appendChild(wrapper);
+ });
+
+ const didCopy = await reactGrab.copyElementViaApi("pre.shiki");
+ expect(didCopy).toBe(true);
+
+ const clipboard = await reactGrab.getClipboardContent();
+ expect(clipboard).toContain("
");
+ });
+
+ test("should include descendant text for nested link labels", async ({ reactGrab }) => {
+ await reactGrab.page.evaluate(() => {
+ const wrapper = document.createElement("div");
+ Object.assign(wrapper.style, {
+ position: "fixed",
+ top: "200px",
+ left: "200px",
+ width: "320px",
+ height: "80px",
+ zIndex: "999",
+ });
+
+ const link = document.createElement("a");
+ link.href = "/docs/ci-and-prs/github-actions-setup";
+ link.className = "flex h-8 w-full items-center gap-2 rounded-md px-2";
+ link.innerHTML = `
+
+ GitHub Actions setup
+ `;
+
+ wrapper.appendChild(link);
+ document.body.appendChild(wrapper);
+ });
+
+ const didCopy = await reactGrab.copyElementViaApi(
+ "a[href='/docs/ci-and-prs/github-actions-setup']",
+ );
+ expect(didCopy).toBe(true);
+
+ const clipboard = await reactGrab.getClipboardContent();
+ expect(clipboard).toContain('");
+ });
+
+ test("should skip preview text for hidden selected roots", async ({ reactGrab }) => {
+ await reactGrab.page.evaluate(() => {
+ const wrapper = document.createElement("div");
+ Object.assign(wrapper.style, {
+ position: "fixed",
+ top: "200px",
+ left: "200px",
+ width: "200px",
+ height: "80px",
+ zIndex: "999",
+ });
+
+ const hiddenLabel = document.createElement("span");
+ hiddenLabel.setAttribute("aria-hidden", "true");
+ hiddenLabel.setAttribute("data-testid", "decorative-hidden-label");
+ hiddenLabel.textContent = "Decorative Hidden Label";
+
+ wrapper.appendChild(hiddenLabel);
+ document.body.appendChild(wrapper);
+ });
+
+ const didCopy = await reactGrab.copyElementViaApi("[data-testid='decorative-hidden-label']");
+ expect(didCopy).toBe(true);
+
+ const clipboard = await reactGrab.getClipboardContent();
+ expect(clipboard).toContain('aria-hidden="true"');
+ expect(clipboard).not.toContain("Decorative Hidden Label");
+ });
+
+ test("should include nested text for mixed inline content", async ({ reactGrab }) => {
+ await reactGrab.page.evaluate(() => {
+ const wrapper = document.createElement("div");
+ Object.assign(wrapper.style, {
+ position: "fixed",
+ top: "200px",
+ left: "200px",
+ width: "260px",
+ height: "80px",
+ zIndex: "999",
+ });
+
+ const link = document.createElement("a");
+ link.href = "/docs/mixed-content";
+ link.textContent = "Read ";
+ const emphasizedText = document.createElement("em");
+ emphasizedText.textContent = "the docs";
+ link.appendChild(emphasizedText);
+
+ wrapper.appendChild(link);
+ document.body.appendChild(wrapper);
+ });
+
+ const didCopy = await reactGrab.copyElementViaApi("a[href='/docs/mixed-content']");
+ expect(didCopy).toBe(true);
+
+ const clipboard = await reactGrab.getClipboardContent();
+ expect(clipboard).toContain("Read the docs");
+ expect(clipboard).not.toContain("");
+ });
});
});
diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts
index 850a446c4..015d86fbc 100644
--- a/packages/react-grab/src/core/context.ts
+++ b/packages/react-grab/src/core/context.ts
@@ -31,6 +31,8 @@ import { normalizeFilePath } from "../utils/normalize-file-path.js";
import { parsePackageName } from "../utils/parse-package-name.js";
import { safeDecodeURIComponent } from "../utils/safe-decode-uri-component.js";
import { isInternalAttribute } from "../utils/strip-internal-attributes.js";
+import { createElementSelector } from "../utils/create-element-selector.js";
+import { getPreviewTextContent } from "../utils/get-preview-text-content.js";
import {
isInternalComponentName,
isUsefulComponentName,
@@ -366,6 +368,11 @@ interface StackContextOptions {
maxLines?: number;
}
+interface TraceContextResult {
+ text: string;
+ shouldAppendSelectorHint: boolean;
+}
+
const hasFormattableFrames = (stack: StackFrame[] | null): boolean => {
if (!stack) return false;
return stack.some((frame) => {
@@ -378,6 +385,14 @@ const hasFormattableFrames = (stack: StackFrame[] | null): boolean => {
});
};
+const isTrustedSourcePath = (filePath: string | null | undefined): boolean =>
+ Boolean(filePath && isSourceFile(filePath) && !parsePackageName(filePath));
+
+const formatSelectorContextLine = (element: Element): string => {
+ const selector = createElementSelector(element);
+ return `\n selector: ${selector}`;
+};
+
const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => {
if (!isInstrumentationActive()) return [];
const fiber = getFiberFromHostInstance(element);
@@ -435,22 +450,38 @@ const formatStackContext = (
stack: StackFrame[],
options: StackContextOptions = {},
leadingSource: ResolvedSource | null = null,
-): string => {
+): TraceContextResult => {
const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options;
const isNextProject = isNextProjectRuntime();
const lines: string[] = [];
let previousLibraryPackage: string | null = null;
let didDedupeLeadingComponent = false;
-
- if (leadingSource) {
- lines.push(formatSourceContextLine(leadingSource, isNextProject));
- }
-
- const emit = (line: string, libraryPackage: string | null) => {
+ let hasTrustedSource = false;
+ let startsWithLowSignalContext = false;
+
+ const emit = (
+ line: string,
+ libraryPackage: string | null,
+ options: { isTrustedSource?: boolean; isLowSignal?: boolean } = {},
+ ) => {
+ if (lines.length === 0 && options.isLowSignal) {
+ startsWithLowSignalContext = true;
+ }
+ if (options.isTrustedSource) {
+ hasTrustedSource = true;
+ }
lines.push(line);
previousLibraryPackage = libraryPackage;
};
+ if (leadingSource) {
+ const isTrustedSource = isTrustedSourcePath(leadingSource.filePath);
+ emit(formatSourceContextLine(leadingSource, isNextProject), null, {
+ isTrustedSource,
+ isLowSignal: !isTrustedSource,
+ });
+ }
+
for (const frame of stack) {
if (lines.length >= maxLines) break;
@@ -475,7 +506,9 @@ const formatStackContext = (
if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) {
const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server";
- emit(`\n in ${componentName ?? ""} (${tag})`, libraryPackage);
+ emit(`\n in ${componentName ?? ""} (${tag})`, libraryPackage, {
+ isLowSignal: true,
+ });
continue;
}
@@ -483,11 +516,13 @@ const formatStackContext = (
emit(
libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`,
libraryPackage,
+ { isLowSignal: Boolean(libraryPackage) },
);
continue;
}
if (resolvedSource) {
+ const isTrustedSource = isTrustedSourcePath(resolvedSource);
emit(
formatSourceContextLine(
{
@@ -499,17 +534,24 @@ const formatStackContext = (
isNextProject,
),
null,
+ {
+ isTrustedSource,
+ isLowSignal: !isTrustedSource,
+ },
);
}
}
- return lines.join("");
+ return {
+ text: lines.join(""),
+ shouldAppendSelectorHint: startsWithLowSignalContext || !hasTrustedSource,
+ };
};
-export const getStackContext = async (
+const getTraceContext = async (
element: Element,
options: StackContextOptions = {},
-): Promise => {
+): Promise => {
const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES;
const leadingSource = await getFiberSource(element);
const stack = await getStack(element);
@@ -519,15 +561,47 @@ export const getStackContext = async (
}
if (leadingSource) {
- return formatSourceContextLine(leadingSource, isNextProjectRuntime());
+ const isTrustedSource = isTrustedSourcePath(leadingSource.filePath);
+ return {
+ text: formatSourceContextLine(leadingSource, isNextProjectRuntime()),
+ shouldAppendSelectorHint: !isTrustedSource,
+ };
}
const componentNames = getComponentNamesFromFiber(findNearestFiberElement(element), maxLines);
if (componentNames.length > 0) {
- return componentNames.map((name) => `\n in ${name}`).join("");
+ return {
+ text: componentNames.map((name) => `\n in ${name}`).join(""),
+ shouldAppendSelectorHint: true,
+ };
}
- return "";
+ return {
+ text: "",
+ shouldAppendSelectorHint: true,
+ };
+};
+
+export const getStackContext = async (
+ element: Element,
+ options: StackContextOptions = {},
+): Promise => {
+ const traceContext = await getTraceContext(element, options);
+ return traceContext.text;
+};
+
+const getSelectorContext = (element: Element, traceContext: TraceContextResult): string =>
+ traceContext.shouldAppendSelectorHint ? formatSelectorContextLine(element) : "";
+
+export const getElementReferenceContext = async (
+ element: Element,
+ options: StackContextOptions = {},
+): Promise => {
+ const traceContext = await getTraceContext(element, options);
+ return `${getInlineHTMLPreview(element)}${traceContext.text}${getSelectorContext(
+ element,
+ traceContext,
+ )}`;
};
export const getElementContext = async (
@@ -536,10 +610,11 @@ export const getElementContext = async (
): Promise => {
const resolvedElement = findNearestFiberElement(element);
const html = getHTMLPreview(resolvedElement);
- const stackContext = await getStackContext(resolvedElement, options);
+ const traceContext = await getTraceContext(resolvedElement, options);
+ const selectorContext = getSelectorContext(resolvedElement, traceContext);
- if (stackContext) {
- return `${html}${stackContext}`;
+ if (traceContext.text || selectorContext) {
+ return `${html}${traceContext.text}${selectorContext}`;
}
return getFallbackContext(resolvedElement);
@@ -552,8 +627,8 @@ const getFallbackContext = (element: Element): string => {
const tagName = getTagName(element);
const attrsText = formatAttrsForPreview(element);
- const directText = getDirectTextContent(element);
- const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH);
+ const previewText = getPreviewTextContent(element, tagName);
+ const truncatedText = truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH);
if (truncatedText.length > 0) {
return `<${tagName}${attrsText}>\n ${truncatedText}\n${tagName}>`;
@@ -614,19 +689,6 @@ const formatAttrsForPreview = (element: Element): string => {
return identifyingParts.join("") + remainingParts.join("") + classAttr;
};
-const getDirectTextContent = (element: Element): string => {
- let directText = "";
- for (const node of element.childNodes) {
- if (node.nodeType === Node.TEXT_NODE) {
- const trimmed = node.textContent?.trim() ?? "";
- if (trimmed) {
- directText += (directText ? " " : "") + trimmed;
- }
- }
- }
- return directText;
-};
-
const formatChildElements = (elements: Array): string => {
if (elements.length === 0) return "";
if (elements.length <= 2) {
@@ -647,8 +709,8 @@ export const getInlineHTMLPreview = (element: Element): string => {
}
const attrsText = formatAttrsForPreview(element);
- const directText = getDirectTextContent(element);
- const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH);
+ const previewText = getPreviewTextContent(element, tagName);
+ const truncatedText = truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH);
if (truncatedText) {
return `<${tagName}${attrsText}>${truncatedText}${tagName}>`;
@@ -659,7 +721,7 @@ export const getInlineHTMLPreview = (element: Element): string => {
export const getHTMLPreview = (element: Element): string => {
const tagName = getTagName(element);
const attrsText = formatAttrsForPreview(element);
- const directText = getDirectTextContent(element);
+ const previewText = getPreviewTextContent(element, tagName);
const topElements: Array = [];
const bottomElements: Array = [];
@@ -682,12 +744,12 @@ export const getHTMLPreview = (element: Element): string => {
let content = "";
const topElementsStr = formatChildElements(topElements);
- if (topElementsStr) content += `\n ${topElementsStr}`;
- if (directText.length > 0) {
- content += `\n ${truncateString(directText, PREVIEW_TEXT_MAX_LENGTH)}`;
+ if (topElementsStr && !previewText) content += `\n ${topElementsStr}`;
+ if (previewText.length > 0) {
+ content += `\n ${truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH)}`;
}
const bottomElementsStr = formatChildElements(bottomElements);
- if (bottomElementsStr) content += `\n ${bottomElementsStr}`;
+ if (bottomElementsStr && !previewText) content += `\n ${bottomElementsStr}`;
if (content.length > 0) {
return `<${tagName}${attrsText}>${content}\n${tagName}>`;
diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts
index e63542667..d2d69b82d 100644
--- a/packages/react-grab/src/core/copy.ts
+++ b/packages/react-grab/src/core/copy.ts
@@ -1,4 +1,4 @@
-import { getInlineHTMLPreview, getStackContext } from "./context.js";
+import { getElementReferenceContext } from "./context.js";
import { copyContent } from "../utils/copy-content.js";
import { normalizeError } from "../utils/normalize-error.js";
@@ -15,11 +15,8 @@ interface CopyFlowHooks {
onCopyError: (error: Error) => void;
}
-const formatElementReference = async (element: Element): Promise => {
- const inlinePreview = getInlineHTMLPreview(element);
- const inlineStack = (await getStackContext(element)).replace(/\n\s+/g, " ");
- return `[${inlinePreview}${inlineStack}]`;
-};
+const formatElementReference = async (element: Element): Promise =>
+ `[${(await getElementReferenceContext(element)).replace(/\n\s+/g, " ")}]`;
const buildClipboardPayload = async (elements: Element[]): Promise => {
const references = await Promise.all(elements.map(formatElementReference));
diff --git a/packages/react-grab/src/utils/create-element-selector.ts b/packages/react-grab/src/utils/create-element-selector.ts
index 0352286d8..4bc0766b5 100644
--- a/packages/react-grab/src/utils/create-element-selector.ts
+++ b/packages/react-grab/src/utils/create-element-selector.ts
@@ -18,6 +18,8 @@ const PREFERRED_SELECTOR_ATTRIBUTE_NAMES = new Set([
"data-cy",
"data-qa",
"aria-label",
+ "href",
+ "src",
"role",
"name",
"title",
diff --git a/packages/react-grab/src/utils/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts
new file mode 100644
index 000000000..74c0c89f3
--- /dev/null
+++ b/packages/react-grab/src/utils/get-preview-text-content.ts
@@ -0,0 +1,52 @@
+const DESCENDANT_TEXT_TAGS = new Set(["a", "code", "pre"]);
+const SKIPPED_TEXT_TAGS = new Set(["script", "style", "template", "noscript"]);
+
+const collapseTextContent = (text: string): string => text.replace(/\s+/g, " ").trim();
+
+const getDirectTextContent = (element: Element): string => {
+ let directText = "";
+ for (const node of element.childNodes) {
+ if (node.nodeType !== Node.TEXT_NODE) continue;
+
+ const trimmed = collapseTextContent(node.textContent ?? "");
+ if (trimmed) {
+ directText += (directText ? " " : "") + trimmed;
+ }
+ }
+ return directText;
+};
+
+const shouldSkipElementText = (element: Element): boolean => {
+ if (element.getAttribute("aria-hidden") === "true") return true;
+ if (element.hasAttribute("hidden")) return true;
+ return SKIPPED_TEXT_TAGS.has(element.tagName.toLowerCase());
+};
+
+const collectDescendantText = (node: Node, parts: string[]): void => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const trimmed = collapseTextContent(node.textContent ?? "");
+ if (trimmed) parts.push(trimmed);
+ return;
+ }
+
+ if (!(node instanceof Element)) return;
+ if (shouldSkipElementText(node)) return;
+
+ for (const childNode of node.childNodes) {
+ collectDescendantText(childNode, parts);
+ }
+};
+
+export const getPreviewTextContent = (element: Element, tagName: string): string => {
+ if (shouldSkipElementText(element)) return "";
+
+ const directText = getDirectTextContent(element);
+ if (!DESCENDANT_TEXT_TAGS.has(tagName)) return directText;
+ if (directText && element.children.length === 0) return directText;
+
+ const parts: string[] = [];
+ for (const childNode of element.childNodes) {
+ collectDescendantText(childNode, parts);
+ }
+ return collapseTextContent(parts.join(" "));
+};