From fa992f79a3de6f421ec10ddcadb5a8d60874400b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Jun 2026 23:00:59 +0000 Subject: [PATCH 01/65] Skip package sourcemap frames for source resolution Co-authored-by: Aiden Bai --- packages/react-grab/package.json | 2 +- packages/react-grab/src/core/context.ts | 39 +++++++++++++------ .../src/utils/parse-package-name.test.ts | 28 +++++++++++++ .../src/utils/parse-package-name.ts | 18 +++++++++ 4 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 packages/react-grab/src/utils/parse-package-name.test.ts diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index 4da96f9e5..a0db6e9d0 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -85,7 +85,7 @@ "build": "NODE_ENV=production vp pack", "build:profiling": "pnpm run prebuild && NODE_ENV=profiling REACT_GRAB_NO_MINIFY=true REACT_GRAB_SOURCEMAP=true vp pack", "dev": "concurrently \"pnpm:css:watch\" \"vp pack --watch\"", - "test": "playwright test", + "test": "vp test run && playwright test", "test:perf": "playwright test --grep @perf --reporter=list", "test:perf:baseline": "PERF_LABEL=baseline playwright test --grep @perf --reporter=list", "test:expect": "bun e2e/react-grab.expect.ts", diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 850a446c4..e8bd85b33 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -281,6 +281,22 @@ interface ResolvedSource { componentName: string | null; } +const isStackSourceFile = (fileName: string | null | undefined): boolean => + Boolean(fileName && isSourceFile(fileName)); + +const isApplicationSourceFile = (fileName: string | null | undefined): boolean => + Boolean(fileName && isSourceFile(fileName) && !parsePackageName(fileName)); + +const isApplicationSourceFrame = (frame: StackFrame): boolean => + isApplicationSourceFile(frame.fileName); + +const pickSourceFrame = (frames: StackFrame[]): StackFrame | null => { + const namedFrame = frames.find( + (frame) => frame.functionName && isSourceComponentName(frame.functionName), + ); + return namedFrame ?? frames[0] ?? null; +}; + const getSourceComponentName = (fiber: Fiber | undefined): string | null => { if (!fiber || !isCompositeFiber(fiber)) return null; const name = getDisplayName(fiber.type); @@ -289,8 +305,8 @@ const getSourceComponentName = (fiber: Fiber | undefined): string | null => { // bippy's getSource prefers React's dev-only _debugSource (the real JSX location // that bundlers like Webpack/Rspack drop from the owner stack) and otherwise -// falls back to the owner stack. We only trust locations that point at a real -// on-disk source file; bundler-virtual URLs are left to the owner-stack scan. +// falls back to the owner stack. We only trust app-owned source locations here; +// library sourcemap paths are left to the owner-stack scan. // This reads React's own dev data, so it works without bippy instrumentation; // getSource can still throw while parsing owner stacks, so it is guarded. const getFiberSource = async (element: Element): Promise => { @@ -299,7 +315,7 @@ const getFiberSource = async (element: Element): Promise try { const source = await getSource(fiber); - if (!source?.fileName || !isSourceFile(source.fileName)) return null; + if (!source?.fileName || !isApplicationSourceFile(source.fileName)) return null; return { filePath: normalizeFilePath(source.fileName), @@ -322,13 +338,12 @@ export const resolveSource = async (element: Element): Promise frame.fileName && isSourceFile(frame.fileName)); - - const namedFrame = sourceFrames.find( - (frame) => frame.functionName && isSourceComponentName(frame.functionName), - ); - - const resolvedFrame = namedFrame ?? sourceFrames[0]; + const sourceFrames = stack.filter(isApplicationSourceFrame); + const fallbackSourceFrames = + sourceFrames.length > 0 + ? sourceFrames + : stack.filter((frame) => isStackSourceFile(frame.fileName)); + const resolvedFrame = pickSourceFrame(fallbackSourceFrames); if (!resolvedFrame?.fileName) return null; return { @@ -454,8 +469,8 @@ const formatStackContext = ( for (const frame of stack) { if (lines.length >= maxLines) break; - const resolvedSource = frame.fileName && isSourceFile(frame.fileName) ? frame.fileName : null; - const libraryPackage = resolvedSource ? null : parsePackageName(frame.fileName); + const libraryPackage = parsePackageName(frame.fileName); + const resolvedSource = isApplicationSourceFile(frame.fileName) ? frame.fileName : null; if (libraryPackage && libraryPackage === previousLibraryPackage) continue; const componentName = diff --git a/packages/react-grab/src/utils/parse-package-name.test.ts b/packages/react-grab/src/utils/parse-package-name.test.ts new file mode 100644 index 000000000..d217d40ea --- /dev/null +++ b/packages/react-grab/src/utils/parse-package-name.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parsePackageName } from "./parse-package-name.js"; + +describe("parsePackageName", () => { + it("reads packages from node_modules paths", () => { + expect(parsePackageName("/app/node_modules/react-tabs/dist/index.js")).toBe("react-tabs"); + expect(parsePackageName("/app/node_modules/@radix-ui/react-tabs/dist/index.js")).toBe( + "@radix-ui/react-tabs", + ); + }); + + it("reads packages from Vite optimized dependency paths", () => { + expect(parsePackageName("/app/node_modules/.vite/deps/@radix-ui_react-tabs.js")).toBe( + "@radix-ui/react-tabs", + ); + }); + + it("reads scoped packages from dependency sourcemap source paths", () => { + expect(parsePackageName("../@rippling/pebble/Tabs/Renderers.js")).toBe("@rippling/pebble"); + expect(parsePackageName("./@radix-ui/react-tabs/src/tabs.tsx")).toBe("@radix-ui/react-tabs"); + }); + + it("does not treat app paths or aliases as packages", () => { + expect(parsePackageName("../components/tabs.tsx")).toBe(null); + expect(parsePackageName("/workspace/app/src/components/tabs.tsx")).toBe(null); + expect(parsePackageName("@/components/tabs.tsx")).toBe(null); + }); +}); diff --git a/packages/react-grab/src/utils/parse-package-name.ts b/packages/react-grab/src/utils/parse-package-name.ts index 82dd675fe..91ccd6e1a 100644 --- a/packages/react-grab/src/utils/parse-package-name.ts +++ b/packages/react-grab/src/utils/parse-package-name.ts @@ -7,6 +7,7 @@ const FILE_EXTENSION_PATTERN = /\.[mc]?[jt]sx?$/i; const VITE_INTERNAL_CHUNK_PATTERN = /^chunk-[A-Za-z0-9_-]+$/; const PATH_SEPARATOR_PATTERN = /[/\\]/; const NAME_AT_VERSION_PATTERN = /^(.+?)@v?\d/; +const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; const splitPathSegments = (path: string): string[] => path.split(PATH_SEPARATOR_PATTERN).filter(Boolean); @@ -73,6 +74,20 @@ const extractVersionedPackageFromUrl = (rawFileName: string): string | null => { return null; }; +const stripRelativePathPrefix = (path: string): string => { + let remainingPath = path; + while (remainingPath.startsWith("../") || remainingPath.startsWith("./")) { + remainingPath = remainingPath.slice(remainingPath.startsWith("../") ? 3 : 2); + } + return remainingPath; +}; + +const extractFromScopedPackageSourcePath = (decodedPath: string): string | null => { + const [scope, packageName] = splitPathSegments(stripRelativePathPrefix(decodedPath)); + if (!scope || !packageName || !SCOPED_PACKAGE_PATTERN.test(scope)) return null; + return `${scope}/${packageName}`; +}; + const extractFromLocalPath = (normalizedPath: string): string | null => extractAfterLastMarker( normalizedPath, @@ -91,6 +106,9 @@ export const parsePackageName = (fileName: string | null | undefined): string | const localResult = extractFromLocalPath(decoded); if (localResult) return localResult; + const packageSourceResult = extractFromScopedPackageSourcePath(decoded); + if (packageSourceResult) return packageSourceResult; + const cdnResult = extractVersionedPackageFromUrl(fileName); if (cdnResult) return cdnResult; From fd6e488ee03b91b782b6b39bede7ffef54266cee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 2 Jun 2026 23:02:20 +0000 Subject: [PATCH 02/65] Limit react-grab unit test scope Co-authored-by: Aiden Bai --- packages/react-grab/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index a0db6e9d0..62a430cc1 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -85,7 +85,7 @@ "build": "NODE_ENV=production vp pack", "build:profiling": "pnpm run prebuild && NODE_ENV=profiling REACT_GRAB_NO_MINIFY=true REACT_GRAB_SOURCEMAP=true vp pack", "dev": "concurrently \"pnpm:css:watch\" \"vp pack --watch\"", - "test": "vp test run && playwright test", + "test": "vp test run src && playwright test", "test:perf": "playwright test --grep @perf --reporter=list", "test:perf:baseline": "PERF_LABEL=baseline playwright test --grep @perf --reporter=list", "test:expect": "bun e2e/react-grab.expect.ts", From 0030f5cf392196d828f0cf257fa9564c63e5afee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 06:43:38 +0000 Subject: [PATCH 03/65] Address package frame quality findings Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 17 +++------------ .../src/utils/parse-package-name.test.ts | 3 +++ .../src/utils/parse-package-name.ts | 21 +++++++++++++++---- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index e8bd85b33..6fb1263dd 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -381,18 +381,6 @@ interface StackContextOptions { maxLines?: number; } -const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { - if (!stack) return false; - return stack.some((frame) => { - if (frame.fileName && isSourceFile(frame.fileName)) return true; - if (frame.isServer && (!frame.functionName || isSourceComponentName(frame.functionName))) { - return true; - } - if (frame.functionName && isSourceComponentName(frame.functionName)) return true; - return false; - }); -}; - const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => { if (!isInstrumentationActive()) return []; const fiber = getFiberFromHostInstance(element); @@ -529,8 +517,9 @@ export const getStackContext = async ( const leadingSource = await getFiberSource(element); const stack = await getStack(element); - if (stack && hasFormattableFrames(stack)) { - return formatStackContext(stack, options, leadingSource); + if (stack) { + const stackContext = formatStackContext(stack, options, leadingSource); + if (stackContext) return stackContext; } if (leadingSource) { diff --git a/packages/react-grab/src/utils/parse-package-name.test.ts b/packages/react-grab/src/utils/parse-package-name.test.ts index d217d40ea..6a2e9e833 100644 --- a/packages/react-grab/src/utils/parse-package-name.test.ts +++ b/packages/react-grab/src/utils/parse-package-name.test.ts @@ -24,5 +24,8 @@ describe("parsePackageName", () => { expect(parsePackageName("../components/tabs.tsx")).toBe(null); expect(parsePackageName("/workspace/app/src/components/tabs.tsx")).toBe(null); expect(parsePackageName("@/components/tabs.tsx")).toBe(null); + expect(parsePackageName("@app/components/tabs.tsx")).toBe(null); + expect(parsePackageName("@company/app/src/tabs.tsx")).toBe(null); + expect(parsePackageName("/@company/app/src/tabs.tsx")).toBe(null); }); }); diff --git a/packages/react-grab/src/utils/parse-package-name.ts b/packages/react-grab/src/utils/parse-package-name.ts index 91ccd6e1a..6eb3f109d 100644 --- a/packages/react-grab/src/utils/parse-package-name.ts +++ b/packages/react-grab/src/utils/parse-package-name.ts @@ -8,6 +8,7 @@ const VITE_INTERNAL_CHUNK_PATTERN = /^chunk-[A-Za-z0-9_-]+$/; const PATH_SEPARATOR_PATTERN = /[/\\]/; const NAME_AT_VERSION_PATTERN = /^(.+?)@v?\d/; const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; +const PACKAGE_NAME_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const splitPathSegments = (path: string): string[] => path.split(PATH_SEPARATOR_PATTERN).filter(Boolean); @@ -74,17 +75,29 @@ const extractVersionedPackageFromUrl = (rawFileName: string): string | null => { return null; }; -const stripRelativePathPrefix = (path: string): string => { +const stripRelativeSourcePathPrefix = (path: string): string | null => { let remainingPath = path; + let didStripPrefix = false; while (remainingPath.startsWith("../") || remainingPath.startsWith("./")) { + didStripPrefix = true; remainingPath = remainingPath.slice(remainingPath.startsWith("../") ? 3 : 2); } - return remainingPath; + return didStripPrefix ? remainingPath : null; }; const extractFromScopedPackageSourcePath = (decodedPath: string): string | null => { - const [scope, packageName] = splitPathSegments(stripRelativePathPrefix(decodedPath)); - if (!scope || !packageName || !SCOPED_PACKAGE_PATTERN.test(scope)) return null; + const sourcePath = stripRelativeSourcePathPrefix(decodedPath); + if (!sourcePath) return null; + + const [scope, packageName] = splitPathSegments(sourcePath); + if ( + !scope || + !packageName || + !SCOPED_PACKAGE_PATTERN.test(scope) || + !PACKAGE_NAME_SEGMENT_PATTERN.test(packageName) + ) { + return null; + } return `${scope}/${packageName}`; }; From 4d8048b2065ee81fb7214ad21b9ba1bb804f63be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 06:55:25 +0000 Subject: [PATCH 04/65] Address review feedback on package stack frames Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 19 ++++++++++++++----- .../src/utils/parse-package-name.test.ts | 2 ++ .../src/utils/parse-package-name.ts | 4 +++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 6fb1263dd..af32d7828 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -442,16 +442,16 @@ const formatStackContext = ( const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options; const isNextProject = isNextProjectRuntime(); const lines: string[] = []; - let previousLibraryPackage: string | null = null; + let previousLibraryFrameKey: string | null = null; let didDedupeLeadingComponent = false; if (leadingSource) { lines.push(formatSourceContextLine(leadingSource, isNextProject)); } - const emit = (line: string, libraryPackage: string | null) => { + const emit = (line: string, libraryFrameKey: string | null) => { lines.push(line); - previousLibraryPackage = libraryPackage; + previousLibraryFrameKey = libraryFrameKey; }; for (const frame of stack) { @@ -463,6 +463,10 @@ const formatStackContext = ( const componentName = frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null; + const libraryFrameKey = libraryPackage + ? `${libraryPackage}:${componentName ?? ""}:${frame.isServer ? "server" : "client"}` + : null; + if (libraryFrameKey && libraryFrameKey === previousLibraryFrameKey) continue; // The owner stack's top frame is usually the same component the leading // source line already names. Drop only that single duplicate; deeper frames @@ -478,18 +482,23 @@ 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})`, libraryFrameKey); continue; } if (!resolvedSource && componentName) { emit( libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`, - libraryPackage, + libraryFrameKey, ); continue; } + if (libraryPackage) { + emit(`\n in ${libraryPackage}`, libraryFrameKey); + continue; + } + if (resolvedSource) { emit( formatSourceContextLine( diff --git a/packages/react-grab/src/utils/parse-package-name.test.ts b/packages/react-grab/src/utils/parse-package-name.test.ts index 6a2e9e833..3e3cf3b81 100644 --- a/packages/react-grab/src/utils/parse-package-name.test.ts +++ b/packages/react-grab/src/utils/parse-package-name.test.ts @@ -26,6 +26,8 @@ describe("parsePackageName", () => { expect(parsePackageName("@/components/tabs.tsx")).toBe(null); expect(parsePackageName("@app/components/tabs.tsx")).toBe(null); expect(parsePackageName("@company/app/src/tabs.tsx")).toBe(null); + expect(parsePackageName("../@company/app/src/tabs.tsx")).toBe(null); + expect(parsePackageName("./@company/web/src/tabs.tsx")).toBe(null); expect(parsePackageName("/@company/app/src/tabs.tsx")).toBe(null); }); }); diff --git a/packages/react-grab/src/utils/parse-package-name.ts b/packages/react-grab/src/utils/parse-package-name.ts index 6eb3f109d..a7c3737ba 100644 --- a/packages/react-grab/src/utils/parse-package-name.ts +++ b/packages/react-grab/src/utils/parse-package-name.ts @@ -9,6 +9,7 @@ const PATH_SEPARATOR_PATTERN = /[/\\]/; const NAME_AT_VERSION_PATTERN = /^(.+?)@v?\d/; const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; const PACKAGE_NAME_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const APPLICATION_PACKAGE_NAME_SEGMENTS = new Set(["app", "web", "website", "frontend", "client"]); const splitPathSegments = (path: string): string[] => path.split(PATH_SEPARATOR_PATTERN).filter(Boolean); @@ -94,7 +95,8 @@ const extractFromScopedPackageSourcePath = (decodedPath: string): string | null !scope || !packageName || !SCOPED_PACKAGE_PATTERN.test(scope) || - !PACKAGE_NAME_SEGMENT_PATTERN.test(packageName) + !PACKAGE_NAME_SEGMENT_PATTERN.test(packageName) || + APPLICATION_PACKAGE_NAME_SEGMENTS.has(packageName) ) { return null; } From ea15420fffbe227c344e48f842728762de9a5938 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 07:04:05 +0000 Subject: [PATCH 05/65] Fix package frame dedupe typecheck Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index af32d7828..b333500d0 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -459,7 +459,6 @@ const formatStackContext = ( const libraryPackage = parsePackageName(frame.fileName); const resolvedSource = isApplicationSourceFile(frame.fileName) ? frame.fileName : null; - if (libraryPackage && libraryPackage === previousLibraryPackage) continue; const componentName = frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null; From a8373dbd99caa8db3e372154d153953ea8b97ef8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 07:17:48 +0000 Subject: [PATCH 06/65] Ignore shadcn ui source frames Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 8 ++++++- ...is-ignored-application-source-path.test.ts | 18 ++++++++++++++++ .../is-ignored-application-source-path.ts | 21 +++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 packages/react-grab/src/utils/is-ignored-application-source-path.test.ts create mode 100644 packages/react-grab/src/utils/is-ignored-application-source-path.ts diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index b333500d0..dc0af4fe9 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -27,6 +27,7 @@ import { import { getTagName } from "../utils/get-tag-name.js"; import { truncateString } from "../utils/truncate-string.js"; import { getNextBasePath } from "../utils/get-next-base-path.js"; +import { isIgnoredApplicationSourcePath } from "../utils/is-ignored-application-source-path.js"; 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"; @@ -285,7 +286,12 @@ const isStackSourceFile = (fileName: string | null | undefined): boolean => Boolean(fileName && isSourceFile(fileName)); const isApplicationSourceFile = (fileName: string | null | undefined): boolean => - Boolean(fileName && isSourceFile(fileName) && !parsePackageName(fileName)); + Boolean( + fileName && + isSourceFile(fileName) && + !parsePackageName(fileName) && + !isIgnoredApplicationSourcePath(fileName), + ); const isApplicationSourceFrame = (frame: StackFrame): boolean => isApplicationSourceFile(frame.fileName); diff --git a/packages/react-grab/src/utils/is-ignored-application-source-path.test.ts b/packages/react-grab/src/utils/is-ignored-application-source-path.test.ts new file mode 100644 index 000000000..50e5140a3 --- /dev/null +++ b/packages/react-grab/src/utils/is-ignored-application-source-path.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; +import { isIgnoredApplicationSourcePath } from "./is-ignored-application-source-path.js"; + +describe("isIgnoredApplicationSourcePath", () => { + it("ignores shadcn components/ui source paths", () => { + expect(isIgnoredApplicationSourcePath("components/ui/button.tsx")).toBe(true); + expect(isIgnoredApplicationSourcePath("src/components/ui/dialog.tsx")).toBe(true); + expect(isIgnoredApplicationSourcePath("../components/ui/tabs.tsx")).toBe(true); + expect(isIgnoredApplicationSourcePath("/workspace/app/components/ui/card.tsx")).toBe(true); + }); + + it("does not ignore nearby application source paths", () => { + expect(isIgnoredApplicationSourcePath("components/nav/button.tsx")).toBe(false); + expect(isIgnoredApplicationSourcePath("src/components/ui-button.tsx")).toBe(false); + expect(isIgnoredApplicationSourcePath("src/design-system/ui/button.tsx")).toBe(false); + expect(isIgnoredApplicationSourcePath(null)).toBe(false); + }); +}); diff --git a/packages/react-grab/src/utils/is-ignored-application-source-path.ts b/packages/react-grab/src/utils/is-ignored-application-source-path.ts new file mode 100644 index 000000000..ba59a2763 --- /dev/null +++ b/packages/react-grab/src/utils/is-ignored-application-source-path.ts @@ -0,0 +1,21 @@ +import { normalizeFilePath } from "./normalize-file-path.js"; + +const COMPONENTS_DIRECTORY_NAME = "components"; +const UI_DIRECTORY_NAME = "ui"; +const PATH_SEPARATOR_PATTERN = /[/\\]/; + +export const isIgnoredApplicationSourcePath = (fileName: string | null | undefined): boolean => { + if (!fileName) return false; + + const segments = normalizeFilePath(fileName).split(PATH_SEPARATOR_PATTERN).filter(Boolean); + for (let segmentIndex = 0; segmentIndex < segments.length - 1; segmentIndex++) { + if ( + segments[segmentIndex] === COMPONENTS_DIRECTORY_NAME && + segments[segmentIndex + 1] === UI_DIRECTORY_NAME + ) { + return true; + } + } + + return false; +}; From 190cd69f87ab9f9258a12e74c79c389f26bec884 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 22:06:23 +0000 Subject: [PATCH 07/65] Add configurable source frame policy Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 46 +++++++----- packages/react-grab/src/core/index.tsx | 6 ++ .../react-grab/src/core/plugin-registry.ts | 4 + packages/react-grab/src/types.ts | 5 ++ .../src/utils/get-script-options.ts | 8 ++ ...is-ignored-application-source-path.test.ts | 18 ----- .../is-ignored-application-source-path.ts | 21 ------ .../src/utils/source-frame-policy.test.ts | 59 +++++++++++++++ .../src/utils/source-frame-policy.ts | 73 +++++++++++++++++++ 9 files changed, 181 insertions(+), 59 deletions(-) delete mode 100644 packages/react-grab/src/utils/is-ignored-application-source-path.test.ts delete mode 100644 packages/react-grab/src/utils/is-ignored-application-source-path.ts create mode 100644 packages/react-grab/src/utils/source-frame-policy.test.ts create mode 100644 packages/react-grab/src/utils/source-frame-policy.ts diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index dc0af4fe9..6917fe80a 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -1,5 +1,4 @@ import { - isSourceFile, getOwnerStack, getSource, formatOwnerStack, @@ -27,17 +26,22 @@ import { import { getTagName } from "../utils/get-tag-name.js"; import { truncateString } from "../utils/truncate-string.js"; import { getNextBasePath } from "../utils/get-next-base-path.js"; -import { isIgnoredApplicationSourcePath } from "../utils/is-ignored-application-source-path.js"; 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 { classifySourcePath } from "../utils/source-frame-policy.js"; import { isInternalAttribute } from "../utils/strip-internal-attributes.js"; import { isInternalComponentName, isUsefulComponentName, } from "../utils/is-useful-component-name.js"; +import type { SourceOptions } from "../types.js"; let cachedIsNextProject: boolean | undefined; +let activeSourceOptions: SourceOptions | undefined; + +export const setSourceOptions = (sourceOptions: SourceOptions | undefined): void => { + activeSourceOptions = sourceOptions; +}; export const isNextProjectRuntime = (shouldRevalidate?: boolean): boolean => { if (shouldRevalidate) { @@ -282,16 +286,8 @@ interface ResolvedSource { componentName: string | null; } -const isStackSourceFile = (fileName: string | null | undefined): boolean => - Boolean(fileName && isSourceFile(fileName)); - const isApplicationSourceFile = (fileName: string | null | undefined): boolean => - Boolean( - fileName && - isSourceFile(fileName) && - !parsePackageName(fileName) && - !isIgnoredApplicationSourcePath(fileName), - ); + classifySourcePath(fileName, activeSourceOptions).kind === "app-source"; const isApplicationSourceFrame = (frame: StackFrame): boolean => isApplicationSourceFile(frame.fileName); @@ -344,12 +340,21 @@ export const resolveSource = async (element: Element): Promise 0 - ? sourceFrames - : stack.filter((frame) => isStackSourceFile(frame.fileName)); - const resolvedFrame = pickSourceFrame(fallbackSourceFrames); + const appSourceFrames = stack.filter(isApplicationSourceFrame); + const ignoredAppSourceFrames = stack.filter( + (frame) => + classifySourcePath(frame.fileName, activeSourceOptions).kind === "ignored-app-source", + ); + const packageSourceFrames = stack.filter( + (frame) => classifySourcePath(frame.fileName, activeSourceOptions).kind === "package-source", + ); + const sourceFrames = + appSourceFrames.length > 0 + ? appSourceFrames + : ignoredAppSourceFrames.length > 0 + ? ignoredAppSourceFrames + : packageSourceFrames; + const resolvedFrame = pickSourceFrame(sourceFrames); if (!resolvedFrame?.fileName) return null; return { @@ -463,8 +468,9 @@ const formatStackContext = ( for (const frame of stack) { if (lines.length >= maxLines) break; - const libraryPackage = parsePackageName(frame.fileName); - const resolvedSource = isApplicationSourceFile(frame.fileName) ? frame.fileName : null; + const sourcePath = classifySourcePath(frame.fileName, activeSourceOptions); + const libraryPackage = sourcePath.packageName; + const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; const componentName = frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 83bab4f16..58261ea20 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -33,6 +33,7 @@ import { getComponentDisplayName, isNextProjectRuntime, resolveSource, + setSourceOptions, } from "./context.js"; import { createNoopApi } from "./noop-api.js"; import { createEventListenerManager } from "./events.js"; @@ -214,6 +215,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const pluginRegistry = createPluginRegistry(settableOptions); + createEffect(() => { + setSourceOptions(pluginRegistry.store.options.source); + }); + onCleanup(() => setSourceOptions(undefined)); + const { store, actions, pointer, viewportVersion, current } = createGrabStore({ keyHoldDuration: pluginRegistry.store.options.keyHoldDuration ?? DEFAULT_KEY_HOLD_DURATION_MS, }); diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index 690affc03..c8f89ad62 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -16,6 +16,7 @@ import type { ActivationMode, ActivationKey, SettableOptions, + SourceOptions, AgentContext, ActionContext, } from "../types.js"; @@ -34,6 +35,7 @@ interface OptionsState { activationKey: ActivationKey | undefined; getContent: ((elements: Element[]) => Promise | string) | undefined; freezeReactUpdates: boolean; + source: SourceOptions | undefined; } const DEFAULT_OPTIONS: OptionsState = { @@ -43,6 +45,7 @@ const DEFAULT_OPTIONS: OptionsState = { activationKey: undefined, getContent: undefined, freezeReactUpdates: true, + source: undefined, }; interface PluginStoreState { @@ -106,6 +109,7 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { "activationKey", "getContent", "freezeReactUpdates", + "source", ]; const setOptions = (optionUpdates: SettableOptions) => { diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 0c59662a1..609659e74 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -349,6 +349,7 @@ export interface Options { allowActivationInsideInput?: boolean; activationKey?: ActivationKey; getContent?: (elements: Element[]) => Promise | string; + source?: SourceOptions; /** * Whether to freeze React state updates while React Grab is active. * This prevents UI changes from interfering with element selection. @@ -363,6 +364,10 @@ export interface Options { telemetry?: boolean; } +export interface SourceOptions { + ignorePaths?: Array; +} + export interface SettableOptions extends Options { enabled?: never; telemetry?: never; diff --git a/packages/react-grab/src/utils/get-script-options.ts b/packages/react-grab/src/utils/get-script-options.ts index e0a267786..795423470 100644 --- a/packages/react-grab/src/utils/get-script-options.ts +++ b/packages/react-grab/src/utils/get-script-options.ts @@ -27,6 +27,14 @@ const parseOptionsFromJson = (rawValue: unknown): Partial | null => { if (typeof rawValue.freezeReactUpdates === "boolean") { parsedOptions.freezeReactUpdates = rawValue.freezeReactUpdates; } + if (isObjectRecord(rawValue.source) && Array.isArray(rawValue.source.ignorePaths)) { + const ignorePaths = rawValue.source.ignorePaths.filter( + (ignorePath) => typeof ignorePath === "string", + ); + if (ignorePaths.length > 0) { + parsedOptions.source = { ignorePaths }; + } + } if (typeof rawValue.telemetry === "boolean") { parsedOptions.telemetry = rawValue.telemetry; } diff --git a/packages/react-grab/src/utils/is-ignored-application-source-path.test.ts b/packages/react-grab/src/utils/is-ignored-application-source-path.test.ts deleted file mode 100644 index 50e5140a3..000000000 --- a/packages/react-grab/src/utils/is-ignored-application-source-path.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { isIgnoredApplicationSourcePath } from "./is-ignored-application-source-path.js"; - -describe("isIgnoredApplicationSourcePath", () => { - it("ignores shadcn components/ui source paths", () => { - expect(isIgnoredApplicationSourcePath("components/ui/button.tsx")).toBe(true); - expect(isIgnoredApplicationSourcePath("src/components/ui/dialog.tsx")).toBe(true); - expect(isIgnoredApplicationSourcePath("../components/ui/tabs.tsx")).toBe(true); - expect(isIgnoredApplicationSourcePath("/workspace/app/components/ui/card.tsx")).toBe(true); - }); - - it("does not ignore nearby application source paths", () => { - expect(isIgnoredApplicationSourcePath("components/nav/button.tsx")).toBe(false); - expect(isIgnoredApplicationSourcePath("src/components/ui-button.tsx")).toBe(false); - expect(isIgnoredApplicationSourcePath("src/design-system/ui/button.tsx")).toBe(false); - expect(isIgnoredApplicationSourcePath(null)).toBe(false); - }); -}); diff --git a/packages/react-grab/src/utils/is-ignored-application-source-path.ts b/packages/react-grab/src/utils/is-ignored-application-source-path.ts deleted file mode 100644 index ba59a2763..000000000 --- a/packages/react-grab/src/utils/is-ignored-application-source-path.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { normalizeFilePath } from "./normalize-file-path.js"; - -const COMPONENTS_DIRECTORY_NAME = "components"; -const UI_DIRECTORY_NAME = "ui"; -const PATH_SEPARATOR_PATTERN = /[/\\]/; - -export const isIgnoredApplicationSourcePath = (fileName: string | null | undefined): boolean => { - if (!fileName) return false; - - const segments = normalizeFilePath(fileName).split(PATH_SEPARATOR_PATTERN).filter(Boolean); - for (let segmentIndex = 0; segmentIndex < segments.length - 1; segmentIndex++) { - if ( - segments[segmentIndex] === COMPONENTS_DIRECTORY_NAME && - segments[segmentIndex + 1] === UI_DIRECTORY_NAME - ) { - return true; - } - } - - return false; -}; diff --git a/packages/react-grab/src/utils/source-frame-policy.test.ts b/packages/react-grab/src/utils/source-frame-policy.test.ts new file mode 100644 index 000000000..0f0a05b56 --- /dev/null +++ b/packages/react-grab/src/utils/source-frame-policy.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; +import { classifySourcePath } from "./source-frame-policy.js"; + +describe("classifySourcePath", () => { + it("classifies application source paths", () => { + expect(classifySourcePath("src/app/employees/employee-tabs.tsx")).toEqual({ + kind: "app-source", + packageName: null, + }); + }); + + it("classifies package source paths", () => { + expect(classifySourcePath("../@rippling/pebble/Tabs/Renderers.js")).toEqual({ + kind: "package-source", + packageName: "@rippling/pebble", + }); + }); + + it("classifies default ignored app source paths", () => { + expect(classifySourcePath("components/ui/button.tsx")).toEqual({ + kind: "ignored-app-source", + packageName: null, + }); + expect(classifySourcePath("src/components/ui/dialog.tsx")).toEqual({ + kind: "ignored-app-source", + packageName: null, + }); + }); + + it("classifies configured ignored source paths", () => { + expect( + classifySourcePath("src/design-system/button.tsx", { + ignorePaths: ["design-system", /\/packages\/ui\//], + }), + ).toEqual({ + kind: "ignored-app-source", + packageName: null, + }); + expect( + classifySourcePath("/workspace/packages/ui/src/button.tsx", { + ignorePaths: ["design-system", /\/packages\/ui\//], + }), + ).toEqual({ + kind: "ignored-app-source", + packageName: null, + }); + }); + + it("does not ignore nearby app source paths", () => { + expect(classifySourcePath("src/components/ui-button.tsx")).toEqual({ + kind: "app-source", + packageName: null, + }); + expect(classifySourcePath("src/design-system-ui/button.tsx", { ignorePaths: ["ui"] })).toEqual({ + kind: "app-source", + packageName: null, + }); + }); +}); diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts new file mode 100644 index 000000000..7d0f23d24 --- /dev/null +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -0,0 +1,73 @@ +import { isSourceFile } from "bippy/source"; +import type { SourceOptions } from "../types.js"; +import { normalizeFilePath } from "./normalize-file-path.js"; +import { parsePackageName } from "./parse-package-name.js"; + +const DEFAULT_IGNORED_SOURCE_PATHS: readonly string[] = ["components/ui"]; +const PATH_SEPARATOR_PATTERN = /[/\\]/; + +export interface SourcePathClassification { + kind: "app-source" | "ignored-app-source" | "package-source" | "unknown"; + packageName: string | null; +} + +const splitPathSegments = (path: string): string[] => + normalizeFilePath(path).split(PATH_SEPARATOR_PATTERN).filter(Boolean); + +const matchesPathSegments = (pathSegments: string[], pattern: string): boolean => { + const patternSegments = splitPathSegments(pattern); + if (patternSegments.length === 0 || patternSegments.length > pathSegments.length) return false; + + for (let pathIndex = 0; pathIndex <= pathSegments.length - patternSegments.length; pathIndex++) { + let didMatch = true; + for (let patternIndex = 0; patternIndex < patternSegments.length; patternIndex++) { + if (pathSegments[pathIndex + patternIndex] !== patternSegments[patternIndex]) { + didMatch = false; + break; + } + } + if (didMatch) return true; + } + + return false; +}; + +const matchesIgnoredSourcePath = (fileName: string, sourceOptions?: SourceOptions): boolean => { + const normalizedPath = normalizeFilePath(fileName); + const pathSegments = splitPathSegments(normalizedPath); + const ignoredSourcePaths = [ + ...DEFAULT_IGNORED_SOURCE_PATHS, + ...(sourceOptions?.ignorePaths ?? []), + ]; + + for (const ignoredSourcePath of ignoredSourcePaths) { + if (typeof ignoredSourcePath === "string") { + if (matchesPathSegments(pathSegments, ignoredSourcePath)) return true; + continue; + } + + if (ignoredSourcePath.test(normalizedPath)) return true; + } + + return false; +}; + +export const classifySourcePath = ( + fileName: string | null | undefined, + sourceOptions?: SourceOptions, +): SourcePathClassification => { + if (!fileName || !isSourceFile(fileName)) { + return { kind: "unknown", packageName: null }; + } + + const packageName = parsePackageName(fileName); + if (packageName) { + return { kind: "package-source", packageName }; + } + + if (matchesIgnoredSourcePath(fileName, sourceOptions)) { + return { kind: "ignored-app-source", packageName: null }; + } + + return { kind: "app-source", packageName: null }; +}; From b91609470f343546a567783eb12c30d3146d1f6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 22:15:35 +0000 Subject: [PATCH 08/65] Reset source ignore regex state Co-authored-by: Aiden Bai --- .../src/utils/source-frame-policy.test.ts | 14 ++++++++++++++ .../react-grab/src/utils/source-frame-policy.ts | 1 + 2 files changed, 15 insertions(+) diff --git a/packages/react-grab/src/utils/source-frame-policy.test.ts b/packages/react-grab/src/utils/source-frame-policy.test.ts index 0f0a05b56..8f0a49cde 100644 --- a/packages/react-grab/src/utils/source-frame-policy.test.ts +++ b/packages/react-grab/src/utils/source-frame-policy.test.ts @@ -46,6 +46,20 @@ describe("classifySourcePath", () => { }); }); + it("resets stateful configured ignored source regexes", () => { + const statefulIgnorePath = /\/packages\/ui\//g; + const sourceOptions = { ignorePaths: [statefulIgnorePath] }; + + expect(classifySourcePath("/workspace/packages/ui/src/button.tsx", sourceOptions)).toEqual({ + kind: "ignored-app-source", + packageName: null, + }); + expect(classifySourcePath("/workspace/packages/ui/src/dialog.tsx", sourceOptions)).toEqual({ + kind: "ignored-app-source", + packageName: null, + }); + }); + it("does not ignore nearby app source paths", () => { expect(classifySourcePath("src/components/ui-button.tsx")).toEqual({ kind: "app-source", diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index 7d0f23d24..1f5939bb0 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -46,6 +46,7 @@ const matchesIgnoredSourcePath = (fileName: string, sourceOptions?: SourceOption continue; } + ignoredSourcePath.lastIndex = 0; if (ignoredSourcePath.test(normalizedPath)) return true; } From 67f2294d62610aa6b6de090c06e97211bd463f95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 22:24:41 +0000 Subject: [PATCH 09/65] Skip ignored app frames in stack output Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 6917fe80a..de1498c2c 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -469,6 +469,8 @@ const formatStackContext = ( if (lines.length >= maxLines) break; const sourcePath = classifySourcePath(frame.fileName, activeSourceOptions); + if (sourcePath.kind === "ignored-app-source") continue; + const libraryPackage = sourcePath.packageName; const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; From 8189395a63839d5e826d4d999a557204165978d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 22:34:48 +0000 Subject: [PATCH 10/65] Preserve leading dedupe for ignored frames Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index de1498c2c..f49a26ff4 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -469,8 +469,6 @@ const formatStackContext = ( if (lines.length >= maxLines) break; const sourcePath = classifySourcePath(frame.fileName, activeSourceOptions); - if (sourcePath.kind === "ignored-app-source") continue; - const libraryPackage = sourcePath.packageName; const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; @@ -493,6 +491,8 @@ const formatStackContext = ( continue; } + if (sourcePath.kind === "ignored-app-source") continue; + if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; emit(`\n in ${componentName ?? ""} (${tag})`, libraryFrameKey); From 6652b88eb4aa8d0a882c4dc159786bbaa750e75d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 22:44:42 +0000 Subject: [PATCH 11/65] Reduce source frame classification overhead Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 25 ++++++------ .../src/utils/source-frame-policy.ts | 39 +++++++++++++++---- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index f49a26ff4..48a62aa76 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -289,9 +289,6 @@ interface ResolvedSource { const isApplicationSourceFile = (fileName: string | null | undefined): boolean => classifySourcePath(fileName, activeSourceOptions).kind === "app-source"; -const isApplicationSourceFrame = (frame: StackFrame): boolean => - isApplicationSourceFile(frame.fileName); - const pickSourceFrame = (frames: StackFrame[]): StackFrame | null => { const namedFrame = frames.find( (frame) => frame.functionName && isSourceComponentName(frame.functionName), @@ -340,14 +337,20 @@ export const resolveSource = async (element: Element): Promise - classifySourcePath(frame.fileName, activeSourceOptions).kind === "ignored-app-source", - ); - const packageSourceFrames = stack.filter( - (frame) => classifySourcePath(frame.fileName, activeSourceOptions).kind === "package-source", - ); + const appSourceFrames: StackFrame[] = []; + const ignoredAppSourceFrames: StackFrame[] = []; + const packageSourceFrames: StackFrame[] = []; + for (const frame of stack) { + const sourcePath = classifySourcePath(frame.fileName, activeSourceOptions); + if (sourcePath.kind === "app-source") { + appSourceFrames.push(frame); + } else if (sourcePath.kind === "ignored-app-source") { + ignoredAppSourceFrames.push(frame); + } else if (sourcePath.kind === "package-source") { + packageSourceFrames.push(frame); + } + } + const sourceFrames = appSourceFrames.length > 0 ? appSourceFrames diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index 1f5939bb0..8ce86e24e 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -5,6 +5,7 @@ import { parsePackageName } from "./parse-package-name.js"; const DEFAULT_IGNORED_SOURCE_PATHS: readonly string[] = ["components/ui"]; const PATH_SEPARATOR_PATTERN = /[/\\]/; +const defaultClassificationCache = new Map(); export interface SourcePathClassification { kind: "app-source" | "ignored-app-source" | "package-source" | "unknown"; @@ -14,8 +15,11 @@ export interface SourcePathClassification { const splitPathSegments = (path: string): string[] => normalizeFilePath(path).split(PATH_SEPARATOR_PATTERN).filter(Boolean); -const matchesPathSegments = (pathSegments: string[], pattern: string): boolean => { - const patternSegments = splitPathSegments(pattern); +const DEFAULT_IGNORED_SOURCE_PATH_SEGMENTS = DEFAULT_IGNORED_SOURCE_PATHS.map((sourcePath) => + splitPathSegments(sourcePath), +); + +const matchesPathSegments = (pathSegments: string[], patternSegments: string[]): boolean => { if (patternSegments.length === 0 || patternSegments.length > pathSegments.length) return false; for (let pathIndex = 0; pathIndex <= pathSegments.length - patternSegments.length; pathIndex++) { @@ -35,14 +39,17 @@ const matchesPathSegments = (pathSegments: string[], pattern: string): boolean = const matchesIgnoredSourcePath = (fileName: string, sourceOptions?: SourceOptions): boolean => { const normalizedPath = normalizeFilePath(fileName); const pathSegments = splitPathSegments(normalizedPath); - const ignoredSourcePaths = [ - ...DEFAULT_IGNORED_SOURCE_PATHS, - ...(sourceOptions?.ignorePaths ?? []), - ]; - for (const ignoredSourcePath of ignoredSourcePaths) { + for (const ignoredSourcePathSegments of DEFAULT_IGNORED_SOURCE_PATH_SEGMENTS) { + if (matchesPathSegments(pathSegments, ignoredSourcePathSegments)) return true; + } + + const customIgnoredSourcePaths = sourceOptions?.ignorePaths; + if (!customIgnoredSourcePaths) return false; + + for (const ignoredSourcePath of customIgnoredSourcePaths) { if (typeof ignoredSourcePath === "string") { - if (matchesPathSegments(pathSegments, ignoredSourcePath)) return true; + if (matchesPathSegments(pathSegments, splitPathSegments(ignoredSourcePath))) return true; continue; } @@ -56,6 +63,22 @@ const matchesIgnoredSourcePath = (fileName: string, sourceOptions?: SourceOption export const classifySourcePath = ( fileName: string | null | undefined, sourceOptions?: SourceOptions, +): SourcePathClassification => { + if (!sourceOptions?.ignorePaths?.length && fileName) { + const cachedClassification = defaultClassificationCache.get(fileName); + if (cachedClassification) return cachedClassification; + } + + const classification = classifySourcePathUncached(fileName, sourceOptions); + if (!sourceOptions?.ignorePaths?.length && fileName) { + defaultClassificationCache.set(fileName, classification); + } + return classification; +}; + +const classifySourcePathUncached = ( + fileName: string | null | undefined, + sourceOptions?: SourceOptions, ): SourcePathClassification => { if (!fileName || !isSourceFile(fileName)) { return { kind: "unknown", packageName: null }; From bc020d8543743b677ffd140031838c77ecdb8156 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 22:58:19 +0000 Subject: [PATCH 12/65] Preserve package labels for bundled frames Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 4 ++-- packages/react-grab/src/utils/source-frame-policy.test.ts | 4 ++++ packages/react-grab/src/utils/source-frame-policy.ts | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 48a62aa76..1d024c320 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -472,6 +472,8 @@ const formatStackContext = ( if (lines.length >= maxLines) break; const sourcePath = classifySourcePath(frame.fileName, activeSourceOptions); + if (sourcePath.kind === "ignored-app-source") continue; + const libraryPackage = sourcePath.packageName; const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; @@ -494,8 +496,6 @@ const formatStackContext = ( continue; } - if (sourcePath.kind === "ignored-app-source") continue; - if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; emit(`\n in ${componentName ?? ""} (${tag})`, libraryFrameKey); diff --git a/packages/react-grab/src/utils/source-frame-policy.test.ts b/packages/react-grab/src/utils/source-frame-policy.test.ts index 8f0a49cde..fd310f5d1 100644 --- a/packages/react-grab/src/utils/source-frame-policy.test.ts +++ b/packages/react-grab/src/utils/source-frame-policy.test.ts @@ -14,6 +14,10 @@ describe("classifySourcePath", () => { kind: "package-source", packageName: "@rippling/pebble", }); + expect(classifySourcePath("/app/node_modules/@radix-ui/react-tabs/dist/index.min.js")).toEqual({ + kind: "package-source", + packageName: "@radix-ui/react-tabs", + }); }); it("classifies default ignored app source paths", () => { diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index 8ce86e24e..a09f8b253 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -80,7 +80,7 @@ const classifySourcePathUncached = ( fileName: string | null | undefined, sourceOptions?: SourceOptions, ): SourcePathClassification => { - if (!fileName || !isSourceFile(fileName)) { + if (!fileName) { return { kind: "unknown", packageName: null }; } @@ -89,6 +89,10 @@ const classifySourcePathUncached = ( return { kind: "package-source", packageName }; } + if (!isSourceFile(fileName)) { + return { kind: "unknown", packageName: null }; + } + if (matchesIgnoredSourcePath(fileName, sourceOptions)) { return { kind: "ignored-app-source", packageName: null }; } From 84aaeea9b2073cc08e8e4896b803383dde4e65e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 23:14:37 +0000 Subject: [PATCH 13/65] Pass source policy options explicitly Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 38 ++++++++++++------- packages/react-grab/src/core/copy.ts | 20 +++++++--- packages/react-grab/src/core/index.tsx | 18 ++++----- packages/react-grab/src/primitives.ts | 16 ++++++-- .../react-grab/src/utils/generate-snippet.ts | 2 + 5 files changed, 61 insertions(+), 33 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 1d024c320..523932991 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -37,11 +37,6 @@ import { import type { SourceOptions } from "../types.js"; let cachedIsNextProject: boolean | undefined; -let activeSourceOptions: SourceOptions | undefined; - -export const setSourceOptions = (sourceOptions: SourceOptions | undefined): void => { - activeSourceOptions = sourceOptions; -}; export const isNextProjectRuntime = (shouldRevalidate?: boolean): boolean => { if (shouldRevalidate) { @@ -286,8 +281,10 @@ interface ResolvedSource { componentName: string | null; } -const isApplicationSourceFile = (fileName: string | null | undefined): boolean => - classifySourcePath(fileName, activeSourceOptions).kind === "app-source"; +const isApplicationSourceFile = ( + fileName: string | null | undefined, + sourceOptions: SourceOptions | undefined, +): boolean => classifySourcePath(fileName, sourceOptions).kind === "app-source"; const pickSourceFrame = (frames: StackFrame[]): StackFrame | null => { const namedFrame = frames.find( @@ -308,13 +305,18 @@ const getSourceComponentName = (fiber: Fiber | undefined): string | null => { // library sourcemap paths are left to the owner-stack scan. // This reads React's own dev data, so it works without bippy instrumentation; // getSource can still throw while parsing owner stacks, so it is guarded. -const getFiberSource = async (element: Element): Promise => { +const getFiberSource = async ( + element: Element, + sourceOptions?: SourceOptions, +): Promise => { const fiber = getFiberFromHostInstance(findNearestFiberElement(element)); if (!fiber) return null; try { const source = await getSource(fiber); - if (!source?.fileName || !isApplicationSourceFile(source.fileName)) return null; + if (!source?.fileName || !isApplicationSourceFile(source.fileName, sourceOptions)) { + return null; + } return { filePath: normalizeFilePath(source.fileName), @@ -330,8 +332,15 @@ const getFiberSource = async (element: Element): Promise } }; -export const resolveSource = async (element: Element): Promise => { - const fiberSource = await getFiberSource(element); +interface ResolveSourceOptions { + sourceOptions?: SourceOptions; +} + +export const resolveSource = async ( + element: Element, + options: ResolveSourceOptions = {}, +): Promise => { + const fiberSource = await getFiberSource(element, options.sourceOptions); if (fiberSource) return fiberSource; const stack = await getStack(element); @@ -341,7 +350,7 @@ export const resolveSource = async (element: Element): Promise { interface StackContextOptions { maxLines?: number; + sourceOptions?: SourceOptions; } const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => { @@ -471,7 +481,7 @@ const formatStackContext = ( for (const frame of stack) { if (lines.length >= maxLines) break; - const sourcePath = classifySourcePath(frame.fileName, activeSourceOptions); + const sourcePath = classifySourcePath(frame.fileName, options.sourceOptions); if (sourcePath.kind === "ignored-app-source") continue; const libraryPackage = sourcePath.packageName; @@ -539,7 +549,7 @@ export const getStackContext = async ( options: StackContextOptions = {}, ): Promise => { const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; - const leadingSource = await getFiberSource(element); + const leadingSource = await getFiberSource(element, options.sourceOptions); const stack = await getStack(element); if (stack) { diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index e63542667..311695b6d 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,10 +1,12 @@ import { getInlineHTMLPreview, getStackContext } from "./context.js"; import { copyContent } from "../utils/copy-content.js"; import { normalizeError } from "../utils/normalize-error.js"; +import type { SourceOptions } from "../types.js"; interface CopyFlowOptions { getContent?: (elements: Element[]) => Promise | string; componentName?: string; + sourceOptions?: SourceOptions; } interface CopyFlowHooks { @@ -15,14 +17,22 @@ interface CopyFlowHooks { onCopyError: (error: Error) => void; } -const formatElementReference = async (element: Element): Promise => { +const formatElementReference = async ( + element: Element, + sourceOptions: SourceOptions | undefined, +): Promise => { const inlinePreview = getInlineHTMLPreview(element); - const inlineStack = (await getStackContext(element)).replace(/\n\s+/g, " "); + const inlineStack = (await getStackContext(element, { sourceOptions })).replace(/\n\s+/g, " "); return `[${inlinePreview}${inlineStack}]`; }; -const buildClipboardPayload = async (elements: Element[]): Promise => { - const references = await Promise.all(elements.map(formatElementReference)); +const buildClipboardPayload = async ( + elements: Element[], + sourceOptions: SourceOptions | undefined, +): Promise => { + const references = await Promise.all( + elements.map((element) => formatElementReference(element, sourceOptions)), + ); const uniqueReferences = [...new Set(references)]; return uniqueReferences.length > 0 ? uniqueReferences.join("\n") : null; }; @@ -41,7 +51,7 @@ export const runCopyFlow = async ( try { const rawContent = options.getContent ? await options.getContent(elements) - : await buildClipboardPayload(elements); + : await buildClipboardPayload(elements, options.sourceOptions); if (rawContent?.trim()) { const transformedContent = await hooks.transformCopyContent(rawContent, elements); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 58261ea20..80f922e5d 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -33,7 +33,6 @@ import { getComponentDisplayName, isNextProjectRuntime, resolveSource, - setSourceOptions, } from "./context.js"; import { createNoopApi } from "./noop-api.js"; import { createEventListenerManager } from "./events.js"; @@ -215,11 +214,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const pluginRegistry = createPluginRegistry(settableOptions); - createEffect(() => { - setSourceOptions(pluginRegistry.store.options.source); - }); - onCleanup(() => setSourceOptions(undefined)); - const { store, actions, pointer, viewportVersion, current } = createGrabStore({ keyHoldDuration: pluginRegistry.store.options.keyHoldDuration ?? DEFAULT_KEY_HOLD_DURATION_MS, }); @@ -552,9 +546,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; const notifyElementsSelected = async (elements: Element[]): Promise => { + const sourceOptions = pluginRegistry.store.options.source; const elementsPayload = await Promise.all( elements.map(async (element) => { - const source = await resolveSource(element); + const source = await resolveSource(element, { sourceOptions }); let componentName = source?.componentName ?? null; const filePath = source?.filePath; const lineNumber = source?.lineNumber ?? undefined; @@ -650,6 +645,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { { getContent: pluginRegistry.store.options.getContent, componentName: elementName, + sourceOptions: pluginRegistry.store.options.source, }, { onBeforeCopy: pluginRegistry.hooks.onBeforeCopy, @@ -1151,7 +1147,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return; } - resolveSource(element) + resolveSource(element, { sourceOptions: pluginRegistry.store.options.source }) .then((source) => { if (selectionSourceRequestVersion !== currentVersion) return; if (!source) { @@ -3137,7 +3133,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { () => store.contextMenuElement, async (element) => { if (!element) return null; - return resolveSource(element); + return resolveSource(element, { sourceOptions: pluginRegistry.store.options.source }); }, ); @@ -3681,7 +3677,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }, copyElement: copyElementAPI, getSource: async (element: Element): Promise => { - const source = await resolveSource(element); + const source = await resolveSource(element, { + sourceOptions: pluginRegistry.store.options.source, + }); if (!source) return null; return { filePath: source.filePath, diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts index 3513203f3..ebe4f969a 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -20,6 +20,7 @@ import { import { Fiber, getFiberFromHostInstance } from "bippy"; import type { StackFrame } from "bippy/source"; export type { StackFrame }; +import type { SourceOptions } from "./types.js"; import { createElementSelector } from "./utils/create-element-selector.js"; import { extractElementCss, disposeBaselineStyles } from "./utils/extract-element-css.js"; import { openFile as openFileAsync } from "./utils/open-file.js"; @@ -39,6 +40,10 @@ export interface ReactGrabElementContext { styles: string; } +export interface ReactGrabElementContextOptions { + sourceOptions?: SourceOptions; +} + /** * Gathers comprehensive context for a DOM element — the same context * React Grab copies to the clipboard, plus structured source location @@ -51,13 +56,16 @@ export interface ReactGrabElementContext { * ctx.filePath; // "/src/components/Button.tsx" * ctx.lineNumber; // 12 */ -export const getElementContext = async (element: Element): Promise => { +export const getElementContext = async ( + element: Element, + options: ReactGrabElementContextOptions = {}, +): Promise => { const [snippet, source, stack] = await Promise.all([ - formatElementSnippet(element), - resolveSource(element), + formatElementSnippet(element, { sourceOptions: options.sourceOptions }), + resolveSource(element, { sourceOptions: options.sourceOptions }), getStack(element).then((result) => result ?? []), ]); - const stackString = await getStackContext(element); + const stackString = await getStackContext(element, { sourceOptions: options.sourceOptions }); const htmlPreview = getHTMLPreview(element); const componentName = getComponentDisplayName(element); const fiber = getFiberFromHostInstance(element); diff --git a/packages/react-grab/src/utils/generate-snippet.ts b/packages/react-grab/src/utils/generate-snippet.ts index a3f2e9555..dce9fa734 100644 --- a/packages/react-grab/src/utils/generate-snippet.ts +++ b/packages/react-grab/src/utils/generate-snippet.ts @@ -1,7 +1,9 @@ import { getElementContext } from "../core/context.js"; +import type { SourceOptions } from "../types.js"; interface GenerateSnippetOptions { maxLines?: number; + sourceOptions?: SourceOptions; } export const generateSnippet = async ( From 8f072960b4b4405687a8621acba9f594c59bae0a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 23:27:43 +0000 Subject: [PATCH 14/65] Apply source policy to stack context API Co-authored-by: Aiden Bai --- packages/react-grab/src/core/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 80f922e5d..65176942c 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -3687,7 +3687,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { componentName: source.componentName, }; }, - getStackContext, + getStackContext: (element: Element) => + getStackContext(element, { sourceOptions: pluginRegistry.store.options.source }), getState: (): ReactGrabState => ({ isActive: isActivated(), isDragging: isDragging(), From 0a0836bd128c17e5891cad23e467d190b205f3e4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 3 Jun 2026 23:38:57 +0000 Subject: [PATCH 15/65] Cache default fiber source resolution Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 523932991..4e3cfe163 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -229,6 +229,7 @@ const findNearestFiberElement = (element: Element): Element => { }; const stackCache = new WeakMap>(); +const fiberSourceCache = new WeakMap>(); const fetchStackForElement = async (element: Element): Promise => { try { @@ -332,6 +333,23 @@ const getFiberSource = async ( } }; +const getCachedFiberSource = ( + element: Element, + sourceOptions?: SourceOptions, +): Promise => { + const resolvedElement = findNearestFiberElement(element); + if (sourceOptions?.ignorePaths?.length) { + return getFiberSource(resolvedElement, sourceOptions); + } + + const cached = fiberSourceCache.get(resolvedElement); + if (cached) return cached; + + const promise = getFiberSource(resolvedElement, sourceOptions); + fiberSourceCache.set(resolvedElement, promise); + return promise; +}; + interface ResolveSourceOptions { sourceOptions?: SourceOptions; } @@ -340,7 +358,7 @@ export const resolveSource = async ( element: Element, options: ResolveSourceOptions = {}, ): Promise => { - const fiberSource = await getFiberSource(element, options.sourceOptions); + const fiberSource = await getCachedFiberSource(element, options.sourceOptions); if (fiberSource) return fiberSource; const stack = await getStack(element); @@ -549,7 +567,7 @@ export const getStackContext = async ( options: StackContextOptions = {}, ): Promise => { const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; - const leadingSource = await getFiberSource(element, options.sourceOptions); + const leadingSource = await getCachedFiberSource(element, options.sourceOptions); const stack = await getStack(element); if (stack) { From 7e90512c635fe095a722915ec6645a75d607231a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 11:25:15 +0000 Subject: [PATCH 16/65] Simplify source policy boundaries Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 39 +++++++------------ .../src/utils/parse-package-name.test.ts | 6 +-- .../src/utils/parse-package-name.ts | 33 ---------------- .../src/utils/source-frame-policy.test.ts | 8 ++++ .../src/utils/source-frame-policy.ts | 38 +++++++++++++++++- 5 files changed, 60 insertions(+), 64 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 4e3cfe163..234dc5ff1 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -300,24 +300,13 @@ const getSourceComponentName = (fiber: Fiber | undefined): string | null => { return name && isSourceComponentName(name) ? name : null; }; -// bippy's getSource prefers React's dev-only _debugSource (the real JSX location -// that bundlers like Webpack/Rspack drop from the owner stack) and otherwise -// falls back to the owner stack. We only trust app-owned source locations here; -// library sourcemap paths are left to the owner-stack scan. -// This reads React's own dev data, so it works without bippy instrumentation; -// getSource can still throw while parsing owner stacks, so it is guarded. -const getFiberSource = async ( - element: Element, - sourceOptions?: SourceOptions, -): Promise => { +const getFiberSource = async (element: Element): Promise => { const fiber = getFiberFromHostInstance(findNearestFiberElement(element)); if (!fiber) return null; try { const source = await getSource(fiber); - if (!source?.fileName || !isApplicationSourceFile(source.fileName, sourceOptions)) { - return null; - } + if (!source?.fileName) return null; return { filePath: normalizeFilePath(source.fileName), @@ -333,23 +322,25 @@ const getFiberSource = async ( } }; -const getCachedFiberSource = ( - element: Element, - sourceOptions?: SourceOptions, -): Promise => { +const getCachedFiberSource = (element: Element): Promise => { const resolvedElement = findNearestFiberElement(element); - if (sourceOptions?.ignorePaths?.length) { - return getFiberSource(resolvedElement, sourceOptions); - } - const cached = fiberSourceCache.get(resolvedElement); if (cached) return cached; - const promise = getFiberSource(resolvedElement, sourceOptions); + const promise = getFiberSource(resolvedElement); fiberSourceCache.set(resolvedElement, promise); return promise; }; +const getApplicationFiberSource = async ( + element: Element, + sourceOptions?: SourceOptions, +): Promise => { + const source = await getCachedFiberSource(element); + if (!source || !isApplicationSourceFile(source.filePath, sourceOptions)) return null; + return source; +}; + interface ResolveSourceOptions { sourceOptions?: SourceOptions; } @@ -358,7 +349,7 @@ export const resolveSource = async ( element: Element, options: ResolveSourceOptions = {}, ): Promise => { - const fiberSource = await getCachedFiberSource(element, options.sourceOptions); + const fiberSource = await getApplicationFiberSource(element, options.sourceOptions); if (fiberSource) return fiberSource; const stack = await getStack(element); @@ -567,7 +558,7 @@ export const getStackContext = async ( options: StackContextOptions = {}, ): Promise => { const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; - const leadingSource = await getCachedFiberSource(element, options.sourceOptions); + const leadingSource = await getApplicationFiberSource(element, options.sourceOptions); const stack = await getStack(element); if (stack) { diff --git a/packages/react-grab/src/utils/parse-package-name.test.ts b/packages/react-grab/src/utils/parse-package-name.test.ts index 3e3cf3b81..ea4be0c2e 100644 --- a/packages/react-grab/src/utils/parse-package-name.test.ts +++ b/packages/react-grab/src/utils/parse-package-name.test.ts @@ -15,11 +15,6 @@ describe("parsePackageName", () => { ); }); - it("reads scoped packages from dependency sourcemap source paths", () => { - expect(parsePackageName("../@rippling/pebble/Tabs/Renderers.js")).toBe("@rippling/pebble"); - expect(parsePackageName("./@radix-ui/react-tabs/src/tabs.tsx")).toBe("@radix-ui/react-tabs"); - }); - it("does not treat app paths or aliases as packages", () => { expect(parsePackageName("../components/tabs.tsx")).toBe(null); expect(parsePackageName("/workspace/app/src/components/tabs.tsx")).toBe(null); @@ -27,6 +22,7 @@ describe("parsePackageName", () => { expect(parsePackageName("@app/components/tabs.tsx")).toBe(null); expect(parsePackageName("@company/app/src/tabs.tsx")).toBe(null); expect(parsePackageName("../@company/app/src/tabs.tsx")).toBe(null); + expect(parsePackageName("../@rippling/pebble/Tabs/Renderers.js")).toBe(null); expect(parsePackageName("./@company/web/src/tabs.tsx")).toBe(null); expect(parsePackageName("/@company/app/src/tabs.tsx")).toBe(null); }); diff --git a/packages/react-grab/src/utils/parse-package-name.ts b/packages/react-grab/src/utils/parse-package-name.ts index a7c3737ba..82dd675fe 100644 --- a/packages/react-grab/src/utils/parse-package-name.ts +++ b/packages/react-grab/src/utils/parse-package-name.ts @@ -7,9 +7,6 @@ const FILE_EXTENSION_PATTERN = /\.[mc]?[jt]sx?$/i; const VITE_INTERNAL_CHUNK_PATTERN = /^chunk-[A-Za-z0-9_-]+$/; const PATH_SEPARATOR_PATTERN = /[/\\]/; const NAME_AT_VERSION_PATTERN = /^(.+?)@v?\d/; -const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; -const PACKAGE_NAME_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; -const APPLICATION_PACKAGE_NAME_SEGMENTS = new Set(["app", "web", "website", "frontend", "client"]); const splitPathSegments = (path: string): string[] => path.split(PATH_SEPARATOR_PATTERN).filter(Boolean); @@ -76,33 +73,6 @@ const extractVersionedPackageFromUrl = (rawFileName: string): string | null => { return null; }; -const stripRelativeSourcePathPrefix = (path: string): string | null => { - let remainingPath = path; - let didStripPrefix = false; - while (remainingPath.startsWith("../") || remainingPath.startsWith("./")) { - didStripPrefix = true; - remainingPath = remainingPath.slice(remainingPath.startsWith("../") ? 3 : 2); - } - return didStripPrefix ? remainingPath : null; -}; - -const extractFromScopedPackageSourcePath = (decodedPath: string): string | null => { - const sourcePath = stripRelativeSourcePathPrefix(decodedPath); - if (!sourcePath) return null; - - const [scope, packageName] = splitPathSegments(sourcePath); - if ( - !scope || - !packageName || - !SCOPED_PACKAGE_PATTERN.test(scope) || - !PACKAGE_NAME_SEGMENT_PATTERN.test(packageName) || - APPLICATION_PACKAGE_NAME_SEGMENTS.has(packageName) - ) { - return null; - } - return `${scope}/${packageName}`; -}; - const extractFromLocalPath = (normalizedPath: string): string | null => extractAfterLastMarker( normalizedPath, @@ -121,9 +91,6 @@ export const parsePackageName = (fileName: string | null | undefined): string | const localResult = extractFromLocalPath(decoded); if (localResult) return localResult; - const packageSourceResult = extractFromScopedPackageSourcePath(decoded); - if (packageSourceResult) return packageSourceResult; - const cdnResult = extractVersionedPackageFromUrl(fileName); if (cdnResult) return cdnResult; diff --git a/packages/react-grab/src/utils/source-frame-policy.test.ts b/packages/react-grab/src/utils/source-frame-policy.test.ts index fd310f5d1..ada0857cf 100644 --- a/packages/react-grab/src/utils/source-frame-policy.test.ts +++ b/packages/react-grab/src/utils/source-frame-policy.test.ts @@ -14,6 +14,10 @@ describe("classifySourcePath", () => { kind: "package-source", packageName: "@rippling/pebble", }); + expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx")).toEqual({ + kind: "package-source", + packageName: "@radix-ui/react-tabs", + }); expect(classifySourcePath("/app/node_modules/@radix-ui/react-tabs/dist/index.min.js")).toEqual({ kind: "package-source", packageName: "@radix-ui/react-tabs", @@ -65,6 +69,10 @@ describe("classifySourcePath", () => { }); it("does not ignore nearby app source paths", () => { + expect(classifySourcePath("../@company/app/src/tabs.tsx")).toEqual({ + kind: "app-source", + packageName: null, + }); expect(classifySourcePath("src/components/ui-button.tsx")).toEqual({ kind: "app-source", packageName: null, diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index a09f8b253..0b0f2d14c 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -1,10 +1,14 @@ -import { isSourceFile } from "bippy/source"; +import { isSourceFile, normalizeFileName } from "bippy/source"; import type { SourceOptions } from "../types.js"; import { normalizeFilePath } from "./normalize-file-path.js"; import { parsePackageName } from "./parse-package-name.js"; +import { safeDecodeURIComponent } from "./safe-decode-uri-component.js"; const DEFAULT_IGNORED_SOURCE_PATHS: readonly string[] = ["components/ui"]; const PATH_SEPARATOR_PATTERN = /[/\\]/; +const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; +const PACKAGE_NAME_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const APPLICATION_PACKAGE_NAME_SEGMENTS = new Set(["app", "web", "website", "frontend", "client"]); const defaultClassificationCache = new Map(); export interface SourcePathClassification { @@ -60,6 +64,36 @@ const matchesIgnoredSourcePath = (fileName: string, sourceOptions?: SourceOption return false; }; +const stripRelativeSourcePathPrefix = (path: string): string | null => { + let remainingPath = path; + let didStripPrefix = false; + while (remainingPath.startsWith("../") || remainingPath.startsWith("./")) { + didStripPrefix = true; + remainingPath = remainingPath.slice(remainingPath.startsWith("../") ? 3 : 2); + } + return didStripPrefix ? remainingPath : null; +}; + +const parseScopedPackageSourceName = (fileName: string): string | null => { + const sourcePath = stripRelativeSourcePathPrefix( + safeDecodeURIComponent(normalizeFileName(fileName)), + ); + if (!sourcePath) return null; + + const [scope, packageName] = sourcePath.split(PATH_SEPARATOR_PATTERN).filter(Boolean); + if ( + !scope || + !packageName || + !SCOPED_PACKAGE_PATTERN.test(scope) || + !PACKAGE_NAME_SEGMENT_PATTERN.test(packageName) || + APPLICATION_PACKAGE_NAME_SEGMENTS.has(packageName) + ) { + return null; + } + + return `${scope}/${packageName}`; +}; + export const classifySourcePath = ( fileName: string | null | undefined, sourceOptions?: SourceOptions, @@ -84,7 +118,7 @@ const classifySourcePathUncached = ( return { kind: "unknown", packageName: null }; } - const packageName = parsePackageName(fileName); + const packageName = parsePackageName(fileName) ?? parseScopedPackageSourceName(fileName); if (packageName) { return { kind: "package-source", packageName }; } From d3f9f9c5ac1c3daaeae609592eba977e38c32ae1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Jun 2026 11:35:12 +0000 Subject: [PATCH 17/65] Rank cached fiber source as fallback Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 49 +++++++++++++++---------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 234dc5ff1..bfc08eed7 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -341,6 +341,17 @@ const getApplicationFiberSource = async ( return source; }; +const resolveStackFrameSource = (frame: StackFrame | null | undefined): ResolvedSource | null => { + if (!frame?.fileName) return null; + return { + filePath: normalizeFilePath(frame.fileName), + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + componentName: + frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null, + }; +}; + interface ResolveSourceOptions { sourceOptions?: SourceOptions; } @@ -349,11 +360,16 @@ export const resolveSource = async ( element: Element, options: ResolveSourceOptions = {}, ): Promise => { - const fiberSource = await getApplicationFiberSource(element, options.sourceOptions); - if (fiberSource) return fiberSource; + const fiberSource = await getCachedFiberSource(element); + const fiberSourceKind = classifySourcePath(fiberSource?.filePath, options.sourceOptions).kind; + if (fiberSourceKind === "app-source") return fiberSource; const stack = await getStack(element); - if (!stack || stack.length === 0) return null; + if (!stack || stack.length === 0) { + return fiberSourceKind === "ignored-app-source" || fiberSourceKind === "package-source" + ? fiberSource + : null; + } const appSourceFrames: StackFrame[] = []; const ignoredAppSourceFrames: StackFrame[] = []; @@ -369,24 +385,17 @@ export const resolveSource = async ( } } - const sourceFrames = - appSourceFrames.length > 0 - ? appSourceFrames - : ignoredAppSourceFrames.length > 0 - ? ignoredAppSourceFrames - : packageSourceFrames; - const resolvedFrame = pickSourceFrame(sourceFrames); - if (!resolvedFrame?.fileName) return null; + const appFrameSource = resolveStackFrameSource(pickSourceFrame(appSourceFrames)); + if (appFrameSource) return appFrameSource; - return { - filePath: normalizeFilePath(resolvedFrame.fileName), - lineNumber: resolvedFrame.lineNumber ?? null, - columnNumber: resolvedFrame.columnNumber ?? null, - componentName: - resolvedFrame.functionName && isSourceComponentName(resolvedFrame.functionName) - ? resolvedFrame.functionName - : null, - }; + if (fiberSourceKind === "ignored-app-source") return fiberSource; + + const ignoredFrameSource = resolveStackFrameSource(pickSourceFrame(ignoredAppSourceFrames)); + if (ignoredFrameSource) return ignoredFrameSource; + + if (fiberSourceKind === "package-source") return fiberSource; + + return resolveStackFrameSource(pickSourceFrame(packageSourceFrames)); }; export const getComponentDisplayName = (element: Element): string | null => { From 12f0d891eb12aaefd8ea781e7cf3c2dd11e4ec1b Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 4 Jun 2026 17:09:27 -0700 Subject: [PATCH 18/65] Extract pure source selection and add unit tests Split the source-resolution precedence into a pure selectResolvedSource helper, dedupe component-name extraction, and simplify the classification cache wrapper. Add unit coverage for the precedence ladder. --- packages/react-grab/src/core/context.test.ts | 111 ++++++++++++++++++ packages/react-grab/src/core/context.ts | 88 +++++++------- .../src/utils/source-frame-policy.ts | 12 +- 3 files changed, 159 insertions(+), 52 deletions(-) create mode 100644 packages/react-grab/src/core/context.test.ts diff --git a/packages/react-grab/src/core/context.test.ts b/packages/react-grab/src/core/context.test.ts new file mode 100644 index 000000000..efd6d5341 --- /dev/null +++ b/packages/react-grab/src/core/context.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { StackFrame } from "bippy/source"; +import { selectResolvedSource, type FramesBySourceKind } from "./context.js"; + +const emptyFramesByKind = (): FramesBySourceKind => ({ + "app-source": [], + "ignored-app-source": [], + "package-source": [], +}); + +const fiberSource = { + filePath: "/src/app/page.tsx", + lineNumber: 1, + columnNumber: 1, + componentName: "Page", +}; + +const appFrame: StackFrame = { + fileName: "/src/app/widget.tsx", + functionName: "Widget", + lineNumber: 5, + columnNumber: 2, +}; + +const ignoredFrame: StackFrame = { + fileName: "/src/components/ui/button.tsx", + functionName: "Button", + lineNumber: 9, + columnNumber: 4, +}; + +const packageFrame: StackFrame = { + fileName: "/app/node_modules/react-tabs/dist/index.js", + functionName: "Tabs", + lineNumber: 1, + columnNumber: 1, +}; + +describe("selectResolvedSource", () => { + it("prefers the fiber source when it is app-source", () => { + const framesByKind = emptyFramesByKind(); + framesByKind["app-source"].push(appFrame); + + expect(selectResolvedSource(fiberSource, "app-source", framesByKind)).toBe(fiberSource); + }); + + it("prefers an app-source frame over a non-app fiber source", () => { + const framesByKind = emptyFramesByKind(); + framesByKind["app-source"].push(appFrame); + + expect(selectResolvedSource(fiberSource, "ignored-app-source", framesByKind)).toMatchObject({ + filePath: "/src/app/widget.tsx", + componentName: "Widget", + }); + }); + + it("prefers an ignored-app-source fiber over ignored or package frames", () => { + const framesByKind = emptyFramesByKind(); + framesByKind["ignored-app-source"].push(ignoredFrame); + framesByKind["package-source"].push(packageFrame); + + expect(selectResolvedSource(fiberSource, "ignored-app-source", framesByKind)).toBe(fiberSource); + }); + + it("falls back to an ignored-app-source frame over a package frame", () => { + const framesByKind = emptyFramesByKind(); + framesByKind["ignored-app-source"].push(ignoredFrame); + framesByKind["package-source"].push(packageFrame); + + expect(selectResolvedSource(null, "unknown", framesByKind)).toMatchObject({ + filePath: "/src/components/ui/button.tsx", + componentName: "Button", + }); + }); + + it("prefers a package-source fiber over package frames", () => { + const framesByKind = emptyFramesByKind(); + framesByKind["package-source"].push(packageFrame); + + expect(selectResolvedSource(fiberSource, "package-source", framesByKind)).toBe(fiberSource); + }); + + it("falls back to a package frame as the last resort", () => { + const framesByKind = emptyFramesByKind(); + framesByKind["package-source"].push(packageFrame); + + expect(selectResolvedSource(null, "unknown", framesByKind)).toMatchObject({ + filePath: "/app/node_modules/react-tabs/dist/index.js", + componentName: "Tabs", + }); + }); + + it("returns null when no fiber source or frames resolve", () => { + expect(selectResolvedSource(null, "unknown", emptyFramesByKind())).toBe(null); + }); + + it("picks the first named frame within a kind over an earlier anonymous frame", () => { + const anonymousFrame: StackFrame = { + fileName: "/src/app/anonymous.tsx", + lineNumber: 2, + columnNumber: 1, + }; + const framesByKind = emptyFramesByKind(); + framesByKind["app-source"].push(anonymousFrame, appFrame); + + expect(selectResolvedSource(null, "unknown", framesByKind)).toMatchObject({ + filePath: "/src/app/widget.tsx", + componentName: "Widget", + }); + }); +}); diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index bfc08eed7..73956d26a 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -28,7 +28,7 @@ import { truncateString } from "../utils/truncate-string.js"; import { getNextBasePath } from "../utils/get-next-base-path.js"; import { normalizeFilePath } from "../utils/normalize-file-path.js"; import { safeDecodeURIComponent } from "../utils/safe-decode-uri-component.js"; -import { classifySourcePath } from "../utils/source-frame-policy.js"; +import { classifySourcePath, type SourcePathClassification } from "../utils/source-frame-policy.js"; import { isInternalAttribute } from "../utils/strip-internal-attributes.js"; import { isInternalComponentName, @@ -56,6 +56,9 @@ const isSourceComponentName = (name: string): boolean => { return true; }; +const toSourceComponentName = (name: string | null | undefined): string | null => + name && isSourceComponentName(name) ? name : null; + const SERVER_COMPONENT_URL_PREFIXES = ["about://React/", "rsc://React/"]; const isServerComponentUrl = (url: string): boolean => @@ -282,11 +285,6 @@ interface ResolvedSource { componentName: string | null; } -const isApplicationSourceFile = ( - fileName: string | null | undefined, - sourceOptions: SourceOptions | undefined, -): boolean => classifySourcePath(fileName, sourceOptions).kind === "app-source"; - const pickSourceFrame = (frames: StackFrame[]): StackFrame | null => { const namedFrame = frames.find( (frame) => frame.functionName && isSourceComponentName(frame.functionName), @@ -296,8 +294,7 @@ const pickSourceFrame = (frames: StackFrame[]): StackFrame | null => { const getSourceComponentName = (fiber: Fiber | undefined): string | null => { if (!fiber || !isCompositeFiber(fiber)) return null; - const name = getDisplayName(fiber.type); - return name && isSourceComponentName(name) ? name : null; + return toSourceComponentName(getDisplayName(fiber.type)); }; const getFiberSource = async (element: Element): Promise => { @@ -313,9 +310,7 @@ const getFiberSource = async (element: Element): Promise lineNumber: source.lineNumber ?? null, columnNumber: source.columnNumber ?? null, componentName: - (source.functionName && isSourceComponentName(source.functionName) - ? source.functionName - : null) ?? getSourceComponentName(fiber._debugOwner), + toSourceComponentName(source.functionName) ?? getSourceComponentName(fiber._debugOwner), }; } catch { return null; @@ -337,7 +332,9 @@ const getApplicationFiberSource = async ( sourceOptions?: SourceOptions, ): Promise => { const source = await getCachedFiberSource(element); - if (!source || !isApplicationSourceFile(source.filePath, sourceOptions)) return null; + if (!source || classifySourcePath(source.filePath, sourceOptions).kind !== "app-source") { + return null; + } return source; }; @@ -347,8 +344,7 @@ const resolveStackFrameSource = (frame: StackFrame | null | undefined): Resolved filePath: normalizeFilePath(frame.fileName), lineNumber: frame.lineNumber ?? null, columnNumber: frame.columnNumber ?? null, - componentName: - frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null, + componentName: toSourceComponentName(frame.functionName), }; }; @@ -356,6 +352,28 @@ interface ResolveSourceOptions { sourceOptions?: SourceOptions; } +// Source candidates are resolved in descending preference: a frame the user +// actually owns beats one they configured to ignore, which beats third-party +// package code. Within each kind the fiber's own source wins over stack frames. +const RESOLVABLE_SOURCE_KINDS = ["app-source", "ignored-app-source", "package-source"] as const; + +type ResolvableSourceKind = (typeof RESOLVABLE_SOURCE_KINDS)[number]; + +export type FramesBySourceKind = Record; + +export const selectResolvedSource = ( + fiberSource: ResolvedSource | null, + fiberSourceKind: SourcePathClassification["kind"], + framesByKind: FramesBySourceKind, +): ResolvedSource | null => { + for (const kind of RESOLVABLE_SOURCE_KINDS) { + if (fiberSourceKind === kind) return fiberSource; + const frameSource = resolveStackFrameSource(pickSourceFrame(framesByKind[kind])); + if (frameSource) return frameSource; + } + return null; +}; + export const resolveSource = async ( element: Element, options: ResolveSourceOptions = {}, @@ -364,38 +382,17 @@ export const resolveSource = async ( const fiberSourceKind = classifySourcePath(fiberSource?.filePath, options.sourceOptions).kind; if (fiberSourceKind === "app-source") return fiberSource; - const stack = await getStack(element); - if (!stack || stack.length === 0) { - return fiberSourceKind === "ignored-app-source" || fiberSourceKind === "package-source" - ? fiberSource - : null; - } - - const appSourceFrames: StackFrame[] = []; - const ignoredAppSourceFrames: StackFrame[] = []; - const packageSourceFrames: StackFrame[] = []; - for (const frame of stack) { - const sourcePath = classifySourcePath(frame.fileName, options.sourceOptions); - if (sourcePath.kind === "app-source") { - appSourceFrames.push(frame); - } else if (sourcePath.kind === "ignored-app-source") { - ignoredAppSourceFrames.push(frame); - } else if (sourcePath.kind === "package-source") { - packageSourceFrames.push(frame); - } + const framesByKind: FramesBySourceKind = { + "app-source": [], + "ignored-app-source": [], + "package-source": [], + }; + for (const frame of (await getStack(element)) ?? []) { + const { kind } = classifySourcePath(frame.fileName, options.sourceOptions); + if (kind !== "unknown") framesByKind[kind].push(frame); } - const appFrameSource = resolveStackFrameSource(pickSourceFrame(appSourceFrames)); - if (appFrameSource) return appFrameSource; - - if (fiberSourceKind === "ignored-app-source") return fiberSource; - - const ignoredFrameSource = resolveStackFrameSource(pickSourceFrame(ignoredAppSourceFrames)); - if (ignoredFrameSource) return ignoredFrameSource; - - if (fiberSourceKind === "package-source") return fiberSource; - - return resolveStackFrameSource(pickSourceFrame(packageSourceFrames)); + return selectResolvedSource(fiberSource, fiberSourceKind, framesByKind); }; export const getComponentDisplayName = (element: Element): string | null => { @@ -505,8 +502,7 @@ const formatStackContext = ( const libraryPackage = sourcePath.packageName; const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; - const componentName = - frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null; + const componentName = toSourceComponentName(frame.functionName); const libraryFrameKey = libraryPackage ? `${libraryPackage}:${componentName ?? ""}:${frame.isServer ? "server" : "client"}` : null; diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index 0b0f2d14c..63cc7963d 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -98,15 +98,15 @@ export const classifySourcePath = ( fileName: string | null | undefined, sourceOptions?: SourceOptions, ): SourcePathClassification => { - if (!sourceOptions?.ignorePaths?.length && fileName) { - const cachedClassification = defaultClassificationCache.get(fileName); - if (cachedClassification) return cachedClassification; + if (!fileName || sourceOptions?.ignorePaths?.length) { + return classifySourcePathUncached(fileName, sourceOptions); } + const cachedClassification = defaultClassificationCache.get(fileName); + if (cachedClassification) return cachedClassification; + const classification = classifySourcePathUncached(fileName, sourceOptions); - if (!sourceOptions?.ignorePaths?.length && fileName) { - defaultClassificationCache.set(fileName, classification); - } + defaultClassificationCache.set(fileName, classification); return classification; }; From 281a4c7b9d035625d80a4055b1c69cce70d1e756 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 4 Jun 2026 17:21:11 -0700 Subject: [PATCH 19/65] Document source-frame classification heuristics Add rationale comments for the default components/ui ignore, the first-party scoped-app allowlist, and the classification cache key. --- packages/react-grab/src/utils/source-frame-policy.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index 63cc7963d..574dd96af 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -4,11 +4,19 @@ import { normalizeFilePath } from "./normalize-file-path.js"; import { parsePackageName } from "./parse-package-name.js"; import { safeDecodeURIComponent } from "./safe-decode-uri-component.js"; +// Always ignored, in addition to any user-supplied ignorePaths: design-system +// wrappers (e.g. shadcn's components/ui) are rarely the file a user wants to +// edit, so grabs resolve to the consuming app source instead. const DEFAULT_IGNORED_SOURCE_PATHS: readonly string[] = ["components/ui"]; const PATH_SEPARATOR_PATTERN = /[/\\]/; const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; const PACKAGE_NAME_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +// A relative scoped path like `../@acme/app/...` has no node_modules marker to +// prove it is third-party, so a monorepo's own app workspace would otherwise be +// misread as a package. Best-effort allowlist of common first-party app dirs. const APPLICATION_PACKAGE_NAME_SEGMENTS = new Set(["app", "web", "website", "frontend", "client"]); +// Keyed by fileName and only used when no custom ignorePaths are set; valid only +// while default classification depends solely on fileName. const defaultClassificationCache = new Map(); export interface SourcePathClassification { From 926fbcd18e8a7608e40ce5bd43d8c70a08a13bd0 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 4 Jun 2026 17:31:55 -0700 Subject: [PATCH 20/65] Consolidate package-name parsing and extract stack frame formatter Move the scoped-package fallback parser into parse-package-name as a single resolvePackageName, so source-frame-policy no longer owns parsing. Extract formatStackFrameLine from formatStackContext so the loop is compute/dedup/push. --- packages/react-grab/src/core/context.ts | 89 ++++++++++--------- .../src/utils/parse-package-name.ts | 44 +++++++++ .../src/utils/source-frame-policy.ts | 43 +-------- 3 files changed, 94 insertions(+), 82 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 73956d26a..83209d175 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -473,6 +473,47 @@ const formatSourceContextLine = (source: ResolvedSource, isNextProject: boolean) : `\n in ${location}`; }; +// Branches are ordered by specificity: a server component (no on-disk source) +// first, then any frame named by component but lacking a source file, then a +// bare third-party package, and finally a real app source location. Returns +// null when the frame carries nothing worth rendering. +const formatStackFrameLine = ( + frame: StackFrame, + sourcePath: SourcePathClassification, + componentName: string | null, + isNextProject: boolean, +): string | null => { + const libraryPackage = sourcePath.packageName; + const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; + + if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { + const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; + return `\n in ${componentName ?? ""} (${tag})`; + } + + if (!resolvedSource && componentName) { + return libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`; + } + + if (libraryPackage) { + return `\n in ${libraryPackage}`; + } + + if (resolvedSource) { + return formatSourceContextLine( + { + componentName, + filePath: resolvedSource, + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + }, + isNextProject, + ); + } + + return null; +}; + const formatStackContext = ( stack: StackFrame[], options: StackContextOptions = {}, @@ -488,23 +529,15 @@ const formatStackContext = ( lines.push(formatSourceContextLine(leadingSource, isNextProject)); } - const emit = (line: string, libraryFrameKey: string | null) => { - lines.push(line); - previousLibraryFrameKey = libraryFrameKey; - }; - for (const frame of stack) { if (lines.length >= maxLines) break; const sourcePath = classifySourcePath(frame.fileName, options.sourceOptions); if (sourcePath.kind === "ignored-app-source") continue; - const libraryPackage = sourcePath.packageName; - const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; - const componentName = toSourceComponentName(frame.functionName); - const libraryFrameKey = libraryPackage - ? `${libraryPackage}:${componentName ?? ""}:${frame.isServer ? "server" : "client"}` + const libraryFrameKey = sourcePath.packageName + ? `${sourcePath.packageName}:${componentName ?? ""}:${frame.isServer ? "server" : "client"}` : null; if (libraryFrameKey && libraryFrameKey === previousLibraryFrameKey) continue; @@ -520,39 +553,11 @@ const formatStackContext = ( continue; } - if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { - const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; - emit(`\n in ${componentName ?? ""} (${tag})`, libraryFrameKey); - continue; - } - - if (!resolvedSource && componentName) { - emit( - libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`, - libraryFrameKey, - ); - continue; - } + const line = formatStackFrameLine(frame, sourcePath, componentName, isNextProject); + if (line === null) continue; - if (libraryPackage) { - emit(`\n in ${libraryPackage}`, libraryFrameKey); - continue; - } - - if (resolvedSource) { - emit( - formatSourceContextLine( - { - componentName, - filePath: resolvedSource, - lineNumber: frame.lineNumber ?? null, - columnNumber: frame.columnNumber ?? null, - }, - isNextProject, - ), - null, - ); - } + lines.push(line); + previousLibraryFrameKey = libraryFrameKey; } return lines.join(""); diff --git a/packages/react-grab/src/utils/parse-package-name.ts b/packages/react-grab/src/utils/parse-package-name.ts index 82dd675fe..2c0dab211 100644 --- a/packages/react-grab/src/utils/parse-package-name.ts +++ b/packages/react-grab/src/utils/parse-package-name.ts @@ -96,3 +96,47 @@ export const parsePackageName = (fileName: string | null | undefined): string | return null; }; + +const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; +const PACKAGE_NAME_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +// A relative scoped path like `../@acme/app/...` has no node_modules marker to +// prove it is third-party, so a monorepo's own app workspace would otherwise be +// misread as a package. Best-effort allowlist of common first-party app dirs. +const APPLICATION_PACKAGE_NAME_SEGMENTS = new Set(["app", "web", "website", "frontend", "client"]); + +const stripRelativeSourcePathPrefix = (path: string): string | null => { + let remainingPath = path; + let didStripPrefix = false; + while (remainingPath.startsWith("../") || remainingPath.startsWith("./")) { + didStripPrefix = true; + remainingPath = remainingPath.slice(remainingPath.startsWith("../") ? 3 : 2); + } + return didStripPrefix ? remainingPath : null; +}; + +const parseScopedPackageSourceName = (fileName: string): string | null => { + const sourcePath = stripRelativeSourcePathPrefix( + safeDecodeURIComponent(normalizeFileName(fileName)), + ); + if (!sourcePath) return null; + + const [scope, packageName] = splitPathSegments(sourcePath); + if ( + !scope || + !packageName || + !SCOPED_PACKAGE_PATTERN.test(scope) || + !PACKAGE_NAME_SEGMENT_PATTERN.test(packageName) || + APPLICATION_PACKAGE_NAME_SEGMENTS.has(packageName) + ) { + return null; + } + + return `${scope}/${packageName}`; +}; + +// parsePackageName keys off node_modules/.vite/CDN markers; the scoped fallback +// handles bare relative imports like `../@acme/ui/...` that carry no marker. +export const resolvePackageName = (fileName: string | null | undefined): string | null => { + if (!fileName) return null; + return parsePackageName(fileName) ?? parseScopedPackageSourceName(fileName); +}; diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index 574dd96af..ab8db37ab 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -1,20 +1,13 @@ -import { isSourceFile, normalizeFileName } from "bippy/source"; +import { isSourceFile } from "bippy/source"; import type { SourceOptions } from "../types.js"; import { normalizeFilePath } from "./normalize-file-path.js"; -import { parsePackageName } from "./parse-package-name.js"; -import { safeDecodeURIComponent } from "./safe-decode-uri-component.js"; +import { resolvePackageName } from "./parse-package-name.js"; // Always ignored, in addition to any user-supplied ignorePaths: design-system // wrappers (e.g. shadcn's components/ui) are rarely the file a user wants to // edit, so grabs resolve to the consuming app source instead. const DEFAULT_IGNORED_SOURCE_PATHS: readonly string[] = ["components/ui"]; const PATH_SEPARATOR_PATTERN = /[/\\]/; -const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; -const PACKAGE_NAME_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; -// A relative scoped path like `../@acme/app/...` has no node_modules marker to -// prove it is third-party, so a monorepo's own app workspace would otherwise be -// misread as a package. Best-effort allowlist of common first-party app dirs. -const APPLICATION_PACKAGE_NAME_SEGMENTS = new Set(["app", "web", "website", "frontend", "client"]); // Keyed by fileName and only used when no custom ignorePaths are set; valid only // while default classification depends solely on fileName. const defaultClassificationCache = new Map(); @@ -72,36 +65,6 @@ const matchesIgnoredSourcePath = (fileName: string, sourceOptions?: SourceOption return false; }; -const stripRelativeSourcePathPrefix = (path: string): string | null => { - let remainingPath = path; - let didStripPrefix = false; - while (remainingPath.startsWith("../") || remainingPath.startsWith("./")) { - didStripPrefix = true; - remainingPath = remainingPath.slice(remainingPath.startsWith("../") ? 3 : 2); - } - return didStripPrefix ? remainingPath : null; -}; - -const parseScopedPackageSourceName = (fileName: string): string | null => { - const sourcePath = stripRelativeSourcePathPrefix( - safeDecodeURIComponent(normalizeFileName(fileName)), - ); - if (!sourcePath) return null; - - const [scope, packageName] = sourcePath.split(PATH_SEPARATOR_PATTERN).filter(Boolean); - if ( - !scope || - !packageName || - !SCOPED_PACKAGE_PATTERN.test(scope) || - !PACKAGE_NAME_SEGMENT_PATTERN.test(packageName) || - APPLICATION_PACKAGE_NAME_SEGMENTS.has(packageName) - ) { - return null; - } - - return `${scope}/${packageName}`; -}; - export const classifySourcePath = ( fileName: string | null | undefined, sourceOptions?: SourceOptions, @@ -126,7 +89,7 @@ const classifySourcePathUncached = ( return { kind: "unknown", packageName: null }; } - const packageName = parsePackageName(fileName) ?? parseScopedPackageSourceName(fileName); + const packageName = resolvePackageName(fileName); if (packageName) { return { kind: "package-source", packageName }; } From 5c67617019c2a9ca4b64d07c7d4e5ebbd0796a1c Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 4 Jun 2026 17:37:45 -0700 Subject: [PATCH 21/65] Classify fiber source from its raw path normalizeFilePath strips a leading "./" that scoped-package detection depends on, so a fiber source pointing at a relative scoped dependency (e.g. ./@radix-ui/...) was misclassified as app-source and returned instead of the real app frame. Classify the unnormalized fileName, matching how stack frames are classified. --- packages/react-grab/src/core/context.test.ts | 1 + packages/react-grab/src/core/context.ts | 11 +++++++++-- .../react-grab/src/utils/source-frame-policy.test.ts | 8 ++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/react-grab/src/core/context.test.ts b/packages/react-grab/src/core/context.test.ts index efd6d5341..b9c066794 100644 --- a/packages/react-grab/src/core/context.test.ts +++ b/packages/react-grab/src/core/context.test.ts @@ -13,6 +13,7 @@ const fiberSource = { lineNumber: 1, columnNumber: 1, componentName: "Page", + sourceFileName: "/src/app/page.tsx", }; const appFrame: StackFrame = { diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 83209d175..506dcce64 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -283,6 +283,10 @@ interface ResolvedSource { lineNumber: number | null; columnNumber: number | null; componentName: string | null; + // Raw path used for classification. normalizeFilePath strips a leading "./", + // which scoped-package detection relies on, so classification must see the + // unnormalized form (matching how stack frames are classified from fileName). + sourceFileName: string; } const pickSourceFrame = (frames: StackFrame[]): StackFrame | null => { @@ -311,6 +315,7 @@ const getFiberSource = async (element: Element): Promise columnNumber: source.columnNumber ?? null, componentName: toSourceComponentName(source.functionName) ?? getSourceComponentName(fiber._debugOwner), + sourceFileName: source.fileName, }; } catch { return null; @@ -332,7 +337,7 @@ const getApplicationFiberSource = async ( sourceOptions?: SourceOptions, ): Promise => { const source = await getCachedFiberSource(element); - if (!source || classifySourcePath(source.filePath, sourceOptions).kind !== "app-source") { + if (!source || classifySourcePath(source.sourceFileName, sourceOptions).kind !== "app-source") { return null; } return source; @@ -345,6 +350,7 @@ const resolveStackFrameSource = (frame: StackFrame | null | undefined): Resolved lineNumber: frame.lineNumber ?? null, columnNumber: frame.columnNumber ?? null, componentName: toSourceComponentName(frame.functionName), + sourceFileName: frame.fileName, }; }; @@ -379,7 +385,7 @@ export const resolveSource = async ( options: ResolveSourceOptions = {}, ): Promise => { const fiberSource = await getCachedFiberSource(element); - const fiberSourceKind = classifySourcePath(fiberSource?.filePath, options.sourceOptions).kind; + const fiberSourceKind = classifySourcePath(fiberSource?.sourceFileName, options.sourceOptions).kind; if (fiberSourceKind === "app-source") return fiberSource; const framesByKind: FramesBySourceKind = { @@ -506,6 +512,7 @@ const formatStackFrameLine = ( filePath: resolvedSource, lineNumber: frame.lineNumber ?? null, columnNumber: frame.columnNumber ?? null, + sourceFileName: resolvedSource, }, isNextProject, ); diff --git a/packages/react-grab/src/utils/source-frame-policy.test.ts b/packages/react-grab/src/utils/source-frame-policy.test.ts index ada0857cf..529fd65ed 100644 --- a/packages/react-grab/src/utils/source-frame-policy.test.ts +++ b/packages/react-grab/src/utils/source-frame-policy.test.ts @@ -24,6 +24,14 @@ describe("classifySourcePath", () => { }); }); + // The relative prefix is the only signal distinguishing a scoped dependency + // import from a `@alias/...` path, so a normalized path (leading "./" removed) + // can no longer be detected as a package. Callers must classify the raw path. + it("only detects relative scoped packages while the relative prefix survives", () => { + expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx").kind).toBe("package-source"); + expect(classifySourcePath("@radix-ui/react-tabs/src/tabs.tsx").kind).not.toBe("package-source"); + }); + it("classifies default ignored app source paths", () => { expect(classifySourcePath("components/ui/button.tsx")).toEqual({ kind: "ignored-app-source", From e18b4f6eec633e4ce39520244c1faaf20f41dcf7 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 4 Jun 2026 17:57:37 -0700 Subject: [PATCH 22/65] Surface ignored fallback source in copied snippet When the only resolvable source is an ignored wrapper (e.g. components/ui) with no app frame, the copied snippet omitted the resolved path because formatStackContext drops ignored frames and only led with app fiber sources. Lead with the ignored fallback in that case so the snippet matches the selection metadata. App and package frames are unaffected since they already render inline and resolveSource only returns ignored when no app source exists. --- packages/react-grab/src/core/context.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 506dcce64..91e1e6b08 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -570,12 +570,35 @@ const formatStackContext = ( return lines.join(""); }; +// The snippet leads with the app fiber's own JSX location when available. +// Otherwise it surfaces an ignored fallback (e.g. components/ui): those frames +// are dropped from the stack body but still back the selection metadata, so +// without this the copied snippet would omit the resolved path. App and package +// frames need no such handling — formatStackContext already renders them inline, +// and resolveSource only returns the ignored kind when no app source exists. +const resolveLeadingSource = async ( + element: Element, + sourceOptions?: SourceOptions, +): Promise => { + const appFiberSource = await getApplicationFiberSource(element, sourceOptions); + if (appFiberSource) return appFiberSource; + + const resolved = await resolveSource(element, { sourceOptions }); + if ( + resolved && + classifySourcePath(resolved.sourceFileName, sourceOptions).kind === "ignored-app-source" + ) { + return resolved; + } + return null; +}; + export const getStackContext = async ( element: Element, options: StackContextOptions = {}, ): Promise => { const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; - const leadingSource = await getApplicationFiberSource(element, options.sourceOptions); + const leadingSource = await resolveLeadingSource(element, options.sourceOptions); const stack = await getStack(element); if (stack) { From cf791673bf2ae26cd33a5d4b0cee59d1d490d71f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 4 Jun 2026 21:37:12 -0700 Subject: [PATCH 23/65] Keep UI-component frames as named context Instead of dropping ignored UI-wrapper frames (e.g. components/ui) from the stack snippet entirely, render them by component name (e.g. "in Button") while still walking up to the app frame. Only app-owned frames contribute a file path, so the wrapper stays as context without competing with the resolved app source or the open-file target. --- packages/react-grab/src/core/context.test.ts | 26 +++++++++++++++++++- packages/react-grab/src/core/context.ts | 6 +++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/react-grab/src/core/context.test.ts b/packages/react-grab/src/core/context.test.ts index b9c066794..c03553aff 100644 --- a/packages/react-grab/src/core/context.test.ts +++ b/packages/react-grab/src/core/context.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import type { StackFrame } from "bippy/source"; -import { selectResolvedSource, type FramesBySourceKind } from "./context.js"; +import { formatStackContext, selectResolvedSource, type FramesBySourceKind } from "./context.js"; const emptyFramesByKind = (): FramesBySourceKind => ({ "app-source": [], @@ -110,3 +110,27 @@ describe("selectResolvedSource", () => { }); }); }); + +describe("formatStackContext", () => { + it("keeps UI-component frames by name while still surfacing the app frame", () => { + const result = formatStackContext([ + { fileName: "src/components/ui/button.tsx", functionName: "Button" }, + { fileName: "src/app/page.tsx", functionName: "Page" }, + ]); + + expect(result).toContain("in Button"); + expect(result).not.toContain("button.tsx"); + expect(result).toContain("in Page"); + expect(result).toContain("app/page.tsx"); + }); + + it("omits anonymous UI-component frames that carry no name", () => { + const result = formatStackContext([ + { fileName: "src/components/ui/button.tsx" }, + { fileName: "src/app/page.tsx", functionName: "Page" }, + ]); + + expect(result).not.toContain("button.tsx"); + expect(result).toContain("in Page"); + }); +}); diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 91e1e6b08..73da7819c 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -490,6 +490,9 @@ const formatStackFrameLine = ( isNextProject: boolean, ): string | null => { const libraryPackage = sourcePath.packageName; + // Only app-owned frames contribute a file path. Ignored UI-wrapper frames + // render by component name (e.g. "in Button") so they stay as context without + // surfacing a wrapper path that would compete with the resolved app source. const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { @@ -521,7 +524,7 @@ const formatStackFrameLine = ( return null; }; -const formatStackContext = ( +export const formatStackContext = ( stack: StackFrame[], options: StackContextOptions = {}, leadingSource: ResolvedSource | null = null, @@ -540,7 +543,6 @@ const formatStackContext = ( if (lines.length >= maxLines) break; const sourcePath = classifySourcePath(frame.fileName, options.sourceOptions); - if (sourcePath.kind === "ignored-app-source") continue; const componentName = toSourceComponentName(frame.functionName); const libraryFrameKey = sourcePath.packageName From e70714ddf9190b9604fcd2f39e429798407e6e9f Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 4 Jun 2026 22:46:15 -0700 Subject: [PATCH 24/65] fix --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 836896efc..f09e8d941 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ React Grab turns a browser selection into source context your agent can use: The copied context includes the selected element and its component stack with source locations: ```txt -[Forgot your password? in LoginForm (at components/login-form.tsx:46:19)] +Forgot your password? + in LoginForm (at components/login-form.tsx:46:19) ``` ## Manual Installation From 29247238e5c698484c47c8af72b2c301b9683cd8 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 4 Jun 2026 22:47:41 -0700 Subject: [PATCH 25/65] Move unit tests into tests/ folder Relocate the colocated src unit tests to a dedicated tests/ directory (matching the e2e/ convention), update the vp test script to run tests/, and add tests/ to the tsconfig include. --- packages/react-grab/package.json | 2 +- packages/react-grab/{src/core => tests}/context.test.ts | 6 +++++- .../{src/utils => tests}/parse-package-name.test.ts | 2 +- .../{src/utils => tests}/source-frame-policy.test.ts | 2 +- packages/react-grab/tsconfig.json | 9 ++++++++- 5 files changed, 16 insertions(+), 5 deletions(-) rename packages/react-grab/{src/core => tests}/context.test.ts (97%) rename packages/react-grab/{src/utils => tests}/parse-package-name.test.ts (94%) rename packages/react-grab/{src/utils => tests}/source-frame-policy.test.ts (97%) diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index 62a430cc1..3566e86c6 100644 --- a/packages/react-grab/package.json +++ b/packages/react-grab/package.json @@ -85,7 +85,7 @@ "build": "NODE_ENV=production vp pack", "build:profiling": "pnpm run prebuild && NODE_ENV=profiling REACT_GRAB_NO_MINIFY=true REACT_GRAB_SOURCEMAP=true vp pack", "dev": "concurrently \"pnpm:css:watch\" \"vp pack --watch\"", - "test": "vp test run src && playwright test", + "test": "vp test run tests && playwright test", "test:perf": "playwright test --grep @perf --reporter=list", "test:perf:baseline": "PERF_LABEL=baseline playwright test --grep @perf --reporter=list", "test:expect": "bun e2e/react-grab.expect.ts", diff --git a/packages/react-grab/src/core/context.test.ts b/packages/react-grab/tests/context.test.ts similarity index 97% rename from packages/react-grab/src/core/context.test.ts rename to packages/react-grab/tests/context.test.ts index c03553aff..c6187f5f4 100644 --- a/packages/react-grab/src/core/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import type { StackFrame } from "bippy/source"; -import { formatStackContext, selectResolvedSource, type FramesBySourceKind } from "./context.js"; +import { + formatStackContext, + selectResolvedSource, + type FramesBySourceKind, +} from "../src/core/context.js"; const emptyFramesByKind = (): FramesBySourceKind => ({ "app-source": [], diff --git a/packages/react-grab/src/utils/parse-package-name.test.ts b/packages/react-grab/tests/parse-package-name.test.ts similarity index 94% rename from packages/react-grab/src/utils/parse-package-name.test.ts rename to packages/react-grab/tests/parse-package-name.test.ts index ea4be0c2e..ef0c49e0d 100644 --- a/packages/react-grab/src/utils/parse-package-name.test.ts +++ b/packages/react-grab/tests/parse-package-name.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { parsePackageName } from "./parse-package-name.js"; +import { parsePackageName } from "../src/utils/parse-package-name.js"; describe("parsePackageName", () => { it("reads packages from node_modules paths", () => { diff --git a/packages/react-grab/src/utils/source-frame-policy.test.ts b/packages/react-grab/tests/source-frame-policy.test.ts similarity index 97% rename from packages/react-grab/src/utils/source-frame-policy.test.ts rename to packages/react-grab/tests/source-frame-policy.test.ts index 529fd65ed..7e02ff5d5 100644 --- a/packages/react-grab/src/utils/source-frame-policy.test.ts +++ b/packages/react-grab/tests/source-frame-policy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { classifySourcePath } from "./source-frame-policy.js"; +import { classifySourcePath } from "../src/utils/source-frame-policy.js"; describe("classifySourcePath", () => { it("classifies application source paths", () => { diff --git a/packages/react-grab/tsconfig.json b/packages/react-grab/tsconfig.json index 6dd790b51..a82d4c27a 100644 --- a/packages/react-grab/tsconfig.json +++ b/packages/react-grab/tsconfig.json @@ -10,6 +10,13 @@ "lib": ["esnext", "dom", "dom.iterable"], "skipLibCheck": true }, - "include": ["src", "vite.config.ts", "solid-babel-plugin.ts", "e2e", "playwright.config.ts"], + "include": [ + "src", + "tests", + "vite.config.ts", + "solid-babel-plugin.ts", + "e2e", + "playwright.config.ts" + ], "exclude": ["**/node_modules/**", "dist"] } From 7076ea76032c35880cfd4e1ba636e8aa8bed29ef Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 5 Jun 2026 23:25:40 -0700 Subject: [PATCH 26/65] Remove unnecessary import/export aliasing Rename the core element-snippet formatter to formatElementInfo (its existing public export name) so it no longer collides with the public getElementContext in primitives, eliminating the getElementContext-as-* import/re-export aliases. Rename the internal open-file util to requestOpenFile to drop the openFile-as-openFileAsync alias; public exports are unchanged. --- packages/react-grab/src/components/renderer.tsx | 4 ++-- packages/react-grab/src/core/context.ts | 2 +- packages/react-grab/src/core/index.tsx | 6 +++--- packages/react-grab/src/core/plugins/open.ts | 4 ++-- packages/react-grab/src/primitives.ts | 8 ++++---- packages/react-grab/src/utils/generate-snippet.ts | 4 ++-- packages/react-grab/src/utils/open-file.ts | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index b6d8c83de..2fc1aeed8 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -7,7 +7,7 @@ import { FROZEN_GLOW_EDGE_PX, Z_INDEX_OVERLAY_CANVAS, } from "../constants.js"; -import { openFile } from "../utils/open-file.js"; +import { requestOpenFile } from "../utils/open-file.js"; import { isElementConnected } from "../utils/is-element-connected.js"; import { OverlayCanvas } from "./overlay-canvas.js"; import { SelectionLabel } from "./selection-label/index.js"; @@ -104,7 +104,7 @@ export const ReactGrabRenderer: Component = (props) => { onCancelDismiss={props.onCancelDismiss} onOpen={() => { if (props.selectionFilePath) { - openFile(props.selectionFilePath, props.selectionLineNumber); + requestOpenFile(props.selectionFilePath, props.selectionLineNumber); } }} isContextMenuOpen={props.contextMenuPosition !== null} diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 73da7819c..7a750eadc 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -620,7 +620,7 @@ export const getStackContext = async ( return ""; }; -export const getElementContext = async ( +export const formatElementInfo = async ( element: Element, options: StackContextOptions = {}, ): Promise => { diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 65176942c..9a1426671 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -89,7 +89,7 @@ import { isCLikeKey } from "../utils/is-c-like-key.js"; import { isTargetKeyCombination } from "../utils/is-target-key-combination.js"; import { parseActivationKey } from "../utils/parse-activation-key.js"; import { isEventFromOverlay } from "../utils/is-event-from-overlay.js"; -import { openFile } from "../utils/open-file.js"; +import { requestOpenFile } from "../utils/open-file.js"; import { combineBounds } from "../utils/combine-bounds.js"; import type { Position, @@ -2209,7 +2209,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const wasHandled = pluginRegistry.hooks.onOpenFile(filePath, lineNumber ?? undefined); if (!wasHandled) { - openFile(filePath, lineNumber ?? undefined, pluginRegistry.hooks.transformOpenFileUrl); + requestOpenFile(filePath, lineNumber ?? undefined, pluginRegistry.hooks.transformOpenFileUrl); } return true; }; @@ -3728,7 +3728,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); }; -export { getStack, getElementContext as formatElementInfo } from "./context.js"; +export { getStack, formatElementInfo } from "./context.js"; export { isInstrumentationActive } from "bippy"; export { DEFAULT_THEME } from "./theme.js"; diff --git a/packages/react-grab/src/core/plugins/open.ts b/packages/react-grab/src/core/plugins/open.ts index e2fc668a5..bf240c571 100644 --- a/packages/react-grab/src/core/plugins/open.ts +++ b/packages/react-grab/src/core/plugins/open.ts @@ -1,5 +1,5 @@ import type { Plugin } from "../../types.js"; -import { openFile } from "../../utils/open-file.js"; +import { requestOpenFile } from "../../utils/open-file.js"; export const openPlugin: Plugin = { name: "open", @@ -15,7 +15,7 @@ export const openPlugin: Plugin = { const wasHandled = context.hooks.onOpenFile(context.filePath, context.lineNumber); if (!wasHandled) { - openFile(context.filePath, context.lineNumber, context.hooks.transformOpenFileUrl); + requestOpenFile(context.filePath, context.lineNumber, context.hooks.transformOpenFileUrl); } context.hideContextMenu(); diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts index ebe4f969a..743a8d496 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -14,7 +14,7 @@ import { getHTMLPreview, getStack, getStackContext, - getElementContext as formatElementSnippet, + formatElementInfo, resolveSource, } from "./core/context.js"; import { Fiber, getFiberFromHostInstance } from "bippy"; @@ -23,7 +23,7 @@ export type { StackFrame }; import type { SourceOptions } from "./types.js"; import { createElementSelector } from "./utils/create-element-selector.js"; import { extractElementCss, disposeBaselineStyles } from "./utils/extract-element-css.js"; -import { openFile as openFileAsync } from "./utils/open-file.js"; +import { requestOpenFile } from "./utils/open-file.js"; export interface ReactGrabElementContext { element: Element; @@ -61,7 +61,7 @@ export const getElementContext = async ( options: ReactGrabElementContextOptions = {}, ): Promise => { const [snippet, source, stack] = await Promise.all([ - formatElementSnippet(element, { sourceOptions: options.sourceOptions }), + formatElementInfo(element, { sourceOptions: options.sourceOptions }), resolveSource(element, { sourceOptions: options.sourceOptions }), getStack(element).then((result) => result ?? []), ]); @@ -169,7 +169,7 @@ export const isFreezeActive = (): boolean => { * openFile("/src/components/Button.tsx", 42); */ export const openFile = async (filePath: string, lineNumber?: number): Promise => { - await openFileAsync(filePath, lineNumber); + await requestOpenFile(filePath, lineNumber); }; export { disposeBaselineStyles }; diff --git a/packages/react-grab/src/utils/generate-snippet.ts b/packages/react-grab/src/utils/generate-snippet.ts index dce9fa734..efface1b7 100644 --- a/packages/react-grab/src/utils/generate-snippet.ts +++ b/packages/react-grab/src/utils/generate-snippet.ts @@ -1,4 +1,4 @@ -import { getElementContext } from "../core/context.js"; +import { formatElementInfo } from "../core/context.js"; import type { SourceOptions } from "../types.js"; interface GenerateSnippetOptions { @@ -11,7 +11,7 @@ export const generateSnippet = async ( options: GenerateSnippetOptions = {}, ): Promise => { const elementSnippetResults = await Promise.allSettled( - elements.map((element) => getElementContext(element, options)), + elements.map((element) => formatElementInfo(element, options)), ); const elementSnippets = elementSnippetResults.map((result) => diff --git a/packages/react-grab/src/utils/open-file.ts b/packages/react-grab/src/utils/open-file.ts index a13edea0d..96f8cf601 100644 --- a/packages/react-grab/src/utils/open-file.ts +++ b/packages/react-grab/src/utils/open-file.ts @@ -24,7 +24,7 @@ const tryDevServerOpen = async ( return response.ok; }; -export const openFile = async ( +export const requestOpenFile = async ( filePath: string, lineNumber: number | undefined, transformUrl?: (url: string, filePath: string, lineNumber?: number) => string, From 56e508016f7a63077562dfc26ebe3457ce561e80 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 06:12:24 +0000 Subject: [PATCH 27/65] Improve HTML preview text extraction Co-authored-by: Aiden Bai --- .../react-grab/e2e/element-context.spec.ts | 42 +++++++++++++ packages/react-grab/src/core/context.ts | 59 +++++++++++++++---- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index a955c9427..b1e2955d7 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -139,5 +139,47 @@ 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); + }); + + await reactGrab.activate(); + + await reactGrab.hoverElement("pre.shiki"); + await reactGrab.waitForSelectionBox(); + await reactGrab.clickElement("pre.shiki"); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain(""); + }); }); }); diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 850a446c4..5f1cb9451 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -552,8 +552,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`; @@ -591,6 +591,37 @@ const formatPriorityAttrs = ( const isClassOrStyleAttr = (name: string): boolean => name === "class" || name === "className" || name === "style"; +const TEXT_CONTENT_PREVIEW_TAGS = new Set([ + "a", + "abbr", + "b", + "button", + "caption", + "cite", + "code", + "dd", + "dt", + "em", + "figcaption", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "label", + "legend", + "li", + "p", + "pre", + "small", + "span", + "strong", + "summary", + "td", + "th", +]); + const formatAttrsForPreview = (element: Element): string => { const identifyingParts: string[] = []; const remainingParts: string[] = []; @@ -614,11 +645,13 @@ const formatAttrsForPreview = (element: Element): string => { return identifyingParts.join("") + remainingParts.join("") + classAttr; }; +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) { - const trimmed = node.textContent?.trim() ?? ""; + const trimmed = collapseTextContent(node.textContent ?? ""); if (trimmed) { directText += (directText ? " " : "") + trimmed; } @@ -627,6 +660,12 @@ const getDirectTextContent = (element: Element): string => { return directText; }; +const getPreviewTextContent = (element: Element, tagName: string): string => { + const directText = getDirectTextContent(element); + if (directText || !TEXT_CONTENT_PREVIEW_TAGS.has(tagName)) return directText; + return collapseTextContent(element.textContent ?? ""); +}; + const formatChildElements = (elements: Array): string => { if (elements.length === 0) return ""; if (elements.length <= 2) { @@ -647,8 +686,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}`; @@ -659,7 +698,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 +721,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`; From 3ee3f97064e48dcd01404ff84b941d26afba8556 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 06:25:22 +0000 Subject: [PATCH 28/65] Stabilize code block preview regression Co-authored-by: Aiden Bai --- packages/react-grab/e2e/element-context.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index b1e2955d7..f51a92781 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -169,11 +169,8 @@ test.describe("Element Context Fallback", () => { document.body.appendChild(wrapper); }); - await reactGrab.activate(); - - await reactGrab.hoverElement("pre.shiki"); - await reactGrab.waitForSelectionBox(); - await reactGrab.clickElement("pre.shiki"); + const didCopy = await reactGrab.copyElementViaApi("pre.shiki"); + expect(didCopy).toBe(true); const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toContain(" Date: Sun, 7 Jun 2026 07:27:59 +0000 Subject: [PATCH 29/65] Cover nested link HTML previews Co-authored-by: Aiden Bai --- .../react-grab/e2e/element-context.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index f51a92781..06f0e4b73 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -178,5 +178,38 @@ test.describe("Element Context Fallback", () => { expect(clipboard).toContain('git commit -m "Add React Doctor to CI"'); 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('"); + }); }); }); From a039a9653b1c715832b74c9c857d6e8924dc9c9f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 07:29:36 +0000 Subject: [PATCH 30/65] Format link preview regression Co-authored-by: Aiden Bai --- packages/react-grab/e2e/element-context.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 06f0e4b73..ab66518a8 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -203,7 +203,9 @@ test.describe("Element Context Fallback", () => { document.body.appendChild(wrapper); }); - const didCopy = await reactGrab.copyElementViaApi("a[href='/docs/ci-and-prs/github-actions-setup']"); + const didCopy = await reactGrab.copyElementViaApi( + "a[href='/docs/ci-and-prs/github-actions-setup']", + ); expect(didCopy).toBe(true); const clipboard = await reactGrab.getClipboardContent(); From 3d929e77cf1a8bc4286a78075c806e27f18cfabc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 07:42:18 +0000 Subject: [PATCH 31/65] Add selector hints for low-signal traces Co-authored-by: Aiden Bai --- .../react-grab/e2e/element-context.spec.ts | 1 + packages/react-grab/src/constants.ts | 1 + packages/react-grab/src/core/context.ts | 57 ++++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index ab66518a8..974233fcc 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -211,6 +211,7 @@ test.describe("Element Context Fallback", () => { const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toContain('"); }); }); diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index efaf6384d..5494bdfe3 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -72,6 +72,7 @@ export const LABEL_GAP_PX = 4; export const PREVIEW_TEXT_MAX_LENGTH = 100; export const PREVIEW_ATTR_VALUE_MAX_LENGTH = 15; export const PREVIEW_MAX_ATTRS = 3; +export const CONTEXT_SELECTOR_MAX_LENGTH_CHARS = 160; export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [ "id", "class", diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 5f1cb9451..5829fc8bb 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -23,6 +23,7 @@ import { PREVIEW_IDENTIFYING_ATTRS, SYMBOLICATION_TIMEOUT_MS, DEFAULT_MAX_CONTEXT_LINES, + CONTEXT_SELECTOR_MAX_LENGTH_CHARS, } from "../constants.js"; import { getTagName } from "../utils/get-tag-name.js"; import { truncateString } from "../utils/truncate-string.js"; @@ -31,6 +32,7 @@ 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 { isInternalComponentName, isUsefulComponentName, @@ -378,6 +380,52 @@ const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { }); }; +const isResolvedSourceFrame = (frame: StackFrame): boolean => + Boolean(frame.fileName && isSourceFile(frame.fileName)); + +const isTrustedSourcePath = (filePath: string | null | undefined): boolean => + Boolean(filePath && isSourceFile(filePath) && !parsePackageName(filePath)); + +const getSourceComponentNameFromFrame = (frame: StackFrame): string | null => + frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null; + +const getFirstContextFrame = (stack: StackFrame[] | null): StackFrame | null => { + if (!stack) return null; + for (const frame of stack) { + if (parsePackageName(frame.fileName)) return frame; + if (isResolvedSourceFrame(frame)) return frame; + if (frame.isServer && (!frame.functionName || getSourceComponentNameFromFrame(frame))) { + return frame; + } + if (getSourceComponentNameFromFrame(frame)) return frame; + } + return null; +}; + +const hasTrustedSourceTrace = ( + leadingSource: ResolvedSource | null, + stack: StackFrame[] | null, +): boolean => + isTrustedSourcePath(leadingSource?.filePath) || + Boolean(stack?.some((frame) => isTrustedSourcePath(frame.fileName))); + +const shouldAppendSelectorHint = ( + leadingSource: ResolvedSource | null, + stack: StackFrame[] | null, +): boolean => { + if (isTrustedSourcePath(leadingSource?.filePath)) return false; + + const firstContextFrame = getFirstContextFrame(stack); + if (!firstContextFrame) return true; + if (parsePackageName(firstContextFrame.fileName)) return true; + return !hasTrustedSourceTrace(leadingSource, stack); +}; + +const formatSelectorContextLine = (element: Element): string => { + const selector = createElementSelector(element); + return `\n selector: ${truncateString(selector, CONTEXT_SELECTOR_MAX_LENGTH_CHARS)}`; +}; + const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => { if (!isInstrumentationActive()) return []; const fiber = getFiberFromHostInstance(element); @@ -513,9 +561,12 @@ export const getStackContext = async ( const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; const leadingSource = await getFiberSource(element); const stack = await getStack(element); + const selectorContext = shouldAppendSelectorHint(leadingSource, stack) + ? formatSelectorContextLine(element) + : ""; if (stack && hasFormattableFrames(stack)) { - return formatStackContext(stack, options, leadingSource); + return `${formatStackContext(stack, options, leadingSource)}${selectorContext}`; } if (leadingSource) { @@ -524,10 +575,10 @@ export const getStackContext = async ( const componentNames = getComponentNamesFromFiber(findNearestFiberElement(element), maxLines); if (componentNames.length > 0) { - return componentNames.map((name) => `\n in ${name}`).join(""); + return `${componentNames.map((name) => `\n in ${name}`).join("")}${selectorContext}`; } - return ""; + return selectorContext; }; export const getElementContext = async ( From 92afd4dca00ce950e0ee00e02baa63f780a88428 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 07:43:26 +0000 Subject: [PATCH 32/65] Prefer link attributes for selector hints Co-authored-by: Aiden Bai --- packages/react-grab/src/utils/create-element-selector.ts | 2 ++ 1 file changed, 2 insertions(+) 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", From 4f309bb7a67b80b089d2a7cd7a3016a48a6caff9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 07:44:14 +0000 Subject: [PATCH 33/65] Expect compact href selector hint Co-authored-by: Aiden Bai --- packages/react-grab/e2e/element-context.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 974233fcc..a8f89495c 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -211,7 +211,7 @@ test.describe("Element Context Fallback", () => { const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toContain('"); }); }); From e1b635d846f78bbbdd88e1048c9931d53afaa7df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 07:59:39 +0000 Subject: [PATCH 34/65] Clean up preview context formatting boundaries Co-authored-by: Aiden Bai --- .../react-grab/e2e/element-context.spec.ts | 1 + packages/react-grab/src/constants.ts | 30 +++ packages/react-grab/src/core/context.ts | 197 ++++++++---------- packages/react-grab/src/core/copy.ts | 9 +- .../src/utils/get-preview-text-content.ts | 50 +++++ 5 files changed, 176 insertions(+), 111 deletions(-) create mode 100644 packages/react-grab/src/utils/get-preview-text-content.ts diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index a8f89495c..30b4d5529 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -211,6 +211,7 @@ test.describe("Element Context Fallback", () => { const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toContain('"); }); diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 5494bdfe3..2df35df02 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -73,6 +73,36 @@ export const PREVIEW_TEXT_MAX_LENGTH = 100; export const PREVIEW_ATTR_VALUE_MAX_LENGTH = 15; export const PREVIEW_MAX_ATTRS = 3; export const CONTEXT_SELECTOR_MAX_LENGTH_CHARS = 160; +export const PREVIEW_TEXT_TAGS = new Set([ + "a", + "abbr", + "b", + "button", + "caption", + "cite", + "code", + "dd", + "dt", + "em", + "figcaption", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "label", + "legend", + "li", + "p", + "pre", + "small", + "span", + "strong", + "summary", + "td", + "th", +]); export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [ "id", "class", diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 5829fc8bb..2f7a425ef 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -33,6 +33,7 @@ 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, @@ -368,6 +369,12 @@ interface StackContextOptions { maxLines?: number; } +interface TraceContextResult { + text: string; + firstSignalKind: "trusted-source" | "untrusted-source" | "package" | "server" | "component" | null; + hasTrustedSource: boolean; +} + const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { if (!stack) return false; return stack.some((frame) => { @@ -380,45 +387,18 @@ const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { }); }; -const isResolvedSourceFrame = (frame: StackFrame): boolean => - Boolean(frame.fileName && isSourceFile(frame.fileName)); - const isTrustedSourcePath = (filePath: string | null | undefined): boolean => Boolean(filePath && isSourceFile(filePath) && !parsePackageName(filePath)); -const getSourceComponentNameFromFrame = (frame: StackFrame): string | null => - frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null; +const getSourceSignalKind = (filePath: string): TraceContextResult["firstSignalKind"] => + isTrustedSourcePath(filePath) ? "trusted-source" : "untrusted-source"; -const getFirstContextFrame = (stack: StackFrame[] | null): StackFrame | null => { - if (!stack) return null; - for (const frame of stack) { - if (parsePackageName(frame.fileName)) return frame; - if (isResolvedSourceFrame(frame)) return frame; - if (frame.isServer && (!frame.functionName || getSourceComponentNameFromFrame(frame))) { - return frame; - } - if (getSourceComponentNameFromFrame(frame)) return frame; +const shouldAppendSelectorHint = (traceContext: TraceContextResult): boolean => { + if (!traceContext.text) return true; + if (traceContext.firstSignalKind === "package" || traceContext.firstSignalKind === "server") { + return true; } - return null; -}; - -const hasTrustedSourceTrace = ( - leadingSource: ResolvedSource | null, - stack: StackFrame[] | null, -): boolean => - isTrustedSourcePath(leadingSource?.filePath) || - Boolean(stack?.some((frame) => isTrustedSourcePath(frame.fileName))); - -const shouldAppendSelectorHint = ( - leadingSource: ResolvedSource | null, - stack: StackFrame[] | null, -): boolean => { - if (isTrustedSourcePath(leadingSource?.filePath)) return false; - - const firstContextFrame = getFirstContextFrame(stack); - if (!firstContextFrame) return true; - if (parsePackageName(firstContextFrame.fileName)) return true; - return !hasTrustedSourceTrace(leadingSource, stack); + return !traceContext.hasTrustedSource; }; const formatSelectorContextLine = (element: Element): string => { @@ -483,22 +463,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; + let firstSignalKind: TraceContextResult["firstSignalKind"] = null; + let hasTrustedSource = false; - if (leadingSource) { - lines.push(formatSourceContextLine(leadingSource, isNextProject)); - } + const recordSignal = (signalKind: TraceContextResult["firstSignalKind"]) => { + firstSignalKind ??= signalKind; + if (signalKind === "trusted-source") hasTrustedSource = true; + }; - const emit = (line: string, libraryPackage: string | null) => { + const emit = ( + line: string, + libraryPackage: string | null, + signalKind: TraceContextResult["firstSignalKind"], + ) => { lines.push(line); previousLibraryPackage = libraryPackage; + recordSignal(signalKind); }; + if (leadingSource) { + emit( + formatSourceContextLine(leadingSource, isNextProject), + null, + getSourceSignalKind(leadingSource.filePath), + ); + } + for (const frame of stack) { if (lines.length >= maxLines) break; @@ -523,7 +519,11 @@ 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, + libraryPackage ? "package" : "server", + ); continue; } @@ -531,6 +531,7 @@ const formatStackContext = ( emit( libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`, libraryPackage, + libraryPackage ? "package" : "component", ); continue; } @@ -547,38 +548,75 @@ const formatStackContext = ( isNextProject, ), null, + getSourceSignalKind(resolvedSource), ); } } - return lines.join(""); + return { + text: lines.join(""), + firstSignalKind, + 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); - const selectorContext = shouldAppendSelectorHint(leadingSource, stack) - ? formatSelectorContextLine(element) - : ""; if (stack && hasFormattableFrames(stack)) { - return `${formatStackContext(stack, options, leadingSource)}${selectorContext}`; + return formatStackContext(stack, options, leadingSource); } if (leadingSource) { - return formatSourceContextLine(leadingSource, isNextProjectRuntime()); + const signalKind = getSourceSignalKind(leadingSource.filePath); + return { + text: formatSourceContextLine(leadingSource, isNextProjectRuntime()), + firstSignalKind: signalKind, + hasTrustedSource: signalKind === "trusted-source", + }; } const componentNames = getComponentNamesFromFiber(findNearestFiberElement(element), maxLines); if (componentNames.length > 0) { - return `${componentNames.map((name) => `\n in ${name}`).join("")}${selectorContext}`; + return { + text: componentNames.map((name) => `\n in ${name}`).join(""), + firstSignalKind: "component", + hasTrustedSource: false, + }; } - return selectorContext; + return { + text: "", + firstSignalKind: null, + hasTrustedSource: false, + }; +}; + +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 => + shouldAppendSelectorHint(traceContext) ? 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 ( @@ -587,10 +625,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); @@ -642,37 +681,6 @@ const formatPriorityAttrs = ( const isClassOrStyleAttr = (name: string): boolean => name === "class" || name === "className" || name === "style"; -const TEXT_CONTENT_PREVIEW_TAGS = new Set([ - "a", - "abbr", - "b", - "button", - "caption", - "cite", - "code", - "dd", - "dt", - "em", - "figcaption", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "label", - "legend", - "li", - "p", - "pre", - "small", - "span", - "strong", - "summary", - "td", - "th", -]); - const formatAttrsForPreview = (element: Element): string => { const identifyingParts: string[] = []; const remainingParts: string[] = []; @@ -696,27 +704,6 @@ const formatAttrsForPreview = (element: Element): string => { return identifyingParts.join("") + remainingParts.join("") + classAttr; }; -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) { - const trimmed = collapseTextContent(node.textContent ?? ""); - if (trimmed) { - directText += (directText ? " " : "") + trimmed; - } - } - } - return directText; -}; - -const getPreviewTextContent = (element: Element, tagName: string): string => { - const directText = getDirectTextContent(element); - if (directText || !TEXT_CONTENT_PREVIEW_TAGS.has(tagName)) return directText; - return collapseTextContent(element.textContent ?? ""); -}; - const formatChildElements = (elements: Array): string => { if (elements.length === 0) return ""; if (elements.length <= 2) { 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/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts new file mode 100644 index 000000000..51b2f8995 --- /dev/null +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -0,0 +1,50 @@ +import { PREVIEW_TEXT_TAGS } from "../constants.js"; + +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 => { + const directText = getDirectTextContent(element); + if (directText || !PREVIEW_TEXT_TAGS.has(tagName)) return directText; + + const parts: string[] = []; + for (const childNode of element.childNodes) { + collectDescendantText(childNode, parts); + } + return collapseTextContent(parts.join(" ")); +}; From 80a3a082ccd3c44a6178d807137ce49c50bd1725 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 08:00:55 +0000 Subject: [PATCH 35/65] Format trace context result type Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 2f7a425ef..cf6600428 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -371,7 +371,13 @@ interface StackContextOptions { interface TraceContextResult { text: string; - firstSignalKind: "trusted-source" | "untrusted-source" | "package" | "server" | "component" | null; + firstSignalKind: + | "trusted-source" + | "untrusted-source" + | "package" + | "server" + | "component" + | null; hasTrustedSource: boolean; } From 015d4e65f35b1d375b72a7cde8e223f28c5cbe4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 08:10:52 +0000 Subject: [PATCH 36/65] Respect hidden roots in preview text Co-authored-by: Aiden Bai --- .../react-grab/e2e/element-context.spec.ts | 29 +++++++++++++++++++ packages/react-grab/src/core/context.ts | 17 +++++++---- .../src/utils/get-preview-text-content.ts | 24 +++++++++++++-- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 30b4d5529..14a9654e0 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -215,5 +215,34 @@ test.describe("Element Context Fallback", () => { expect(clipboard).toContain('selector: [href="/docs/ci-and-prs/github-actions-setup"]'); 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"); + }); }); }); diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index cf6600428..f95621650 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -33,7 +33,10 @@ 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 { + getPreviewTextContent, + getPreviewTextContentResult, +} from "../utils/get-preview-text-content.js"; import { isInternalComponentName, isUsefulComponentName, @@ -742,7 +745,7 @@ export const getInlineHTMLPreview = (element: Element): string => { export const getHTMLPreview = (element: Element): string => { const tagName = getTagName(element); const attrsText = formatAttrsForPreview(element); - const previewText = getPreviewTextContent(element, tagName); + const previewText = getPreviewTextContentResult(element, tagName); const topElements: Array = []; const bottomElements: Array = []; @@ -765,12 +768,14 @@ export const getHTMLPreview = (element: Element): string => { let content = ""; const topElementsStr = formatChildElements(topElements); - if (topElementsStr && !previewText) content += `\n ${topElementsStr}`; - if (previewText.length > 0) { - content += `\n ${truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH)}`; + if (topElementsStr && previewText.source !== "descendant") content += `\n ${topElementsStr}`; + if (previewText.text.length > 0) { + content += `\n ${truncateString(previewText.text, PREVIEW_TEXT_MAX_LENGTH)}`; } const bottomElementsStr = formatChildElements(bottomElements); - if (bottomElementsStr && !previewText) content += `\n ${bottomElementsStr}`; + if (bottomElementsStr && previewText.source !== "descendant") { + content += `\n ${bottomElementsStr}`; + } if (content.length > 0) { return `<${tagName}${attrsText}>${content}\n`; diff --git a/packages/react-grab/src/utils/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts index 51b2f8995..58663d582 100644 --- a/packages/react-grab/src/utils/get-preview-text-content.ts +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -1,5 +1,10 @@ import { PREVIEW_TEXT_TAGS } from "../constants.js"; +export interface PreviewTextContent { + text: string; + source: "direct" | "descendant" | null; +} + const SKIPPED_TEXT_TAGS = new Set(["script", "style", "template", "noscript"]); const collapseTextContent = (text: string): string => text.replace(/\s+/g, " ").trim(); @@ -38,13 +43,26 @@ const collectDescendantText = (node: Node, parts: string[]): void => { } }; -export const getPreviewTextContent = (element: Element, tagName: string): string => { +export const getPreviewTextContentResult = ( + element: Element, + tagName: string, +): PreviewTextContent => { + if (shouldSkipElementText(element)) return { text: "", source: null }; + const directText = getDirectTextContent(element); - if (directText || !PREVIEW_TEXT_TAGS.has(tagName)) return directText; + if (directText) return { text: directText, source: "direct" }; + if (!PREVIEW_TEXT_TAGS.has(tagName)) return { text: "", source: null }; const parts: string[] = []; for (const childNode of element.childNodes) { collectDescendantText(childNode, parts); } - return collapseTextContent(parts.join(" ")); + const descendantText = collapseTextContent(parts.join(" ")); + return { + text: descendantText, + source: descendantText ? "descendant" : null, + }; }; + +export const getPreviewTextContent = (element: Element, tagName: string): string => + getPreviewTextContentResult(element, tagName).text; From e648c5535ab1f11941ae4f93713abfc1e5e8ef5a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 08:22:03 +0000 Subject: [PATCH 37/65] Keep preview text formatting compact Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 17 +++++-------- .../src/utils/get-preview-text-content.ts | 24 ++++--------------- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index f95621650..cf6600428 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -33,10 +33,7 @@ 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, - getPreviewTextContentResult, -} from "../utils/get-preview-text-content.js"; +import { getPreviewTextContent } from "../utils/get-preview-text-content.js"; import { isInternalComponentName, isUsefulComponentName, @@ -745,7 +742,7 @@ export const getInlineHTMLPreview = (element: Element): string => { export const getHTMLPreview = (element: Element): string => { const tagName = getTagName(element); const attrsText = formatAttrsForPreview(element); - const previewText = getPreviewTextContentResult(element, tagName); + const previewText = getPreviewTextContent(element, tagName); const topElements: Array = []; const bottomElements: Array = []; @@ -768,14 +765,12 @@ export const getHTMLPreview = (element: Element): string => { let content = ""; const topElementsStr = formatChildElements(topElements); - if (topElementsStr && previewText.source !== "descendant") content += `\n ${topElementsStr}`; - if (previewText.text.length > 0) { - content += `\n ${truncateString(previewText.text, 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 && previewText.source !== "descendant") { - content += `\n ${bottomElementsStr}`; - } + if (bottomElementsStr && !previewText) content += `\n ${bottomElementsStr}`; if (content.length > 0) { return `<${tagName}${attrsText}>${content}\n`; diff --git a/packages/react-grab/src/utils/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts index 58663d582..25596ee5d 100644 --- a/packages/react-grab/src/utils/get-preview-text-content.ts +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -1,10 +1,5 @@ import { PREVIEW_TEXT_TAGS } from "../constants.js"; -export interface PreviewTextContent { - text: string; - source: "direct" | "descendant" | null; -} - const SKIPPED_TEXT_TAGS = new Set(["script", "style", "template", "noscript"]); const collapseTextContent = (text: string): string => text.replace(/\s+/g, " ").trim(); @@ -43,26 +38,15 @@ const collectDescendantText = (node: Node, parts: string[]): void => { } }; -export const getPreviewTextContentResult = ( - element: Element, - tagName: string, -): PreviewTextContent => { - if (shouldSkipElementText(element)) return { text: "", source: null }; +export const getPreviewTextContent = (element: Element, tagName: string): string => { + if (shouldSkipElementText(element)) return ""; const directText = getDirectTextContent(element); - if (directText) return { text: directText, source: "direct" }; - if (!PREVIEW_TEXT_TAGS.has(tagName)) return { text: "", source: null }; + if (directText || !PREVIEW_TEXT_TAGS.has(tagName)) return directText; const parts: string[] = []; for (const childNode of element.childNodes) { collectDescendantText(childNode, parts); } - const descendantText = collapseTextContent(parts.join(" ")); - return { - text: descendantText, - source: descendantText ? "descendant" : null, - }; + return collapseTextContent(parts.join(" ")); }; - -export const getPreviewTextContent = (element: Element, tagName: string): string => - getPreviewTextContentResult(element, tagName).text; From 73fea8596f875c401a9a0b2443da38aed071771f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 08:31:21 +0000 Subject: [PATCH 38/65] Include mixed inline preview text Co-authored-by: Aiden Bai --- .../react-grab/e2e/element-context.spec.ts | 31 +++++++++++++++++++ .../src/utils/get-preview-text-content.ts | 3 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 14a9654e0..3a77e223a 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -244,5 +244,36 @@ test.describe("Element Context Fallback", () => { 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/utils/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts index 25596ee5d..ef492ef09 100644 --- a/packages/react-grab/src/utils/get-preview-text-content.ts +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -42,7 +42,8 @@ export const getPreviewTextContent = (element: Element, tagName: string): string if (shouldSkipElementText(element)) return ""; const directText = getDirectTextContent(element); - if (directText || !PREVIEW_TEXT_TAGS.has(tagName)) return directText; + if (!PREVIEW_TEXT_TAGS.has(tagName)) return directText; + if (directText && element.children.length === 0) return directText; const parts: string[] = []; for (const childNode of element.childNodes) { From 17c3cff4d9d8a391658680fb26d84b7d507c01ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 09:14:46 +0000 Subject: [PATCH 39/65] Simplify selector hint trace heuristic Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 68 ++++++++++--------------- 1 file changed, 27 insertions(+), 41 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index cf6600428..bf6e16119 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -371,14 +371,7 @@ interface StackContextOptions { interface TraceContextResult { text: string; - firstSignalKind: - | "trusted-source" - | "untrusted-source" - | "package" - | "server" - | "component" - | null; - hasTrustedSource: boolean; + shouldAppendSelectorHint: boolean; } const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { @@ -396,17 +389,6 @@ const hasFormattableFrames = (stack: StackFrame[] | null): boolean => { const isTrustedSourcePath = (filePath: string | null | undefined): boolean => Boolean(filePath && isSourceFile(filePath) && !parsePackageName(filePath)); -const getSourceSignalKind = (filePath: string): TraceContextResult["firstSignalKind"] => - isTrustedSourcePath(filePath) ? "trusted-source" : "untrusted-source"; - -const shouldAppendSelectorHint = (traceContext: TraceContextResult): boolean => { - if (!traceContext.text) return true; - if (traceContext.firstSignalKind === "package" || traceContext.firstSignalKind === "server") { - return true; - } - return !traceContext.hasTrustedSource; -}; - const formatSelectorContextLine = (element: Element): string => { const selector = createElementSelector(element); return `\n selector: ${truncateString(selector, CONTEXT_SELECTOR_MAX_LENGTH_CHARS)}`; @@ -475,29 +457,33 @@ const formatStackContext = ( const lines: string[] = []; let previousLibraryPackage: string | null = null; let didDedupeLeadingComponent = false; - let firstSignalKind: TraceContextResult["firstSignalKind"] = null; let hasTrustedSource = false; - - const recordSignal = (signalKind: TraceContextResult["firstSignalKind"]) => { - firstSignalKind ??= signalKind; - if (signalKind === "trusted-source") hasTrustedSource = true; - }; + let startsWithLowSignalContext = false; const emit = ( line: string, libraryPackage: string | null, - signalKind: TraceContextResult["firstSignalKind"], + options: { isTrustedSource?: boolean; isLowSignal?: boolean } = {}, ) => { + if (lines.length === 0 && options.isLowSignal) { + startsWithLowSignalContext = true; + } + if (options.isTrustedSource) { + hasTrustedSource = true; + } lines.push(line); previousLibraryPackage = libraryPackage; - recordSignal(signalKind); }; if (leadingSource) { + const isTrustedSource = isTrustedSourcePath(leadingSource.filePath); emit( formatSourceContextLine(leadingSource, isNextProject), null, - getSourceSignalKind(leadingSource.filePath), + { + isTrustedSource, + isLowSignal: !isTrustedSource, + }, ); } @@ -528,7 +514,7 @@ const formatStackContext = ( emit( `\n in ${componentName ?? ""} (${tag})`, libraryPackage, - libraryPackage ? "package" : "server", + { isLowSignal: true }, ); continue; } @@ -537,12 +523,13 @@ const formatStackContext = ( emit( libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`, libraryPackage, - libraryPackage ? "package" : "component", + { isLowSignal: Boolean(libraryPackage) }, ); continue; } if (resolvedSource) { + const isTrustedSource = isTrustedSourcePath(resolvedSource); emit( formatSourceContextLine( { @@ -554,15 +541,17 @@ const formatStackContext = ( isNextProject, ), null, - getSourceSignalKind(resolvedSource), + { + isTrustedSource, + isLowSignal: !isTrustedSource, + }, ); } } return { text: lines.join(""), - firstSignalKind, - hasTrustedSource, + shouldAppendSelectorHint: startsWithLowSignalContext || !hasTrustedSource, }; }; @@ -579,11 +568,10 @@ const getTraceContext = async ( } if (leadingSource) { - const signalKind = getSourceSignalKind(leadingSource.filePath); + const isTrustedSource = isTrustedSourcePath(leadingSource.filePath); return { text: formatSourceContextLine(leadingSource, isNextProjectRuntime()), - firstSignalKind: signalKind, - hasTrustedSource: signalKind === "trusted-source", + shouldAppendSelectorHint: !isTrustedSource, }; } @@ -591,15 +579,13 @@ const getTraceContext = async ( if (componentNames.length > 0) { return { text: componentNames.map((name) => `\n in ${name}`).join(""), - firstSignalKind: "component", - hasTrustedSource: false, + shouldAppendSelectorHint: true, }; } return { text: "", - firstSignalKind: null, - hasTrustedSource: false, + shouldAppendSelectorHint: true, }; }; @@ -612,7 +598,7 @@ export const getStackContext = async ( }; const getSelectorContext = (element: Element, traceContext: TraceContextResult): string => - shouldAppendSelectorHint(traceContext) ? formatSelectorContextLine(element) : ""; + traceContext.shouldAppendSelectorHint ? formatSelectorContextLine(element) : ""; export const getElementReferenceContext = async ( element: Element, From b2addcfdd46d6a444fab07948dbae197a45c952c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 09:15:51 +0000 Subject: [PATCH 40/65] Format simplified trace heuristic Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index bf6e16119..2feab3267 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -477,14 +477,10 @@ const formatStackContext = ( if (leadingSource) { const isTrustedSource = isTrustedSourcePath(leadingSource.filePath); - emit( - formatSourceContextLine(leadingSource, isNextProject), - null, - { - isTrustedSource, - isLowSignal: !isTrustedSource, - }, - ); + emit(formatSourceContextLine(leadingSource, isNextProject), null, { + isTrustedSource, + isLowSignal: !isTrustedSource, + }); } for (const frame of stack) { @@ -511,11 +507,9 @@ const formatStackContext = ( if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; - emit( - `\n in ${componentName ?? ""} (${tag})`, - libraryPackage, - { isLowSignal: true }, - ); + emit(`\n in ${componentName ?? ""} (${tag})`, libraryPackage, { + isLowSignal: true, + }); continue; } From 39e0b55d054b491f6b3a0daa65c34b0e28e7d2be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 7 Jun 2026 09:29:21 +0000 Subject: [PATCH 41/65] Minimize preview text policy changes Co-authored-by: Aiden Bai --- packages/react-grab/src/constants.ts | 31 ------------------- packages/react-grab/src/core/context.ts | 3 +- .../src/utils/get-preview-text-content.ts | 5 ++- 3 files changed, 3 insertions(+), 36 deletions(-) diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 2df35df02..efaf6384d 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -72,37 +72,6 @@ export const LABEL_GAP_PX = 4; export const PREVIEW_TEXT_MAX_LENGTH = 100; export const PREVIEW_ATTR_VALUE_MAX_LENGTH = 15; export const PREVIEW_MAX_ATTRS = 3; -export const CONTEXT_SELECTOR_MAX_LENGTH_CHARS = 160; -export const PREVIEW_TEXT_TAGS = new Set([ - "a", - "abbr", - "b", - "button", - "caption", - "cite", - "code", - "dd", - "dt", - "em", - "figcaption", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "label", - "legend", - "li", - "p", - "pre", - "small", - "span", - "strong", - "summary", - "td", - "th", -]); export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [ "id", "class", diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 2feab3267..015d86fbc 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -23,7 +23,6 @@ import { PREVIEW_IDENTIFYING_ATTRS, SYMBOLICATION_TIMEOUT_MS, DEFAULT_MAX_CONTEXT_LINES, - CONTEXT_SELECTOR_MAX_LENGTH_CHARS, } from "../constants.js"; import { getTagName } from "../utils/get-tag-name.js"; import { truncateString } from "../utils/truncate-string.js"; @@ -391,7 +390,7 @@ const isTrustedSourcePath = (filePath: string | null | undefined): boolean => const formatSelectorContextLine = (element: Element): string => { const selector = createElementSelector(element); - return `\n selector: ${truncateString(selector, CONTEXT_SELECTOR_MAX_LENGTH_CHARS)}`; + return `\n selector: ${selector}`; }; const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => { diff --git a/packages/react-grab/src/utils/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts index ef492ef09..74c0c89f3 100644 --- a/packages/react-grab/src/utils/get-preview-text-content.ts +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -1,5 +1,4 @@ -import { PREVIEW_TEXT_TAGS } from "../constants.js"; - +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(); @@ -42,7 +41,7 @@ export const getPreviewTextContent = (element: Element, tagName: string): string if (shouldSkipElementText(element)) return ""; const directText = getDirectTextContent(element); - if (!PREVIEW_TEXT_TAGS.has(tagName)) return directText; + if (!DESCENDANT_TEXT_TAGS.has(tagName)) return directText; if (directText && element.children.length === 0) return directText; const parts: string[] = []; From d1b5fe04f5e18b163a087099a434912604e02213 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 02:39:32 -0700 Subject: [PATCH 42/65] Update formatStackContext tests for TraceContextResult return Folding in #461 changed formatStackContext to return a typed trace context ({ text, shouldAppendSelectorHint }); assert against .text. --- packages/react-grab/tests/context.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index c6187f5f4..5d01a701b 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -122,10 +122,10 @@ describe("formatStackContext", () => { { fileName: "src/app/page.tsx", functionName: "Page" }, ]); - expect(result).toContain("in Button"); - expect(result).not.toContain("button.tsx"); - expect(result).toContain("in Page"); - expect(result).toContain("app/page.tsx"); + expect(result.text).toContain("in Button"); + expect(result.text).not.toContain("button.tsx"); + expect(result.text).toContain("in Page"); + expect(result.text).toContain("app/page.tsx"); }); it("omits anonymous UI-component frames that carry no name", () => { @@ -134,7 +134,7 @@ describe("formatStackContext", () => { { fileName: "src/app/page.tsx", functionName: "Page" }, ]); - expect(result).not.toContain("button.tsx"); - expect(result).toContain("in Page"); + expect(result.text).not.toContain("button.tsx"); + expect(result.text).toContain("in Page"); }); }); From 990bbe72e88f62b003d83b6859d42c6d8137a749 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 03:35:49 -0700 Subject: [PATCH 43/65] Unify source-trust check through classifySourcePath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isTrustedSourcePath re-derived "is this app code" with its own isSourceFile + parsePackageName conditions, which diverged from the canonical classifier: it used the wrong package detector (missing relative scoped packages), ignored sourceOptions, and treated ignored components/ui wrappers as trusted — so the selector hint never fired for those low-signal resolutions. Route trust through classifySourcePath so resolution and the selector-hint heuristic share one definition, and drop the now-always-true recompute in the app-source frame branch. Add unit coverage for the ignored-leading-source selector-hint case. --- packages/react-grab/src/core/context.ts | 22 +++++++++++++--------- packages/react-grab/tests/context.test.ts | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index a85ea827f..5772f31aa 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -3,7 +3,6 @@ import { getSource, formatOwnerStack, hasDebugStack, - isSourceFile, parseStack, type StackFrame, } from "bippy/source"; @@ -30,7 +29,6 @@ import { getNextBasePath } from "../utils/get-next-base-path.js"; import { normalizeFilePath } from "../utils/normalize-file-path.js"; import { safeDecodeURIComponent } from "../utils/safe-decode-uri-component.js"; import { classifySourcePath, type SourcePathClassification } from "../utils/source-frame-policy.js"; -import { parsePackageName } from "../utils/parse-package-name.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"; @@ -435,8 +433,13 @@ interface TraceContextResult { shouldAppendSelectorHint: boolean; } -const isTrustedSourcePath = (filePath: string | null | undefined): boolean => - Boolean(filePath && isSourceFile(filePath) && !parsePackageName(filePath)); +// A source is trusted when it resolves to editable app code (not a third-party +// package and not an ignored UI wrapper). Reuse the canonical classifier so the +// selector-hint heuristic and source resolution share one notion of "app code". +const isTrustedSourcePath = ( + sourceFileName: string | null | undefined, + sourceOptions?: SourceOptions, +): boolean => classifySourcePath(sourceFileName, sourceOptions).kind === "app-source"; const formatSelectorContextLine = (element: Element): string => { const selector = createElementSelector(element); @@ -543,8 +546,9 @@ const formatStackFrameLine = ( return { text: `\n in ${libraryPackage}`, isTrustedSource: false, isLowSignal: true }; } + // resolvedSource is only set for app-source frames, so it is trusted by + // definition — no need to re-classify here. if (resolvedSource) { - const isTrustedSource = isTrustedSourcePath(resolvedSource); return { text: formatSourceContextLine( { @@ -556,8 +560,8 @@ const formatStackFrameLine = ( }, isNextProject, ), - isTrustedSource, - isLowSignal: !isTrustedSource, + isTrustedSource: true, + isLowSignal: false, }; } @@ -588,7 +592,7 @@ export const formatStackContext = ( }; if (leadingSource) { - const isTrustedSource = isTrustedSourcePath(leadingSource.filePath); + const isTrustedSource = isTrustedSourcePath(leadingSource.sourceFileName, options.sourceOptions); emit({ text: formatSourceContextLine(leadingSource, isNextProject), isTrustedSource, @@ -669,7 +673,7 @@ const getTraceContext = async ( } if (leadingSource) { - const isTrustedSource = isTrustedSourcePath(leadingSource.filePath); + const isTrustedSource = isTrustedSourcePath(leadingSource.sourceFileName, options.sourceOptions); return { text: formatSourceContextLine(leadingSource, isNextProjectRuntime()), shouldAppendSelectorHint: !isTrustedSource, diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index 5d01a701b..eddf9a968 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -137,4 +137,22 @@ describe("formatStackContext", () => { expect(result.text).not.toContain("button.tsx"); expect(result.text).toContain("in Page"); }); + + it("does not request a selector hint for a trusted app leading source", () => { + const result = formatStackContext([], {}, fiberSource); + + expect(result.shouldAppendSelectorHint).toBe(false); + }); + + it("requests a selector hint for an ignored components/ui leading source", () => { + const result = formatStackContext([], {}, { + filePath: "/src/components/ui/button.tsx", + lineNumber: 1, + columnNumber: 1, + componentName: "Button", + sourceFileName: "src/components/ui/button.tsx", + }); + + expect(result.shouldAppendSelectorHint).toBe(true); + }); }); From 18967aa6172f6d61765e23be0d3477257a2f2097 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 03:44:05 -0700 Subject: [PATCH 44/65] Remove unreachable getFallbackContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getTraceContext always sets shouldAppendSelectorHint on empty-text paths, so selectorContext is non-empty whenever traceContext.text is empty — the formatElementInfo fallback branch was never reachable. --- packages/react-grab/src/core/context.ts | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 5772f31aa..53655d079 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -724,28 +724,7 @@ export const formatElementInfo = async ( const html = getHTMLPreview(resolvedElement); const traceContext = await getTraceContext(resolvedElement, options); const selectorContext = getSelectorContext(resolvedElement, traceContext); - - if (traceContext.text || selectorContext) { - return `${html}${traceContext.text}${selectorContext}`; - } - - return getFallbackContext(resolvedElement); -}; - -const getFallbackContext = (element: Element): string => { - if (!(element instanceof HTMLElement)) { - return getInlineHTMLPreview(element); - } - - const tagName = getTagName(element); - const attrsText = formatAttrsForPreview(element); - const previewText = getPreviewTextContent(element, tagName); - const truncatedText = truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH); - - if (truncatedText.length > 0) { - return `<${tagName}${attrsText}>\n ${truncatedText}\n`; - } - return `<${tagName}${attrsText} />`; + return `${html}${traceContext.text}${selectorContext}`; }; const truncateAttrValue = (value: string): string => From 2f8c80162c2b01feed9e405d29722f10de7e2a9a Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 03:47:28 -0700 Subject: [PATCH 45/65] Move preview text tag sets to constants Keep preview policy data alongside the existing PREVIEW_* sets rather than inline in the util, matching the codebase convention. --- packages/react-grab/src/constants.ts | 5 +++++ .../react-grab/src/utils/get-preview-text-content.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index efaf6384d..3cd0f16f0 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -105,6 +105,11 @@ export const PREVIEW_IDENTIFYING_ATTRS = new Set([ "open", ]); +// Only these tags pull text from descendants for previews; others use direct +// text only so structural containers stay compact. +export const PREVIEW_DESCENDANT_TEXT_TAGS = new Set(["a", "code", "pre"]); +export const PREVIEW_SKIPPED_TEXT_TAGS = new Set(["script", "style", "template", "noscript"]); + export const MODIFIER_KEYS: readonly string[] = ["Meta", "Control", "Shift", "Alt"]; export const ARROW_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]); diff --git a/packages/react-grab/src/utils/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts index 74c0c89f3..985d3e893 100644 --- a/packages/react-grab/src/utils/get-preview-text-content.ts +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -1,5 +1,7 @@ -const DESCENDANT_TEXT_TAGS = new Set(["a", "code", "pre"]); -const SKIPPED_TEXT_TAGS = new Set(["script", "style", "template", "noscript"]); +import { + PREVIEW_DESCENDANT_TEXT_TAGS, + PREVIEW_SKIPPED_TEXT_TAGS, +} from "../constants.js"; const collapseTextContent = (text: string): string => text.replace(/\s+/g, " ").trim(); @@ -19,7 +21,7 @@ const getDirectTextContent = (element: Element): string => { 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()); + return PREVIEW_SKIPPED_TEXT_TAGS.has(element.tagName.toLowerCase()); }; const collectDescendantText = (node: Node, parts: string[]): void => { @@ -41,7 +43,7 @@ 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 (!PREVIEW_DESCENDANT_TEXT_TAGS.has(tagName)) return directText; if (directText && element.children.length === 0) return directText; const parts: string[] = []; From 7b046e29b04fc92f8e1e2e32f215caff22643419 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 03:54:04 -0700 Subject: [PATCH 46/65] fix --- packages/grab/README.md | 3 ++- packages/react-grab/README.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/grab/README.md b/packages/grab/README.md index 2ed99ed8d..6c2cb9a9d 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -28,7 +28,8 @@ React Grab turns a browser selection into source context your agent can use: The copied context includes the selected element and its component stack with source locations: ```txt -[Forgot your password? in LoginForm (at components/login-form.tsx:46:19)] +Forgot your password? + in LoginForm (at components/login-form.tsx:46:19) ``` ## Manual Installation diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index 836896efc..f09e8d941 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -28,7 +28,8 @@ React Grab turns a browser selection into source context your agent can use: The copied context includes the selected element and its component stack with source locations: ```txt -[Forgot your password? in LoginForm (at components/login-form.tsx:46:19)] +Forgot your password? + in LoginForm (at components/login-form.tsx:46:19) ``` ## Manual Installation From b5d59cd78ef4d47bef60410e6bd89f59aea98811 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 04:15:36 -0700 Subject: [PATCH 47/65] Dig past low-signal frames for a real app source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trace budget counted every frame, so a stack whose top frames are all packages/wrappers/server could exhaust DEFAULT_MAX_CONTEXT_LINES before reaching a deeper app-source frame — emitting only junk context plus a selector hint. Keep scanning past the soft budget while every emitted line is still low-signal (capped at MAX_TRACE_CONTEXT_LINES) so one trusted app frame gets surfaced when it exists. --- packages/react-grab/src/constants.ts | 5 +++++ packages/react-grab/src/core/context.ts | 8 +++++++- packages/react-grab/tests/context.test.ts | 12 ++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 3cd0f16f0..af7f626e6 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -20,6 +20,11 @@ export const INPUT_FOCUS_ACTIVATION_DELAY_MS = 400; export const INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS = 600; export const DEFAULT_KEY_HOLD_DURATION_MS = 100; export const DEFAULT_MAX_CONTEXT_LINES = 3; +// When the first DEFAULT_MAX_CONTEXT_LINES frames are all low-signal (packages, +// server, UI wrappers), keep scanning deeper for one trusted app frame instead +// of stopping with junk context — capped here so a pathological all-package +// stack can't balloon the copied reference. +export const MAX_TRACE_CONTEXT_LINES = 6; export const SYMBOLICATION_TIMEOUT_MS = 5000; export const MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS = 200; export const FINDER_TIMEOUT_MS = 200; diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 53655d079..dd67b2b33 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -22,6 +22,7 @@ import { PREVIEW_IDENTIFYING_ATTRS, SYMBOLICATION_TIMEOUT_MS, DEFAULT_MAX_CONTEXT_LINES, + MAX_TRACE_CONTEXT_LINES, } from "../constants.js"; import { getTagName } from "../utils/get-tag-name.js"; import { truncateString } from "../utils/truncate-string.js"; @@ -574,6 +575,7 @@ export const formatStackContext = ( leadingSource: ResolvedSource | null = null, ): TraceContextResult => { const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options; + const hardMaxLines = Math.max(maxLines, MAX_TRACE_CONTEXT_LINES); const isNextProject = isNextProjectRuntime(); const lines: string[] = []; let previousLibraryFrameKey: string | null = null; @@ -601,7 +603,11 @@ export const formatStackContext = ( } for (const frame of stack) { - if (lines.length >= maxLines) break; + // Once the soft budget is full, keep going only while every line so far is + // low-signal — this digs past junk frames to surface one real app frame — + // and never beyond the hard cap. + if (lines.length >= hardMaxLines) break; + if (lines.length >= maxLines && hasTrustedSource) break; const sourcePath = classifySourcePath(frame.fileName, options.sourceOptions); diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index eddf9a968..1e44c18e3 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -138,6 +138,18 @@ describe("formatStackContext", () => { expect(result.text).toContain("in Page"); }); + it("digs past low-signal package frames to surface a deeper app source", () => { + const result = formatStackContext([ + { fileName: "node_modules/react-tabs/dist/index.js", functionName: "Tabs" }, + { fileName: "node_modules/@radix-ui/react-dialog/dist/index.js", functionName: "Dialog" }, + { fileName: "node_modules/framer-motion/dist/index.js", functionName: "Motion" }, + { fileName: "src/app/page.tsx", functionName: "Page" }, + ]); + + expect(result.text).toContain("app/page.tsx"); + expect(result.text).toContain("in Page"); + }); + it("does not request a selector hint for a trusted app leading source", () => { const result = formatStackContext([], {}, fiberSource); From ce4a72cf8c918188b624346d9acc38a6a5d88034 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 04:16:37 -0700 Subject: [PATCH 48/65] Trim comments that restate code Drop the descendant-tag-set comment and the redundant first sentence of the trusted-source note; keep only the non-obvious rationale. --- packages/react-grab/src/constants.ts | 7 +------ packages/react-grab/src/core/context.ts | 5 ++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index af7f626e6..16e0d1034 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -20,10 +20,7 @@ export const INPUT_FOCUS_ACTIVATION_DELAY_MS = 400; export const INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS = 600; export const DEFAULT_KEY_HOLD_DURATION_MS = 100; export const DEFAULT_MAX_CONTEXT_LINES = 3; -// When the first DEFAULT_MAX_CONTEXT_LINES frames are all low-signal (packages, -// server, UI wrappers), keep scanning deeper for one trusted app frame instead -// of stopping with junk context — capped here so a pathological all-package -// stack can't balloon the copied reference. +// Hard cap when digging past low-signal frames for a trusted app source. export const MAX_TRACE_CONTEXT_LINES = 6; export const SYMBOLICATION_TIMEOUT_MS = 5000; export const MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS = 200; @@ -110,8 +107,6 @@ export const PREVIEW_IDENTIFYING_ATTRS = new Set([ "open", ]); -// Only these tags pull text from descendants for previews; others use direct -// text only so structural containers stay compact. export const PREVIEW_DESCENDANT_TEXT_TAGS = new Set(["a", "code", "pre"]); export const PREVIEW_SKIPPED_TEXT_TAGS = new Set(["script", "style", "template", "noscript"]); diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index dd67b2b33..f06e45087 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -434,9 +434,8 @@ interface TraceContextResult { shouldAppendSelectorHint: boolean; } -// A source is trusted when it resolves to editable app code (not a third-party -// package and not an ignored UI wrapper). Reuse the canonical classifier so the -// selector-hint heuristic and source resolution share one notion of "app code". +// Reuse the canonical classifier so the selector-hint heuristic and source +// resolution share one notion of "app code". const isTrustedSourcePath = ( sourceFileName: string | null | undefined, sourceOptions?: SourceOptions, From 34c89d444e5ff162aea1da89fdcb895bcd566105 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 04:17:00 -0700 Subject: [PATCH 49/65] Raise trace context hard cap to 20 frames --- packages/react-grab/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 16e0d1034..49930e80f 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -21,7 +21,7 @@ export const INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS = 600; export const DEFAULT_KEY_HOLD_DURATION_MS = 100; export const DEFAULT_MAX_CONTEXT_LINES = 3; // Hard cap when digging past low-signal frames for a trusted app source. -export const MAX_TRACE_CONTEXT_LINES = 6; +export const MAX_TRACE_CONTEXT_LINES = 20; export const SYMBOLICATION_TIMEOUT_MS = 5000; export const MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS = 200; export const FINDER_TIMEOUT_MS = 200; From b3ca773e873a4ba6f1e649be9d3df34b3fc09fa6 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 04:22:33 -0700 Subject: [PATCH 50/65] Retry fiber-source resolution after a null result getCachedFiberSource cached the first promise per element forever, so a grab that resolved before the fiber's source metadata was attached could never recover. Evict null resolutions so a later grab retries, while still deduping concurrent in-flight lookups. --- packages/react-grab/src/core/context.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index f06e45087..e6c615681 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -330,7 +330,12 @@ const getCachedFiberSource = (element: Element): Promise const cached = fiberSourceCache.get(resolvedElement); if (cached) return cached; - const promise = getFiberSource(resolvedElement); + // Evict null resolutions so a later grab can retry once the fiber's source + // metadata is attached, while still deduping concurrent in-flight lookups. + const promise = getFiberSource(resolvedElement).then((source) => { + if (!source) fiberSourceCache.delete(resolvedElement); + return source; + }); fiberSourceCache.set(resolvedElement, promise); return promise; }; @@ -644,9 +649,10 @@ export const formatStackContext = ( // The snippet leads with the app fiber's own JSX location when available. // Otherwise it surfaces an ignored fallback (e.g. components/ui): those frames // are dropped from the stack body but still back the selection metadata, so -// without this the copied snippet would omit the resolved path. App and package -// frames need no such handling — formatStackContext already renders them inline, -// and resolveSource only returns the ignored kind when no app source exists. +// without this the copied snippet would omit the resolved path. App frames need +// no such handling — formatStackContext renders them inline. Package-only +// sources are intentionally not promoted here: surfacing a node_modules path as +// the resolved location is exactly what this resolution is meant to avoid. const resolveLeadingSource = async ( element: Element, sourceOptions?: SourceOptions, From c20d702f5ed791e773442879f3c7b5783ec17c87 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 04:40:21 -0700 Subject: [PATCH 51/65] Remove configurable source.ignorePaths option ignorePaths was the only field of SourceOptions, so drop the whole source/sourceOptions plumbing (public Options.source, the API option on getElementContext, and the threading through resolution/copy/snippet). The built-in components/ui ignore stays; classification now depends solely on fileName, so it is always cacheable. --- packages/react-grab/src/core/context.ts | 50 ++++++------------- packages/react-grab/src/core/copy.ts | 18 ++----- packages/react-grab/src/core/index.tsx | 15 ++---- .../react-grab/src/core/plugin-registry.ts | 4 -- packages/react-grab/src/primitives.ts | 12 ++--- packages/react-grab/src/types.ts | 5 -- .../react-grab/src/utils/generate-snippet.ts | 2 - .../src/utils/get-script-options.ts | 8 --- .../src/utils/source-frame-policy.ts | 45 ++++------------- .../tests/source-frame-policy.test.ts | 37 -------------- 10 files changed, 38 insertions(+), 158 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index e6c615681..063da52fa 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -37,7 +37,6 @@ import { isInternalComponentName, isUsefulComponentName, } from "../utils/is-useful-component-name.js"; -import type { SourceOptions } from "../types.js"; let cachedIsNextProject: boolean | undefined; @@ -342,10 +341,9 @@ const getCachedFiberSource = (element: Element): Promise const getApplicationFiberSource = async ( element: Element, - sourceOptions?: SourceOptions, ): Promise => { const source = await getCachedFiberSource(element); - if (!source || classifySourcePath(source.sourceFileName, sourceOptions).kind !== "app-source") { + if (!source || classifySourcePath(source.sourceFileName).kind !== "app-source") { return null; } return source; @@ -362,13 +360,9 @@ const resolveStackFrameSource = (frame: StackFrame | null | undefined): Resolved }; }; -interface ResolveSourceOptions { - sourceOptions?: SourceOptions; -} - // Source candidates are resolved in descending preference: a frame the user -// actually owns beats one they configured to ignore, which beats third-party -// package code. Within each kind the fiber's own source wins over stack frames. +// actually owns beats an ignored UI wrapper, which beats third-party package +// code. Within each kind the fiber's own source wins over stack frames. const RESOLVABLE_SOURCE_KINDS = ["app-source", "ignored-app-source", "package-source"] as const; type ResolvableSourceKind = (typeof RESOLVABLE_SOURCE_KINDS)[number]; @@ -388,12 +382,9 @@ export const selectResolvedSource = ( return null; }; -export const resolveSource = async ( - element: Element, - options: ResolveSourceOptions = {}, -): Promise => { +export const resolveSource = async (element: Element): Promise => { const fiberSource = await getCachedFiberSource(element); - const fiberSourceKind = classifySourcePath(fiberSource?.sourceFileName, options.sourceOptions).kind; + const fiberSourceKind = classifySourcePath(fiberSource?.sourceFileName).kind; if (fiberSourceKind === "app-source") return fiberSource; const framesByKind: FramesBySourceKind = { @@ -402,7 +393,7 @@ export const resolveSource = async ( "package-source": [], }; for (const frame of (await getStack(element)) ?? []) { - const { kind } = classifySourcePath(frame.fileName, options.sourceOptions); + const { kind } = classifySourcePath(frame.fileName); if (kind !== "unknown") framesByKind[kind].push(frame); } @@ -431,7 +422,6 @@ export const getComponentDisplayName = (element: Element): string | null => { interface StackContextOptions { maxLines?: number; - sourceOptions?: SourceOptions; } interface TraceContextResult { @@ -441,10 +431,8 @@ interface TraceContextResult { // Reuse the canonical classifier so the selector-hint heuristic and source // resolution share one notion of "app code". -const isTrustedSourcePath = ( - sourceFileName: string | null | undefined, - sourceOptions?: SourceOptions, -): boolean => classifySourcePath(sourceFileName, sourceOptions).kind === "app-source"; +const isTrustedSourcePath = (sourceFileName: string | null | undefined): boolean => + classifySourcePath(sourceFileName).kind === "app-source"; const formatSelectorContextLine = (element: Element): string => { const selector = createElementSelector(element); @@ -598,7 +586,7 @@ export const formatStackContext = ( }; if (leadingSource) { - const isTrustedSource = isTrustedSourcePath(leadingSource.sourceFileName, options.sourceOptions); + const isTrustedSource = isTrustedSourcePath(leadingSource.sourceFileName); emit({ text: formatSourceContextLine(leadingSource, isNextProject), isTrustedSource, @@ -613,7 +601,7 @@ export const formatStackContext = ( if (lines.length >= hardMaxLines) break; if (lines.length >= maxLines && hasTrustedSource) break; - const sourcePath = classifySourcePath(frame.fileName, options.sourceOptions); + const sourcePath = classifySourcePath(frame.fileName); const componentName = toSourceComponentName(frame.functionName); const libraryFrameKey = sourcePath.packageName @@ -653,18 +641,12 @@ export const formatStackContext = ( // no such handling — formatStackContext renders them inline. Package-only // sources are intentionally not promoted here: surfacing a node_modules path as // the resolved location is exactly what this resolution is meant to avoid. -const resolveLeadingSource = async ( - element: Element, - sourceOptions?: SourceOptions, -): Promise => { - const appFiberSource = await getApplicationFiberSource(element, sourceOptions); +const resolveLeadingSource = async (element: Element): Promise => { + const appFiberSource = await getApplicationFiberSource(element); if (appFiberSource) return appFiberSource; - const resolved = await resolveSource(element, { sourceOptions }); - if ( - resolved && - classifySourcePath(resolved.sourceFileName, sourceOptions).kind === "ignored-app-source" - ) { + const resolved = await resolveSource(element); + if (resolved && classifySourcePath(resolved.sourceFileName).kind === "ignored-app-source") { return resolved; } return null; @@ -675,7 +657,7 @@ const getTraceContext = async ( options: StackContextOptions = {}, ): Promise => { const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; - const leadingSource = await resolveLeadingSource(element, options.sourceOptions); + const leadingSource = await resolveLeadingSource(element); const stack = await getStack(element); if (stack) { @@ -684,7 +666,7 @@ const getTraceContext = async ( } if (leadingSource) { - const isTrustedSource = isTrustedSourcePath(leadingSource.sourceFileName, options.sourceOptions); + const isTrustedSource = isTrustedSourcePath(leadingSource.sourceFileName); return { text: formatSourceContextLine(leadingSource, isNextProjectRuntime()), shouldAppendSelectorHint: !isTrustedSource, diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index d50b8a5cf..b23ea4543 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,12 +1,10 @@ import { getElementReferenceContext } from "./context.js"; import { copyContent } from "../utils/copy-content.js"; import { normalizeError } from "../utils/normalize-error.js"; -import type { SourceOptions } from "../types.js"; interface CopyFlowOptions { getContent?: (elements: Element[]) => Promise | string; componentName?: string; - sourceOptions?: SourceOptions; } interface CopyFlowHooks { @@ -17,18 +15,12 @@ interface CopyFlowHooks { onCopyError: (error: Error) => void; } -const formatElementReference = async ( - element: Element, - sourceOptions: SourceOptions | undefined, -): Promise => - `[${(await getElementReferenceContext(element, { sourceOptions })).replace(/\n\s+/g, " ")}]`; +const formatElementReference = async (element: Element): Promise => + `[${(await getElementReferenceContext(element)).replace(/\n\s+/g, " ")}]`; -const buildClipboardPayload = async ( - elements: Element[], - sourceOptions: SourceOptions | undefined, -): Promise => { +const buildClipboardPayload = async (elements: Element[]): Promise => { const references = await Promise.all( - elements.map((element) => formatElementReference(element, sourceOptions)), + elements.map((element) => formatElementReference(element)), ); const uniqueReferences = [...new Set(references)]; return uniqueReferences.length > 0 ? uniqueReferences.join("\n") : null; @@ -48,7 +40,7 @@ export const runCopyFlow = async ( try { const rawContent = options.getContent ? await options.getContent(elements) - : await buildClipboardPayload(elements, options.sourceOptions); + : await buildClipboardPayload(elements); if (rawContent?.trim()) { const transformedContent = await hooks.transformCopyContent(rawContent, elements); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 9a1426671..7c25a303c 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -546,10 +546,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }; const notifyElementsSelected = async (elements: Element[]): Promise => { - const sourceOptions = pluginRegistry.store.options.source; const elementsPayload = await Promise.all( elements.map(async (element) => { - const source = await resolveSource(element, { sourceOptions }); + const source = await resolveSource(element); let componentName = source?.componentName ?? null; const filePath = source?.filePath; const lineNumber = source?.lineNumber ?? undefined; @@ -645,7 +644,6 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { { getContent: pluginRegistry.store.options.getContent, componentName: elementName, - sourceOptions: pluginRegistry.store.options.source, }, { onBeforeCopy: pluginRegistry.hooks.onBeforeCopy, @@ -1147,7 +1145,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return; } - resolveSource(element, { sourceOptions: pluginRegistry.store.options.source }) + resolveSource(element) .then((source) => { if (selectionSourceRequestVersion !== currentVersion) return; if (!source) { @@ -3133,7 +3131,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { () => store.contextMenuElement, async (element) => { if (!element) return null; - return resolveSource(element, { sourceOptions: pluginRegistry.store.options.source }); + return resolveSource(element); }, ); @@ -3677,9 +3675,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }, copyElement: copyElementAPI, getSource: async (element: Element): Promise => { - const source = await resolveSource(element, { - sourceOptions: pluginRegistry.store.options.source, - }); + const source = await resolveSource(element); if (!source) return null; return { filePath: source.filePath, @@ -3687,8 +3683,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { componentName: source.componentName, }; }, - getStackContext: (element: Element) => - getStackContext(element, { sourceOptions: pluginRegistry.store.options.source }), + getStackContext: (element: Element) => getStackContext(element), getState: (): ReactGrabState => ({ isActive: isActivated(), isDragging: isDragging(), diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index c8f89ad62..690affc03 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -16,7 +16,6 @@ import type { ActivationMode, ActivationKey, SettableOptions, - SourceOptions, AgentContext, ActionContext, } from "../types.js"; @@ -35,7 +34,6 @@ interface OptionsState { activationKey: ActivationKey | undefined; getContent: ((elements: Element[]) => Promise | string) | undefined; freezeReactUpdates: boolean; - source: SourceOptions | undefined; } const DEFAULT_OPTIONS: OptionsState = { @@ -45,7 +43,6 @@ const DEFAULT_OPTIONS: OptionsState = { activationKey: undefined, getContent: undefined, freezeReactUpdates: true, - source: undefined, }; interface PluginStoreState { @@ -109,7 +106,6 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { "activationKey", "getContent", "freezeReactUpdates", - "source", ]; const setOptions = (optionUpdates: SettableOptions) => { diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts index 743a8d496..7cd000676 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -20,7 +20,6 @@ import { import { Fiber, getFiberFromHostInstance } from "bippy"; import type { StackFrame } from "bippy/source"; export type { StackFrame }; -import type { SourceOptions } from "./types.js"; import { createElementSelector } from "./utils/create-element-selector.js"; import { extractElementCss, disposeBaselineStyles } from "./utils/extract-element-css.js"; import { requestOpenFile } from "./utils/open-file.js"; @@ -40,10 +39,6 @@ export interface ReactGrabElementContext { styles: string; } -export interface ReactGrabElementContextOptions { - sourceOptions?: SourceOptions; -} - /** * Gathers comprehensive context for a DOM element — the same context * React Grab copies to the clipboard, plus structured source location @@ -58,14 +53,13 @@ export interface ReactGrabElementContextOptions { */ export const getElementContext = async ( element: Element, - options: ReactGrabElementContextOptions = {}, ): Promise => { const [snippet, source, stack] = await Promise.all([ - formatElementInfo(element, { sourceOptions: options.sourceOptions }), - resolveSource(element, { sourceOptions: options.sourceOptions }), + formatElementInfo(element), + resolveSource(element), getStack(element).then((result) => result ?? []), ]); - const stackString = await getStackContext(element, { sourceOptions: options.sourceOptions }); + const stackString = await getStackContext(element); const htmlPreview = getHTMLPreview(element); const componentName = getComponentDisplayName(element); const fiber = getFiberFromHostInstance(element); diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 609659e74..0c59662a1 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -349,7 +349,6 @@ export interface Options { allowActivationInsideInput?: boolean; activationKey?: ActivationKey; getContent?: (elements: Element[]) => Promise | string; - source?: SourceOptions; /** * Whether to freeze React state updates while React Grab is active. * This prevents UI changes from interfering with element selection. @@ -364,10 +363,6 @@ export interface Options { telemetry?: boolean; } -export interface SourceOptions { - ignorePaths?: Array; -} - export interface SettableOptions extends Options { enabled?: never; telemetry?: never; diff --git a/packages/react-grab/src/utils/generate-snippet.ts b/packages/react-grab/src/utils/generate-snippet.ts index efface1b7..0c94b670e 100644 --- a/packages/react-grab/src/utils/generate-snippet.ts +++ b/packages/react-grab/src/utils/generate-snippet.ts @@ -1,9 +1,7 @@ import { formatElementInfo } from "../core/context.js"; -import type { SourceOptions } from "../types.js"; interface GenerateSnippetOptions { maxLines?: number; - sourceOptions?: SourceOptions; } export const generateSnippet = async ( diff --git a/packages/react-grab/src/utils/get-script-options.ts b/packages/react-grab/src/utils/get-script-options.ts index 795423470..e0a267786 100644 --- a/packages/react-grab/src/utils/get-script-options.ts +++ b/packages/react-grab/src/utils/get-script-options.ts @@ -27,14 +27,6 @@ const parseOptionsFromJson = (rawValue: unknown): Partial | null => { if (typeof rawValue.freezeReactUpdates === "boolean") { parsedOptions.freezeReactUpdates = rawValue.freezeReactUpdates; } - if (isObjectRecord(rawValue.source) && Array.isArray(rawValue.source.ignorePaths)) { - const ignorePaths = rawValue.source.ignorePaths.filter( - (ignorePath) => typeof ignorePath === "string", - ); - if (ignorePaths.length > 0) { - parsedOptions.source = { ignorePaths }; - } - } if (typeof rawValue.telemetry === "boolean") { parsedOptions.telemetry = rawValue.telemetry; } diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index ab8db37ab..32d3c4883 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -1,15 +1,12 @@ import { isSourceFile } from "bippy/source"; -import type { SourceOptions } from "../types.js"; import { normalizeFilePath } from "./normalize-file-path.js"; import { resolvePackageName } from "./parse-package-name.js"; -// Always ignored, in addition to any user-supplied ignorePaths: design-system -// wrappers (e.g. shadcn's components/ui) are rarely the file a user wants to -// edit, so grabs resolve to the consuming app source instead. +// design-system wrappers (e.g. shadcn's components/ui) are rarely the file a +// user wants to edit, so grabs resolve to the consuming app source instead. const DEFAULT_IGNORED_SOURCE_PATHS: readonly string[] = ["components/ui"]; const PATH_SEPARATOR_PATTERN = /[/\\]/; -// Keyed by fileName and only used when no custom ignorePaths are set; valid only -// while default classification depends solely on fileName. +// Keyed by fileName; classification depends solely on fileName. const defaultClassificationCache = new Map(); export interface SourcePathClassification { @@ -41,54 +38,30 @@ const matchesPathSegments = (pathSegments: string[], patternSegments: string[]): return false; }; -const matchesIgnoredSourcePath = (fileName: string, sourceOptions?: SourceOptions): boolean => { - const normalizedPath = normalizeFilePath(fileName); - const pathSegments = splitPathSegments(normalizedPath); +const matchesIgnoredSourcePath = (fileName: string): boolean => { + const pathSegments = splitPathSegments(fileName); for (const ignoredSourcePathSegments of DEFAULT_IGNORED_SOURCE_PATH_SEGMENTS) { if (matchesPathSegments(pathSegments, ignoredSourcePathSegments)) return true; } - const customIgnoredSourcePaths = sourceOptions?.ignorePaths; - if (!customIgnoredSourcePaths) return false; - - for (const ignoredSourcePath of customIgnoredSourcePaths) { - if (typeof ignoredSourcePath === "string") { - if (matchesPathSegments(pathSegments, splitPathSegments(ignoredSourcePath))) return true; - continue; - } - - ignoredSourcePath.lastIndex = 0; - if (ignoredSourcePath.test(normalizedPath)) return true; - } - return false; }; export const classifySourcePath = ( fileName: string | null | undefined, - sourceOptions?: SourceOptions, ): SourcePathClassification => { - if (!fileName || sourceOptions?.ignorePaths?.length) { - return classifySourcePathUncached(fileName, sourceOptions); - } + if (!fileName) return { kind: "unknown", packageName: null }; const cachedClassification = defaultClassificationCache.get(fileName); if (cachedClassification) return cachedClassification; - const classification = classifySourcePathUncached(fileName, sourceOptions); + const classification = classifySourcePathUncached(fileName); defaultClassificationCache.set(fileName, classification); return classification; }; -const classifySourcePathUncached = ( - fileName: string | null | undefined, - sourceOptions?: SourceOptions, -): SourcePathClassification => { - if (!fileName) { - return { kind: "unknown", packageName: null }; - } - +const classifySourcePathUncached = (fileName: string): SourcePathClassification => { const packageName = resolvePackageName(fileName); if (packageName) { return { kind: "package-source", packageName }; @@ -98,7 +71,7 @@ const classifySourcePathUncached = ( return { kind: "unknown", packageName: null }; } - if (matchesIgnoredSourcePath(fileName, sourceOptions)) { + if (matchesIgnoredSourcePath(fileName)) { return { kind: "ignored-app-source", packageName: null }; } diff --git a/packages/react-grab/tests/source-frame-policy.test.ts b/packages/react-grab/tests/source-frame-policy.test.ts index 7e02ff5d5..124a56fbc 100644 --- a/packages/react-grab/tests/source-frame-policy.test.ts +++ b/packages/react-grab/tests/source-frame-policy.test.ts @@ -43,39 +43,6 @@ describe("classifySourcePath", () => { }); }); - it("classifies configured ignored source paths", () => { - expect( - classifySourcePath("src/design-system/button.tsx", { - ignorePaths: ["design-system", /\/packages\/ui\//], - }), - ).toEqual({ - kind: "ignored-app-source", - packageName: null, - }); - expect( - classifySourcePath("/workspace/packages/ui/src/button.tsx", { - ignorePaths: ["design-system", /\/packages\/ui\//], - }), - ).toEqual({ - kind: "ignored-app-source", - packageName: null, - }); - }); - - it("resets stateful configured ignored source regexes", () => { - const statefulIgnorePath = /\/packages\/ui\//g; - const sourceOptions = { ignorePaths: [statefulIgnorePath] }; - - expect(classifySourcePath("/workspace/packages/ui/src/button.tsx", sourceOptions)).toEqual({ - kind: "ignored-app-source", - packageName: null, - }); - expect(classifySourcePath("/workspace/packages/ui/src/dialog.tsx", sourceOptions)).toEqual({ - kind: "ignored-app-source", - packageName: null, - }); - }); - it("does not ignore nearby app source paths", () => { expect(classifySourcePath("../@company/app/src/tabs.tsx")).toEqual({ kind: "app-source", @@ -85,9 +52,5 @@ describe("classifySourcePath", () => { kind: "app-source", packageName: null, }); - expect(classifySourcePath("src/design-system-ui/button.tsx", { ignorePaths: ["ui"] })).toEqual({ - kind: "app-source", - packageName: null, - }); }); }); From ec3fa39b51cdf6df459759e905881e189d2ade34 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sun, 7 Jun 2026 04:41:28 -0700 Subject: [PATCH 52/65] Clarify hardMaxLines uses max not min A reviewer suggested Math.min, which would collapse the hard cap onto the soft budget and disable digging past low-signal frames. Document why max is required. --- packages/react-grab/src/core/context.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 063da52fa..48229c440 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -567,6 +567,8 @@ export const formatStackContext = ( leadingSource: ResolvedSource | null = null, ): TraceContextResult => { const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options; + // max, not min: the dig-past-low-signal cap must sit above the soft budget + // (min would collapse it onto maxLines and disable digging entirely). const hardMaxLines = Math.max(maxLines, MAX_TRACE_CONTEXT_LINES); const isNextProject = isNextProjectRuntime(); const lines: string[] = []; From 2fe43bf48bd20c1a78b2043599671cf58ea23b08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 06:08:15 +0000 Subject: [PATCH 53/65] Include stack metadata in custom grab payload Co-authored-by: Aiden Bai --- packages/cli/src/utils/clipboard.ts | 15 ++++ packages/react-grab/e2e/selection.spec.ts | 4 + packages/react-grab/src/core/copy.ts | 79 ++++++++++++++++--- packages/react-grab/src/utils/copy-content.ts | 19 +++++ 4 files changed, 105 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/utils/clipboard.ts b/packages/cli/src/utils/clipboard.ts index 51f5d4aee..b9efd53c9 100644 --- a/packages/cli/src/utils/clipboard.ts +++ b/packages/cli/src/utils/clipboard.ts @@ -37,6 +37,21 @@ interface GrabEntry { componentName?: string; content?: string; commentText?: string; + source?: { + filePath: string; + lineNumber: number | null; + columnNumber?: number | null; + componentName: string | null; + } | null; + stackContext?: string; + frames?: Array<{ + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; + isServer?: boolean; + isSymbolicated?: boolean; + }>; } interface GrabRecord { diff --git a/packages/react-grab/e2e/selection.spec.ts b/packages/react-grab/e2e/selection.spec.ts index 11079edff..af75321dc 100644 --- a/packages/react-grab/e2e/selection.spec.ts +++ b/packages/react-grab/e2e/selection.spec.ts @@ -57,6 +57,10 @@ test.describe("Element Selection", () => { expect(clipboardMetadata.content).toContain("Todo List"); expect(clipboardMetadata.entries).toHaveLength(1); expect(clipboardMetadata.entries[0].content).toContain("Todo List"); + expect(clipboardMetadata.entries[0]).toHaveProperty("source"); + expect(clipboardMetadata.entries[0].stackContext).toContain("TodoList"); + expect(Array.isArray(clipboardMetadata.entries[0].frames)).toBe(true); + expect(clipboardMetadata.entries[0].frames.length).toBeGreaterThan(0); }); // PR #349 ("fix: keep page interactive while grabbing") intentionally diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index b23ea4543..9213a2c70 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,6 +1,8 @@ -import { getElementReferenceContext } from "./context.js"; +import { getElementReferenceContext, getStack, resolveSource } from "./context.js"; import { copyContent } from "../utils/copy-content.js"; import { normalizeError } from "../utils/normalize-error.js"; +import { getTagName } from "../utils/get-tag-name.js"; +import type { StackFrame } from "bippy/source"; interface CopyFlowOptions { getContent?: (elements: Element[]) => Promise | string; @@ -15,15 +17,64 @@ interface CopyFlowHooks { onCopyError: (error: Error) => void; } -const formatElementReference = async (element: Element): Promise => - `[${(await getElementReferenceContext(element)).replace(/\n\s+/g, " ")}]`; +interface CopyPayloadEntry { + tagName?: string; + componentName?: string; + content: string; + source?: { + filePath: string; + lineNumber: number | null; + columnNumber: number | null; + componentName: string | null; + } | null; + stackContext?: string; + frames?: Array<{ + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; + isServer?: boolean; + isSymbolicated?: boolean; + }>; +} + +interface CopyPayload { + content: string; + entries: CopyPayloadEntry[]; +} + +const formatStackFramePayload = ( + frame: StackFrame, +): NonNullable[number] => ({ + functionName: frame.functionName, + fileName: frame.fileName, + lineNumber: frame.lineNumber, + columnNumber: frame.columnNumber, + isServer: frame.isServer, + isSymbolicated: frame.isSymbolicated, +}); + +const buildElementPayloadEntry = async (element: Element): Promise => { + const [referenceContext, source, stack] = await Promise.all([ + getElementReferenceContext(element), + resolveSource(element), + getStack(element), + ]); + const inlineReference = `[${referenceContext.replace(/\n\s+/g, " ")}]`; + return { + tagName: getTagName(element), + componentName: source?.componentName ?? undefined, + content: inlineReference, + source, + stackContext: referenceContext, + frames: (stack ?? []).map(formatStackFramePayload), + }; +}; -const buildClipboardPayload = async (elements: Element[]): Promise => { - const references = await Promise.all( - elements.map((element) => formatElementReference(element)), - ); - const uniqueReferences = [...new Set(references)]; - return uniqueReferences.length > 0 ? uniqueReferences.join("\n") : null; +const buildClipboardPayload = async (elements: Element[]): Promise => { + const entries = await Promise.all(elements.map(buildElementPayloadEntry)); + const uniqueReferences = [...new Set(entries.map((entry) => entry.content))]; + return uniqueReferences.length > 0 ? { content: uniqueReferences.join("\n"), entries } : null; }; export const runCopyFlow = async ( @@ -38,16 +89,20 @@ export const runCopyFlow = async ( let finalContent = ""; try { - const rawContent = options.getContent - ? await options.getContent(elements) + const payload = options.getContent + ? { content: await options.getContent(elements), entries: undefined } : await buildClipboardPayload(elements); + const rawContent = payload?.content; if (rawContent?.trim()) { const transformedContent = await hooks.transformCopyContent(rawContent, elements); finalContent = prependedPrompt ? `${prependedPrompt}\n${transformedContent}` : transformedContent; - didCopy = copyContent(finalContent, { componentName: options.componentName }); + didCopy = copyContent(finalContent, { + componentName: options.componentName, + entries: payload.entries, + }); } } catch (error) { hooks.onCopyError(normalizeError(error)); diff --git a/packages/react-grab/src/utils/copy-content.ts b/packages/react-grab/src/utils/copy-content.ts index 190d11315..fe5743735 100644 --- a/packages/react-grab/src/utils/copy-content.ts +++ b/packages/react-grab/src/utils/copy-content.ts @@ -7,6 +7,25 @@ interface ReactGrabEntry { componentName?: string; content: string; commentText?: string; + source?: ReactGrabSourceInfo | null; + stackContext?: string; + frames?: ReactGrabStackFrame[]; +} + +interface ReactGrabSourceInfo { + filePath: string; + lineNumber: number | null; + columnNumber: number | null; + componentName: string | null; +} + +interface ReactGrabStackFrame { + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; + isServer?: boolean; + isSymbolicated?: boolean; } interface CopyContentOptions { From 67f4f22e3662efb395a562b456927740474884c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 06:09:31 +0000 Subject: [PATCH 54/65] Fix nullable custom payload entries Co-authored-by: Aiden Bai --- packages/react-grab/src/core/copy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 9213a2c70..8ae94d9a5 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -101,7 +101,7 @@ export const runCopyFlow = async ( : transformedContent; didCopy = copyContent(finalContent, { componentName: options.componentName, - entries: payload.entries, + entries: payload?.entries, }); } } catch (error) { From a34d4573a88d08bd509464ff4e5e547391311839 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 06:19:39 +0000 Subject: [PATCH 55/65] Clarify source path fields Co-authored-by: Aiden Bai --- packages/react-grab/src/constants.ts | 1 - packages/react-grab/src/core/context.ts | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 49930e80f..00123ddd1 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -20,7 +20,6 @@ export const INPUT_FOCUS_ACTIVATION_DELAY_MS = 400; export const INPUT_TEXT_SELECTION_ACTIVATION_DELAY_MS = 600; export const DEFAULT_KEY_HOLD_DURATION_MS = 100; export const DEFAULT_MAX_CONTEXT_LINES = 3; -// Hard cap when digging past low-signal frames for a trusted app source. export const MAX_TRACE_CONTEXT_LINES = 20; export const SYMBOLICATION_TIMEOUT_MS = 5000; export const MIN_HOLD_FOR_ACTIVATION_AFTER_COPY_MS = 200; diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 48229c440..ec8c89818 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -287,7 +287,8 @@ interface ResolvedSource { componentName: string | null; // Raw path used for classification. normalizeFilePath strips a leading "./", // which scoped-package detection relies on, so classification must see the - // unnormalized form (matching how stack frames are classified from fileName). + // unnormalized form: filePath="components/ui/button.tsx", + // sourceFileName="./@radix-ui/react-button/src/button.tsx". sourceFileName: string; } @@ -339,9 +340,7 @@ const getCachedFiberSource = (element: Element): Promise return promise; }; -const getApplicationFiberSource = async ( - element: Element, -): Promise => { +const getApplicationFiberSource = async (element: Element): Promise => { const source = await getCachedFiberSource(element); if (!source || classifySourcePath(source.sourceFileName).kind !== "app-source") { return null; From 5a9c26813eed5efd61faec419a0e7f92c82069e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 06:28:44 +0000 Subject: [PATCH 56/65] Clarify file path and file name comment Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index ec8c89818..768d637f3 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -285,10 +285,10 @@ interface ResolvedSource { lineNumber: number | null; columnNumber: number | null; componentName: string | null; - // Raw path used for classification. normalizeFilePath strips a leading "./", - // which scoped-package detection relies on, so classification must see the - // unnormalized form: filePath="components/ui/button.tsx", - // sourceFileName="./@radix-ui/react-button/src/button.tsx". + // filePath is the normalized path we display/open. sourceFileName preserves + // React/StackFrame.fileName for classification. Example: + // filePath="@radix-ui/react-button/src/button.tsx" + // sourceFileName="./@radix-ui/react-button/src/button.tsx" sourceFileName: string; } From 74536dd15ec2a14904b710e016da9b06361aba54 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 06:34:40 +0000 Subject: [PATCH 57/65] Avoid selector hints after app source Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 6 +----- packages/react-grab/tests/context.test.ts | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 768d637f3..30253ecf9 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -574,12 +574,8 @@ export const formatStackContext = ( let previousLibraryFrameKey: string | null = null; let didDedupeLeadingComponent = false; let hasTrustedSource = false; - let startsWithLowSignalContext = false; const emit = (line: StackFrameLine) => { - if (lines.length === 0 && line.isLowSignal) { - startsWithLowSignalContext = true; - } if (line.isTrustedSource) { hasTrustedSource = true; } @@ -631,7 +627,7 @@ export const formatStackContext = ( return { text: lines.join(""), - shouldAppendSelectorHint: startsWithLowSignalContext || !hasTrustedSource, + shouldAppendSelectorHint: !hasTrustedSource, }; }; diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index 1e44c18e3..29e2ce3ef 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -148,6 +148,7 @@ describe("formatStackContext", () => { expect(result.text).toContain("app/page.tsx"); expect(result.text).toContain("in Page"); + expect(result.shouldAppendSelectorHint).toBe(false); }); it("does not request a selector hint for a trusted app leading source", () => { @@ -157,13 +158,17 @@ describe("formatStackContext", () => { }); it("requests a selector hint for an ignored components/ui leading source", () => { - const result = formatStackContext([], {}, { - filePath: "/src/components/ui/button.tsx", - lineNumber: 1, - columnNumber: 1, - componentName: "Button", - sourceFileName: "src/components/ui/button.tsx", - }); + const result = formatStackContext( + [], + {}, + { + filePath: "/src/components/ui/button.tsx", + lineNumber: 1, + columnNumber: 1, + componentName: "Button", + sourceFileName: "src/components/ui/button.tsx", + }, + ); expect(result.shouldAppendSelectorHint).toBe(true); }); From d42fdea921a64e70cef49fac3e4c95fc1ef44428 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 06:44:36 +0000 Subject: [PATCH 58/65] Keep custom grab entries aligned Co-authored-by: Aiden Bai --- packages/react-grab/src/core/copy.ts | 35 ++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 8ae94d9a5..5ef8714e4 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -21,6 +21,7 @@ interface CopyPayloadEntry { tagName?: string; componentName?: string; content: string; + commentText?: string; source?: { filePath: string; lineNumber: number | null; @@ -72,9 +73,35 @@ const buildElementPayloadEntry = async (element: Element): Promise => { - const entries = await Promise.all(elements.map(buildElementPayloadEntry)); - const uniqueReferences = [...new Set(entries.map((entry) => entry.content))]; - return uniqueReferences.length > 0 ? { content: uniqueReferences.join("\n"), entries } : null; + const rawEntries = await Promise.all(elements.map(buildElementPayloadEntry)); + const entriesByContent = new Map(); + for (const entry of rawEntries) { + if (!entriesByContent.has(entry.content)) { + entriesByContent.set(entry.content, entry); + } + } + const entries = [...entriesByContent.values()]; + return entries.length > 0 + ? { content: entries.map((entry) => entry.content).join("\n"), entries } + : null; +}; + +const getMetadataEntries = ( + payload: CopyPayload | null, + rawContent: string, + finalContent: string, + prependedPrompt: string | undefined, +): CopyPayloadEntry[] | undefined => { + if (!payload) return undefined; + if (finalContent === rawContent) return payload.entries; + if (payload.entries.length !== 1) return undefined; + return [ + { + ...payload.entries[0], + content: finalContent, + commentText: prependedPrompt, + }, + ]; }; export const runCopyFlow = async ( @@ -101,7 +128,7 @@ export const runCopyFlow = async ( : transformedContent; didCopy = copyContent(finalContent, { componentName: options.componentName, - entries: payload?.entries, + entries: getMetadataEntries(payload, rawContent, finalContent, prependedPrompt), }); } } catch (error) { From f8e25f0e955367c866ce1a4e62a012c65853b04c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 06:54:07 +0000 Subject: [PATCH 59/65] Allow custom copy payload without entries Co-authored-by: Aiden Bai --- packages/react-grab/src/core/copy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 5ef8714e4..546525c8b 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -41,7 +41,7 @@ interface CopyPayloadEntry { interface CopyPayload { content: string; - entries: CopyPayloadEntry[]; + entries?: CopyPayloadEntry[]; } const formatStackFramePayload = ( @@ -92,7 +92,7 @@ const getMetadataEntries = ( finalContent: string, prependedPrompt: string | undefined, ): CopyPayloadEntry[] | undefined => { - if (!payload) return undefined; + if (!payload?.entries) return undefined; if (finalContent === rawContent) return payload.entries; if (payload.entries.length !== 1) return undefined; return [ From 73cea2fc83e1af3bfbe8f5ff13825a6eacce0f43 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 9 Jun 2026 17:28:54 -0700 Subject: [PATCH 60/65] Simplify source-frame PR and split context.ts into focused modules Retain all PR functionality (source classification, low-signal trace digging, selector hints, descendant preview text) while cutting indirection: classify source paths once at construction, collapse the trusted/low-signal flag pair into one signal field, share one context composer, and revert noise churn. Split context.ts (820 -> ~475 lines) along clean seams into next-server-frames.ts and html-preview.ts, extract is-next-project-runtime.ts to fix a utils->core import inversion, and delete dead options, constants, and the CSS.escape polyfill. Fix two regressions found by adversarial review and pin them with mutation-verified e2e tests: getHTMLPreview kept child placeholders only for a/code/pre subsumption, and copy references preserve newlines inside attribute values. Revert the README example to match actual copy output. --- README.md | 3 +- apps/e2e-app/src/main.tsx | 4 +- packages/grab/README.md | 3 +- packages/react-grab/README.md | 3 +- .../react-grab/e2e/element-context.spec.ts | 67 +++ packages/react-grab/src/constants.ts | 1 - packages/react-grab/src/core/context.ts | 552 +++--------------- packages/react-grab/src/core/copy.ts | 2 +- packages/react-grab/src/core/html-preview.ts | 118 ++++ packages/react-grab/src/core/index.tsx | 18 +- .../react-grab/src/core/next-server-frames.ts | 170 ++++++ packages/react-grab/src/primitives.ts | 6 +- .../src/utils/create-element-selector.ts | 14 +- .../react-grab/src/utils/generate-snippet.ts | 15 +- .../src/utils/get-preview-text-content.ts | 47 +- .../src/utils/is-next-project-runtime.ts | 11 + packages/react-grab/src/utils/open-file.ts | 12 +- .../src/utils/parse-package-name.ts | 13 +- .../src/utils/source-frame-policy.ts | 62 +- packages/react-grab/tests/context.test.ts | 80 +-- .../tests/source-frame-policy.test.ts | 4 + skills/react-grab/SKILL.md | 6 +- 22 files changed, 563 insertions(+), 648 deletions(-) create mode 100644 packages/react-grab/src/core/html-preview.ts create mode 100644 packages/react-grab/src/core/next-server-frames.ts create mode 100644 packages/react-grab/src/utils/is-next-project-runtime.ts diff --git a/README.md b/README.md index f09e8d941..836896efc 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,7 @@ React Grab turns a browser selection into source context your agent can use: The copied context includes the selected element and its component stack with source locations: ```txt -Forgot your password? - in LoginForm (at components/login-form.tsx:46:19) +[Forgot your password? in LoginForm (at components/login-form.tsx:46:19)] ``` ## Manual Installation diff --git a/apps/e2e-app/src/main.tsx b/apps/e2e-app/src/main.tsx index d80e81435..93f1efefa 100644 --- a/apps/e2e-app/src/main.tsx +++ b/apps/e2e-app/src/main.tsx @@ -1,16 +1,18 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { init } from "react-grab"; +import { init, formatElementInfo } from "react-grab"; import "./index.css"; import App from "./App.tsx"; declare global { interface Window { initReactGrab: typeof init; + formatElementInfo: typeof formatElementInfo; } } window.initReactGrab = init; +window.formatElementInfo = formatElementInfo; createRoot(document.getElementById("root")!).render( diff --git a/packages/grab/README.md b/packages/grab/README.md index 6c2cb9a9d..2ed99ed8d 100644 --- a/packages/grab/README.md +++ b/packages/grab/README.md @@ -28,8 +28,7 @@ React Grab turns a browser selection into source context your agent can use: The copied context includes the selected element and its component stack with source locations: ```txt -Forgot your password? - in LoginForm (at components/login-form.tsx:46:19) +[Forgot your password? in LoginForm (at components/login-form.tsx:46:19)] ``` ## Manual Installation diff --git a/packages/react-grab/README.md b/packages/react-grab/README.md index f09e8d941..836896efc 100644 --- a/packages/react-grab/README.md +++ b/packages/react-grab/README.md @@ -28,8 +28,7 @@ React Grab turns a browser selection into source context your agent can use: The copied context includes the selected element and its component stack with source locations: ```txt -Forgot your password? - in LoginForm (at components/login-form.tsx:46:19) +[Forgot your password? in LoginForm (at components/login-form.tsx:46:19)] ``` ## Manual Installation diff --git a/packages/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index 3a77e223a..42a99b9eb 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -245,6 +245,73 @@ test.describe("Element Context Fallback", () => { expect(clipboard).not.toContain("Decorative Hidden Label"); }); + test("should keep child placeholders alongside direct text in element info", 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 card = document.createElement("div"); + card.id = "mixed-content-card"; + card.append("Hello "); + const detail = document.createElement("span"); + detail.textContent = "world"; + card.appendChild(detail); + + wrapper.appendChild(card); + document.body.appendChild(wrapper); + }); + + const elementInfo = await reactGrab.page.evaluate(() => { + const formatInfo = (window as { formatElementInfo?: (element: Element) => Promise }) + .formatElementInfo; + const element = document.querySelector("#mixed-content-card"); + if (!element || !formatInfo) return null; + return formatInfo(element); + }); + + expect(elementInfo).toContain("Hello"); + expect(elementInfo).toContain(""); + }); + + test("should preserve newlines inside attribute values in copied references", 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 saveButton = document.createElement("button"); + saveButton.id = "multiline-label-button"; + saveButton.setAttribute("aria-label", "Save\n draft"); + saveButton.textContent = "Save"; + + wrapper.appendChild(saveButton); + document.body.appendChild(wrapper); + }); + + const didCopy = await reactGrab.copyElementViaApi("#multiline-label-button"); + expect(didCopy).toBe(true); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain('aria-label="Save\n draft"'); + }); + test("should include nested text for mixed inline content", async ({ reactGrab }) => { await reactGrab.page.evaluate(() => { const wrapper = document.createElement("div"); diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 00123ddd1..b22698a35 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -72,7 +72,6 @@ export const ARROW_PANEL_OVERLAP_PX = 1; export const LABEL_GAP_PX = 4; export const PREVIEW_TEXT_MAX_LENGTH = 100; export const PREVIEW_ATTR_VALUE_MAX_LENGTH = 15; -export const PREVIEW_MAX_ATTRS = 3; export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [ "id", "class", diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 30253ecf9..1c86249e2 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -1,11 +1,4 @@ -import { - getOwnerStack, - getSource, - formatOwnerStack, - hasDebugStack, - parseStack, - type StackFrame, -} from "bippy/source"; +import { getOwnerStack, getSource, type StackFrame } from "bippy/source"; import { getFiberFromHostInstance, isInstrumentationActive, @@ -14,42 +7,18 @@ import { traverseFiber, type Fiber, } from "bippy"; -import { - PREVIEW_TEXT_MAX_LENGTH, - PREVIEW_ATTR_VALUE_MAX_LENGTH, - PREVIEW_MAX_ATTRS, - PREVIEW_PRIORITY_ATTRS, - PREVIEW_IDENTIFYING_ATTRS, - SYMBOLICATION_TIMEOUT_MS, - DEFAULT_MAX_CONTEXT_LINES, - MAX_TRACE_CONTEXT_LINES, -} from "../constants.js"; -import { getTagName } from "../utils/get-tag-name.js"; -import { truncateString } from "../utils/truncate-string.js"; -import { getNextBasePath } from "../utils/get-next-base-path.js"; +import { DEFAULT_MAX_CONTEXT_LINES, MAX_TRACE_CONTEXT_LINES } from "../constants.js"; import { normalizeFilePath } from "../utils/normalize-file-path.js"; -import { safeDecodeURIComponent } from "../utils/safe-decode-uri-component.js"; import { classifySourcePath, type SourcePathClassification } from "../utils/source-frame-policy.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 { isNextProjectRuntime } from "../utils/is-next-project-runtime.js"; +import { enrichServerFrameLocations, symbolicateServerFrames } from "./next-server-frames.js"; +import { getHTMLPreview, getInlineHTMLPreview } from "./html-preview.js"; import { isInternalComponentName, isUsefulComponentName, } from "../utils/is-useful-component-name.js"; -let cachedIsNextProject: boolean | undefined; - -export const isNextProjectRuntime = (shouldRevalidate?: boolean): boolean => { - if (shouldRevalidate) { - cachedIsNextProject = undefined; - } - cachedIsNextProject ??= - typeof document !== "undefined" && - Boolean(document.getElementById("__NEXT_DATA__") || document.querySelector("nextjs-portal")); - return cachedIsNextProject; -}; - const isSourceComponentName = (name: string): boolean => { if (name.length <= 1) return false; if (isInternalComponentName(name)) return false; @@ -61,168 +30,6 @@ const isSourceComponentName = (name: string): boolean => { const toSourceComponentName = (name: string | null | undefined): string | null => name && isSourceComponentName(name) ? name : null; -const SERVER_COMPONENT_URL_PREFIXES = ["about://React/", "rsc://React/"]; - -const isServerComponentUrl = (url: string): boolean => - SERVER_COMPONENT_URL_PREFIXES.some((prefix) => url.startsWith(prefix)); - -const devirtualizeServerUrl = (url: string): string => { - for (const prefix of SERVER_COMPONENT_URL_PREFIXES) { - if (!url.startsWith(prefix)) continue; - const environmentEndIndex = url.indexOf("/", prefix.length); - if (environmentEndIndex === -1) continue; - const pathStart = environmentEndIndex + 1; - const querySuffixIndex = url.lastIndexOf("?"); - const rawPath = - querySuffixIndex > pathStart ? url.slice(pathStart, querySuffixIndex) : url.slice(pathStart); - return safeDecodeURIComponent(rawPath); - } - return url; -}; - -interface NextJsOriginalFrame { - file: string | null; - line1: number | null; - column1: number | null; - ignored: boolean; -} - -interface NextJsFrameResult { - status: string; - value?: { originalStackFrame: NextJsOriginalFrame | null }; -} - -interface NextJsRequestFrame { - file: string; - methodName: string; - line1: number | null; - column1: number | null; - arguments: string[]; -} - -const symbolicateServerFrames = async (frames: StackFrame[]): Promise => { - const serverFrameIndices: number[] = []; - const requestFrames: NextJsRequestFrame[] = []; - - for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) { - const frame = frames[frameIndex]; - if (!frame.isServer || !frame.fileName) continue; - - serverFrameIndices.push(frameIndex); - requestFrames.push({ - file: devirtualizeServerUrl(frame.fileName), - methodName: frame.functionName ?? "", - line1: frame.lineNumber ?? null, - column1: frame.columnNumber ?? null, - arguments: [], - }); - } - - if (requestFrames.length === 0) return frames; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), SYMBOLICATION_TIMEOUT_MS); - - try { - // Next.js dev server (>=15.2) exposes a batched symbolication endpoint that - // resolves bundled/virtual stack frames back to original source locations via - // source maps. Server components produce virtual URLs like - // "rsc://React/Server/webpack-internal:///..." that have no real file on disk. - // We POST an array of frames and get back PromiseSettledResult[]. - // getNextBasePath() is required for apps deployed with a basePath. - const response = await fetch(`${getNextBasePath()}/__nextjs_original-stack-frames`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - frames: requestFrames, - isServer: true, - isEdgeServer: false, - isAppDirectory: true, - }), - signal: controller.signal, - }); - - if (!response.ok) return frames; - - const results = (await response.json()) as NextJsFrameResult[]; - const resolvedFrames = [...frames]; - - for (let resultIndex = 0; resultIndex < serverFrameIndices.length; resultIndex++) { - const result = results[resultIndex]; - if (result?.status !== "fulfilled") continue; - - const resolved = result.value?.originalStackFrame; - if (!resolved?.file || resolved.ignored) continue; - - const originalFrameIndex = serverFrameIndices[resultIndex]; - resolvedFrames[originalFrameIndex] = { - ...frames[originalFrameIndex], - fileName: resolved.file, - lineNumber: resolved.line1 ?? undefined, - columnNumber: resolved.column1 ?? undefined, - isSymbolicated: true, - }; - } - - return resolvedFrames; - } catch { - return frames; - } finally { - clearTimeout(timeout); - } -}; - -const extractServerFramesFromDebugStack = (rootFiber: Fiber): Map => { - const serverFramesByName = new Map(); - - traverseFiber( - rootFiber, - (currentFiber) => { - if (!hasDebugStack(currentFiber)) return false; - - const ownerStack = formatOwnerStack(currentFiber._debugStack.stack); - if (!ownerStack) return false; - - for (const frame of parseStack(ownerStack)) { - if (!frame.functionName || !frame.fileName) continue; - if (!isServerComponentUrl(frame.fileName)) continue; - if (serverFramesByName.has(frame.functionName)) continue; - - serverFramesByName.set(frame.functionName, { - ...frame, - isServer: true, - }); - } - return false; - }, - true, - ); - - return serverFramesByName; -}; - -const enrichServerFrameLocations = (rootFiber: Fiber, frames: StackFrame[]): StackFrame[] => { - const hasUnresolvedServerFrames = frames.some( - (frame) => frame.isServer && !frame.fileName && frame.functionName, - ); - if (!hasUnresolvedServerFrames) return frames; - - const serverFramesByName = extractServerFramesFromDebugStack(rootFiber); - if (serverFramesByName.size === 0) return frames; - - return frames.map((frame) => { - if (!frame.isServer || frame.fileName || !frame.functionName) return frame; - const resolved = serverFramesByName.get(frame.functionName); - if (!resolved) return frame; - return { - ...frame, - fileName: resolved.fileName, - lineNumber: resolved.lineNumber, - columnNumber: resolved.columnNumber, - }; - }); -}; - const findNearestFiberElement = (element: Element): Element => { if (!isInstrumentationActive()) return element; let current: Element | null = element; @@ -272,30 +79,26 @@ export const getNearestComponentName = async (element: Element): Promise { - const namedFrame = frames.find( - (frame) => frame.functionName && isSourceComponentName(frame.functionName), - ); + const namedFrame = frames.find((frame) => Boolean(toSourceComponentName(frame.functionName))); return namedFrame ?? frames[0] ?? null; }; @@ -304,6 +107,8 @@ const getSourceComponentName = (fiber: Fiber | undefined): string | null => { return toSourceComponentName(getDisplayName(fiber.type)); }; +// getSource reads React's own dev-only debug data, so it works without bippy +// instrumentation, but it can throw while parsing owner stacks. const getFiberSource = async (element: Element): Promise => { const fiber = getFiberFromHostInstance(findNearestFiberElement(element)); if (!fiber) return null; @@ -318,7 +123,9 @@ const getFiberSource = async (element: Element): Promise columnNumber: source.columnNumber ?? null, componentName: toSourceComponentName(source.functionName) ?? getSourceComponentName(fiber._debugOwner), - sourceFileName: source.fileName, + // Classify the raw path: normalizeFilePath strips the leading "./" that + // scoped-package detection relies on. + kind: classifySourcePath(source.fileName).kind, }; } catch { return null; @@ -340,86 +147,53 @@ const getCachedFiberSource = (element: Element): Promise return promise; }; -const getApplicationFiberSource = async (element: Element): Promise => { - const source = await getCachedFiberSource(element); - if (!source || classifySourcePath(source.sourceFileName).kind !== "app-source") { - return null; - } - return source; -}; - -const resolveStackFrameSource = (frame: StackFrame | null | undefined): ResolvedSource | null => { - if (!frame?.fileName) return null; +const resolveStackFrameSource = ( + frame: StackFrame, + kind: SourcePathClassification["kind"], +): ResolvedSource | null => { + if (!frame.fileName) return null; return { filePath: normalizeFilePath(frame.fileName), lineNumber: frame.lineNumber ?? null, columnNumber: frame.columnNumber ?? null, componentName: toSourceComponentName(frame.functionName), - sourceFileName: frame.fileName, + kind, }; }; -// Source candidates are resolved in descending preference: a frame the user -// actually owns beats an ignored UI wrapper, which beats third-party package -// code. Within each kind the fiber's own source wins over stack frames. -const RESOLVABLE_SOURCE_KINDS = ["app-source", "ignored-app-source", "package-source"] as const; - -type ResolvableSourceKind = (typeof RESOLVABLE_SOURCE_KINDS)[number]; - -export type FramesBySourceKind = Record; +const SOURCE_KIND_PREFERENCE_ORDER = [ + "app-source", + "ignored-app-source", + "package-source", +] as const; export const selectResolvedSource = ( fiberSource: ResolvedSource | null, - fiberSourceKind: SourcePathClassification["kind"], - framesByKind: FramesBySourceKind, + stack: StackFrame[], ): ResolvedSource | null => { - for (const kind of RESOLVABLE_SOURCE_KINDS) { - if (fiberSourceKind === kind) return fiberSource; - const frameSource = resolveStackFrameSource(pickSourceFrame(framesByKind[kind])); - if (frameSource) return frameSource; + for (const kind of SOURCE_KIND_PREFERENCE_ORDER) { + if (fiberSource?.kind === kind) return fiberSource; + const kindFrames = stack.filter((frame) => classifySourcePath(frame.fileName).kind === kind); + const frame = pickSourceFrame(kindFrames); + if (frame) { + const frameSource = resolveStackFrameSource(frame, kind); + if (frameSource) return frameSource; + } } return null; }; export const resolveSource = async (element: Element): Promise => { const fiberSource = await getCachedFiberSource(element); - const fiberSourceKind = classifySourcePath(fiberSource?.sourceFileName).kind; - if (fiberSourceKind === "app-source") return fiberSource; - - const framesByKind: FramesBySourceKind = { - "app-source": [], - "ignored-app-source": [], - "package-source": [], - }; - for (const frame of (await getStack(element)) ?? []) { - const { kind } = classifySourcePath(frame.fileName); - if (kind !== "unknown") framesByKind[kind].push(frame); - } + if (fiberSource?.kind === "app-source") return fiberSource; - return selectResolvedSource(fiberSource, fiberSourceKind, framesByKind); + return selectResolvedSource(fiberSource, (await getStack(element)) ?? []); }; -export const getComponentDisplayName = (element: Element): string | null => { - if (!isInstrumentationActive()) return null; - const resolvedElement = findNearestFiberElement(element); - const fiber = getFiberFromHostInstance(resolvedElement); - if (!fiber) return null; +export const getComponentDisplayName = (element: Element): string | null => + getComponentNamesFromFiber(findNearestFiberElement(element), 1)[0] ?? null; - let currentFiber = fiber.return; - while (currentFiber) { - if (isCompositeFiber(currentFiber)) { - const name = getDisplayName(currentFiber.type); - if (name && isUsefulComponentName(name)) { - return name; - } - } - currentFiber = currentFiber.return; - } - - return null; -}; - -interface StackContextOptions { +export interface StackContextOptions { maxLines?: number; } @@ -428,16 +202,6 @@ interface TraceContextResult { shouldAppendSelectorHint: boolean; } -// Reuse the canonical classifier so the selector-hint heuristic and source -// resolution share one notion of "app code". -const isTrustedSourcePath = (sourceFileName: string | null | undefined): boolean => - classifySourcePath(sourceFileName).kind === "app-source"; - -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); @@ -477,7 +241,7 @@ const formatContextFilePath = (filePath: string, isNextProject: boolean): string return normalizedPath; }; -const formatSourceContextLine = (source: ResolvedSource, isNextProject: boolean): string => { +const formatSourceContextLine = (source: SourceLocation, isNextProject: boolean): string => { const displayPath = formatContextFilePath(source.filePath, isNextProject); // HACK: bundlers like Vite produce unreliable line/column numbers from owner // stacks, so we only include them for Next.js where the dev server @@ -494,66 +258,53 @@ const formatSourceContextLine = (source: ResolvedSource, isNextProject: boolean) interface StackFrameLine { text: string; isTrustedSource: boolean; - isLowSignal: boolean; } -// Branches are ordered by specificity: a server component (no on-disk source) -// first, then any frame named by component but lacking a source file, then a -// bare third-party package, and finally a real app source location. Returns -// null when the frame carries nothing worth rendering. A line is low-signal when -// it cannot pin a trusted app source (server, package, or wrapper context), -// which higher-level formatters use to decide whether to append a selector hint. const formatStackFrameLine = ( frame: StackFrame, - sourcePath: SourcePathClassification, + sourceClassification: SourcePathClassification, componentName: string | null, isNextProject: boolean, ): StackFrameLine | null => { - const libraryPackage = sourcePath.packageName; + const libraryPackage = sourceClassification.packageName; // Only app-owned frames contribute a file path. Ignored UI-wrapper frames // render by component name (e.g. "in Button") so they stay as context without // surfacing a wrapper path that would compete with the resolved app source. - const resolvedSource = sourcePath.kind === "app-source" ? frame.fileName : null; + const appSourceFilePath = sourceClassification.kind === "app-source" ? frame.fileName : null; - if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { - const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; + if (frame.isServer && !appSourceFilePath && (componentName || !frame.functionName)) { + const serverTag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; return { - text: `\n in ${componentName ?? ""} (${tag})`, + text: `\n in ${componentName ?? ""} (${serverTag})`, isTrustedSource: false, - isLowSignal: true, }; } - if (!resolvedSource && componentName) { + if (!appSourceFilePath && componentName) { return { text: libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`, isTrustedSource: false, - isLowSignal: Boolean(libraryPackage), }; } if (libraryPackage) { - return { text: `\n in ${libraryPackage}`, isTrustedSource: false, isLowSignal: true }; + return { text: `\n in ${libraryPackage}`, isTrustedSource: false }; } - // resolvedSource is only set for app-source frames, so it is trusted by - // definition — no need to re-classify here. - if (resolvedSource) { + if (appSourceFilePath) { return { text: formatSourceContextLine( { componentName, - filePath: resolvedSource, + filePath: appSourceFilePath, lineNumber: frame.lineNumber ?? null, columnNumber: frame.columnNumber ?? null, - sourceFileName: resolvedSource, }, isNextProject, ), isTrustedSource: true, - isLowSignal: false, }; } @@ -583,26 +334,22 @@ export const formatStackContext = ( }; if (leadingSource) { - const isTrustedSource = isTrustedSourcePath(leadingSource.sourceFileName); emit({ text: formatSourceContextLine(leadingSource, isNextProject), - isTrustedSource, - isLowSignal: !isTrustedSource, + isTrustedSource: leadingSource.kind === "app-source", }); } for (const frame of stack) { - // Once the soft budget is full, keep going only while every line so far is - // low-signal — this digs past junk frames to surface one real app frame — - // and never beyond the hard cap. + // Past the soft budget, keep digging until a trusted app frame or the hard cap. if (lines.length >= hardMaxLines) break; if (lines.length >= maxLines && hasTrustedSource) break; - const sourcePath = classifySourcePath(frame.fileName); + const sourceClassification = classifySourcePath(frame.fileName); const componentName = toSourceComponentName(frame.functionName); - const libraryFrameKey = sourcePath.packageName - ? `${sourcePath.packageName}:${componentName ?? ""}:${frame.isServer ? "server" : "client"}` + const libraryFrameKey = sourceClassification.packageName + ? `${sourceClassification.packageName}:${componentName ?? ""}:${frame.isServer ? "server" : "client"}` : null; if (libraryFrameKey && libraryFrameKey === previousLibraryFrameKey) continue; @@ -618,7 +365,12 @@ export const formatStackContext = ( continue; } - const frameLine = formatStackFrameLine(frame, sourcePath, componentName, isNextProject); + const frameLine = formatStackFrameLine( + frame, + sourceClassification, + componentName, + isNextProject, + ); if (frameLine === null) continue; emit(frameLine); @@ -631,22 +383,16 @@ export const formatStackContext = ( }; }; -// The snippet leads with the app fiber's own JSX location when available. -// Otherwise it surfaces an ignored fallback (e.g. components/ui): those frames -// are dropped from the stack body but still back the selection metadata, so -// without this the copied snippet would omit the resolved path. App frames need -// no such handling — formatStackContext renders them inline. Package-only -// sources are intentionally not promoted here: surfacing a node_modules path as -// the resolved location is exactly what this resolution is meant to avoid. +// Ignored components/ui sources are promoted to the leading line because their +// frames are dropped from the stack body yet still back the selection metadata, +// so the copied snippet would otherwise omit the resolved path. Package-only +// sources are never promoted: surfacing node_modules paths is what this avoids. const resolveLeadingSource = async (element: Element): Promise => { - const appFiberSource = await getApplicationFiberSource(element); - if (appFiberSource) return appFiberSource; + const fiberSource = await getCachedFiberSource(element); + if (fiberSource?.kind === "app-source") return fiberSource; - const resolved = await resolveSource(element); - if (resolved && classifySourcePath(resolved.sourceFileName).kind === "ignored-app-source") { - return resolved; - } - return null; + const fallbackSource = selectResolvedSource(fiberSource, (await getStack(element)) ?? []); + return fallbackSource?.kind === "ignored-app-source" ? fallbackSource : null; }; const getTraceContext = async ( @@ -663,10 +409,9 @@ const getTraceContext = async ( } if (leadingSource) { - const isTrustedSource = isTrustedSourcePath(leadingSource.sourceFileName); return { text: formatSourceContextLine(leadingSource, isNextProjectRuntime()), - shouldAppendSelectorHint: !isTrustedSource, + shouldAppendSelectorHint: leadingSource.kind !== "app-source", }; } @@ -692,18 +437,24 @@ export const getStackContext = async ( return traceContext.text; }; -const getSelectorContext = (element: Element, traceContext: TraceContextResult): string => - traceContext.shouldAppendSelectorHint ? formatSelectorContextLine(element) : ""; +const composeElementContext = ( + element: Element, + htmlPreview: string, + traceContext: TraceContextResult, +): string => { + const selectorHint = traceContext.shouldAppendSelectorHint + ? `\n selector: ${createElementSelector(element)}` + : ""; + return `${htmlPreview}${traceContext.text}${selectorHint}`; +}; export const getElementReferenceContext = async ( element: Element, options: StackContextOptions = {}, ): Promise => { const traceContext = await getTraceContext(element, options); - return `${getInlineHTMLPreview(element)}${traceContext.text}${getSelectorContext( - element, - traceContext, - )}`; + const contextText = composeElementContext(element, "", traceContext); + return `${getInlineHTMLPreview(element)}${contextText.replace(/\n\s+/g, " ")}`; }; export const formatElementInfo = async ( @@ -711,129 +462,10 @@ export const formatElementInfo = async ( options: StackContextOptions = {}, ): Promise => { const resolvedElement = findNearestFiberElement(element); - const html = getHTMLPreview(resolvedElement); - const traceContext = await getTraceContext(resolvedElement, options); - const selectorContext = getSelectorContext(resolvedElement, traceContext); - return `${html}${traceContext.text}${selectorContext}`; -}; - -const truncateAttrValue = (value: string): string => - truncateString(value, PREVIEW_ATTR_VALUE_MAX_LENGTH); - -interface FormatPriorityAttrsOptions { - truncate?: boolean; - maxAttrs?: number; -} - -const formatPriorityAttrs = ( - element: Element, - options: FormatPriorityAttrsOptions = {}, -): string => { - const { truncate = true, maxAttrs = PREVIEW_MAX_ATTRS } = options; - const priorityAttrs: string[] = []; - - for (const name of PREVIEW_PRIORITY_ATTRS) { - if (priorityAttrs.length >= maxAttrs) break; - const value = element.getAttribute(name); - if (value) { - const formattedValue = truncate ? truncateAttrValue(value) : value; - priorityAttrs.push(`${name}="${formattedValue}"`); - } - } - - return priorityAttrs.length > 0 ? ` ${priorityAttrs.join(" ")}` : ""; -}; - -const isClassOrStyleAttr = (name: string): boolean => - name === "class" || name === "className" || name === "style"; - -const formatAttrsForPreview = (element: Element): string => { - const identifyingParts: string[] = []; - const remainingParts: string[] = []; - let classAttr = ""; - - for (const { name, value } of element.attributes) { - if (isInternalAttribute(name)) continue; - if (isClassOrStyleAttr(name)) { - if (name !== "style" && value) { - classAttr = ` class="${truncateAttrValue(value)}"`; - } - continue; - } - if (PREVIEW_IDENTIFYING_ATTRS.has(name)) { - identifyingParts.push(value ? ` ${name}="${value}"` : ` ${name}`); - } else if (value) { - remainingParts.push(` ${name}="${truncateAttrValue(value)}"`); - } - } - - return identifyingParts.join("") + remainingParts.join("") + classAttr; -}; - -const formatChildElements = (elements: Array): string => { - if (elements.length === 0) return ""; - if (elements.length <= 2) { - return elements.map((childElement) => `<${getTagName(childElement)} ...>`).join("\n "); - } - return `(${elements.length} elements)`; -}; - -export const getInlineHTMLPreview = (element: Element): string => { - const tagName = getTagName(element); - - if (!(element instanceof HTMLElement)) { - const attrsHint = formatPriorityAttrs(element, { - truncate: false, - maxAttrs: PREVIEW_PRIORITY_ATTRS.length, - }); - return `<${tagName}${attrsHint} />`; - } - - const attrsText = formatAttrsForPreview(element); - const previewText = getPreviewTextContent(element, tagName); - const truncatedText = truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH); - - if (truncatedText) { - return `<${tagName}${attrsText}>${truncatedText}`; - } - return `<${tagName}${attrsText} />`; -}; - -export const getHTMLPreview = (element: Element): string => { - const tagName = getTagName(element); - const attrsText = formatAttrsForPreview(element); - const previewText = getPreviewTextContent(element, tagName); - - const topElements: Array = []; - const bottomElements: Array = []; - let foundFirstText = false; - - for (const node of element.childNodes) { - if (node.nodeType === Node.COMMENT_NODE) continue; - if (node.nodeType === Node.TEXT_NODE) { - if (node.textContent && node.textContent.trim().length > 0) { - foundFirstText = true; - } - } else if (node instanceof Element) { - if (!foundFirstText) { - topElements.push(node); - } else { - bottomElements.push(node); - } - } - } - - let content = ""; - const topElementsStr = formatChildElements(topElements); - if (topElementsStr && !previewText) content += `\n ${topElementsStr}`; - if (previewText.length > 0) { - content += `\n ${truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH)}`; - } - const bottomElementsStr = formatChildElements(bottomElements); - if (bottomElementsStr && !previewText) content += `\n ${bottomElementsStr}`; - - if (content.length > 0) { - return `<${tagName}${attrsText}>${content}\n`; - } - return `<${tagName}${attrsText} />`; + const htmlPreview = getHTMLPreview(resolvedElement); + return composeElementContext( + resolvedElement, + htmlPreview, + await getTraceContext(resolvedElement, options), + ); }; diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index 546525c8b..dd9112619 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -61,7 +61,7 @@ const buildElementPayloadEntry = async (element: Element): Promise + truncateString(value, PREVIEW_ATTR_VALUE_MAX_LENGTH); + +const formatPriorityAttrs = (element: Element): string => { + const priorityAttrs: string[] = []; + + for (const name of PREVIEW_PRIORITY_ATTRS) { + const value = element.getAttribute(name); + if (value) priorityAttrs.push(`${name}="${value}"`); + } + + return priorityAttrs.length > 0 ? ` ${priorityAttrs.join(" ")}` : ""; +}; + +const isClassOrStyleAttr = (name: string): boolean => + name === "class" || name === "className" || name === "style"; + +const formatAttrsForPreview = (element: Element): string => { + const identifyingParts: string[] = []; + const remainingParts: string[] = []; + let classAttr = ""; + + for (const { name, value } of element.attributes) { + if (isInternalAttribute(name)) continue; + if (isClassOrStyleAttr(name)) { + if (name !== "style" && value) { + classAttr = ` class="${truncateAttrValue(value)}"`; + } + continue; + } + if (PREVIEW_IDENTIFYING_ATTRS.has(name)) { + identifyingParts.push(value ? ` ${name}="${value}"` : ` ${name}`); + } else if (value) { + remainingParts.push(` ${name}="${truncateAttrValue(value)}"`); + } + } + + return identifyingParts.join("") + remainingParts.join("") + classAttr; +}; + +const formatChildElements = (elements: Array): string => { + if (elements.length === 0) return ""; + if (elements.length <= 2) { + return elements.map((childElement) => `<${getTagName(childElement)} ...>`).join("\n "); + } + return `(${elements.length} elements)`; +}; + +export const getInlineHTMLPreview = (element: Element): string => { + const tagName = getTagName(element); + + if (!(element instanceof HTMLElement)) { + return `<${tagName}${formatPriorityAttrs(element)} />`; + } + + const attrsText = formatAttrsForPreview(element); + const previewText = getPreviewTextContent(element, tagName); + const truncatedText = truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH); + + if (truncatedText) { + return `<${tagName}${attrsText}>${truncatedText}`; + } + return `<${tagName}${attrsText} />`; +}; + +export const getHTMLPreview = (element: Element): string => { + const tagName = getTagName(element); + const attrsText = formatAttrsForPreview(element); + const previewText = getPreviewTextContent(element, tagName); + + const topElements: Array = []; + const bottomElements: Array = []; + let foundFirstText = false; + + for (const node of element.childNodes) { + if (node.nodeType === Node.COMMENT_NODE) continue; + if (node.nodeType === Node.TEXT_NODE) { + if (node.textContent && node.textContent.trim().length > 0) { + foundFirstText = true; + } + } else if (node instanceof Element) { + if (!foundFirstText) { + topElements.push(node); + } else { + bottomElements.push(node); + } + } + } + + const previewSubsumesChildren = + previewText.length > 0 && PREVIEW_DESCENDANT_TEXT_TAGS.has(tagName); + + let content = ""; + const topElementsStr = formatChildElements(topElements); + if (topElementsStr && !previewSubsumesChildren) content += `\n ${topElementsStr}`; + if (previewText) { + content += `\n ${truncateString(previewText, PREVIEW_TEXT_MAX_LENGTH)}`; + } + const bottomElementsStr = formatChildElements(bottomElements); + if (bottomElementsStr && !previewSubsumesChildren) content += `\n ${bottomElementsStr}`; + + if (content.length > 0) { + return `<${tagName}${attrsText}>${content}\n`; + } + return `<${tagName}${attrsText} />`; +}; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 7c25a303c..baaf0fd87 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -31,9 +31,9 @@ import { getStackContext, getNearestComponentName, getComponentDisplayName, - isNextProjectRuntime, resolveSource, } from "./context.js"; +import { isNextProjectRuntime } from "../utils/is-next-project-runtime.js"; import { createNoopApi } from "./noop-api.js"; import { createEventListenerManager } from "./events.js"; import { runCopyFlow } from "./copy.js"; @@ -645,13 +645,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { getContent: pluginRegistry.store.options.getContent, componentName: elementName, }, - { - onBeforeCopy: pluginRegistry.hooks.onBeforeCopy, - transformCopyContent: pluginRegistry.hooks.transformCopyContent, - onAfterCopy: pluginRegistry.hooks.onAfterCopy, - onCopySuccess: pluginRegistry.hooks.onCopySuccess, - onCopyError: pluginRegistry.hooks.onCopyError, - }, + pluginRegistry.hooks, elements, extraPrompt, ); @@ -2207,7 +2201,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const wasHandled = pluginRegistry.hooks.onOpenFile(filePath, lineNumber ?? undefined); if (!wasHandled) { - requestOpenFile(filePath, lineNumber ?? undefined, pluginRegistry.hooks.transformOpenFileUrl); + requestOpenFile( + filePath, + lineNumber ?? undefined, + pluginRegistry.hooks.transformOpenFileUrl, + ); } return true; }; @@ -3683,7 +3681,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { componentName: source.componentName, }; }, - getStackContext: (element: Element) => getStackContext(element), + getStackContext, getState: (): ReactGrabState => ({ isActive: isActivated(), isDragging: isDragging(), diff --git a/packages/react-grab/src/core/next-server-frames.ts b/packages/react-grab/src/core/next-server-frames.ts new file mode 100644 index 000000000..acc7a5a73 --- /dev/null +++ b/packages/react-grab/src/core/next-server-frames.ts @@ -0,0 +1,170 @@ +import { formatOwnerStack, hasDebugStack, parseStack, type StackFrame } from "bippy/source"; +import { traverseFiber, type Fiber } from "bippy"; +import { SYMBOLICATION_TIMEOUT_MS } from "../constants.js"; +import { getNextBasePath } from "../utils/get-next-base-path.js"; +import { safeDecodeURIComponent } from "../utils/safe-decode-uri-component.js"; + +const SERVER_COMPONENT_URL_PREFIXES = ["about://React/", "rsc://React/"]; + +const isServerComponentUrl = (url: string): boolean => + SERVER_COMPONENT_URL_PREFIXES.some((prefix) => url.startsWith(prefix)); + +const devirtualizeServerUrl = (url: string): string => { + for (const prefix of SERVER_COMPONENT_URL_PREFIXES) { + if (!url.startsWith(prefix)) continue; + const environmentEndIndex = url.indexOf("/", prefix.length); + if (environmentEndIndex === -1) continue; + const pathStart = environmentEndIndex + 1; + const querySuffixIndex = url.lastIndexOf("?"); + const rawPath = + querySuffixIndex > pathStart ? url.slice(pathStart, querySuffixIndex) : url.slice(pathStart); + return safeDecodeURIComponent(rawPath); + } + return url; +}; + +interface NextJsOriginalFrame { + file: string | null; + line1: number | null; + column1: number | null; + ignored: boolean; +} + +interface NextJsFrameResult { + status: string; + value?: { originalStackFrame: NextJsOriginalFrame | null }; +} + +interface NextJsRequestFrame { + file: string; + methodName: string; + line1: number | null; + column1: number | null; + arguments: string[]; +} + +export const symbolicateServerFrames = async (frames: StackFrame[]): Promise => { + const serverFrameIndices: number[] = []; + const requestFrames: NextJsRequestFrame[] = []; + + for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) { + const frame = frames[frameIndex]; + if (!frame.isServer || !frame.fileName) continue; + + serverFrameIndices.push(frameIndex); + requestFrames.push({ + file: devirtualizeServerUrl(frame.fileName), + methodName: frame.functionName ?? "", + line1: frame.lineNumber ?? null, + column1: frame.columnNumber ?? null, + arguments: [], + }); + } + + if (requestFrames.length === 0) return frames; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SYMBOLICATION_TIMEOUT_MS); + + try { + // Next.js dev server (>=15.2) exposes a batched symbolication endpoint that + // resolves bundled/virtual stack frames back to original source locations via + // source maps. Server components produce virtual URLs like + // "rsc://React/Server/webpack-internal:///..." that have no real file on disk. + // We POST an array of frames and get back PromiseSettledResult[]. + // getNextBasePath() is required for apps deployed with a basePath. + const response = await fetch(`${getNextBasePath()}/__nextjs_original-stack-frames`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + frames: requestFrames, + isServer: true, + isEdgeServer: false, + isAppDirectory: true, + }), + signal: controller.signal, + }); + + if (!response.ok) return frames; + + const results = (await response.json()) as NextJsFrameResult[]; + const resolvedFrames = [...frames]; + + for (let resultIndex = 0; resultIndex < serverFrameIndices.length; resultIndex++) { + const result = results[resultIndex]; + if (result?.status !== "fulfilled") continue; + + const resolved = result.value?.originalStackFrame; + if (!resolved?.file || resolved.ignored) continue; + + const originalFrameIndex = serverFrameIndices[resultIndex]; + resolvedFrames[originalFrameIndex] = { + ...frames[originalFrameIndex], + fileName: resolved.file, + lineNumber: resolved.line1 ?? undefined, + columnNumber: resolved.column1 ?? undefined, + isSymbolicated: true, + }; + } + + return resolvedFrames; + } catch { + return frames; + } finally { + clearTimeout(timeout); + } +}; + +const extractServerFramesFromDebugStack = (rootFiber: Fiber): Map => { + const serverFramesByName = new Map(); + + traverseFiber( + rootFiber, + (currentFiber) => { + if (!hasDebugStack(currentFiber)) return false; + + const ownerStack = formatOwnerStack(currentFiber._debugStack.stack); + if (!ownerStack) return false; + + for (const frame of parseStack(ownerStack)) { + if (!frame.functionName || !frame.fileName) continue; + if (!isServerComponentUrl(frame.fileName)) continue; + if (serverFramesByName.has(frame.functionName)) continue; + + serverFramesByName.set(frame.functionName, { + ...frame, + isServer: true, + }); + } + return false; + }, + true, + ); + + return serverFramesByName; +}; + +export const enrichServerFrameLocations = ( + rootFiber: Fiber, + frames: StackFrame[], +): StackFrame[] => { + const hasUnresolvedServerFrames = frames.some( + (frame) => frame.isServer && !frame.fileName && frame.functionName, + ); + if (!hasUnresolvedServerFrames) return frames; + + const serverFramesByName = extractServerFramesFromDebugStack(rootFiber); + if (serverFramesByName.size === 0) return frames; + + return frames.map((frame) => { + if (!frame.isServer || frame.fileName || !frame.functionName) return frame; + const resolved = serverFramesByName.get(frame.functionName); + if (!resolved) return frame; + return { + ...frame, + fileName: resolved.fileName, + lineNumber: resolved.lineNumber, + columnNumber: resolved.columnNumber, + }; + }); +}; diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts index 7cd000676..a1da9575d 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -11,12 +11,12 @@ import { } from "./utils/pointer-events-freeze.js"; import { getComponentDisplayName, - getHTMLPreview, getStack, getStackContext, formatElementInfo, resolveSource, } from "./core/context.js"; +import { getHTMLPreview } from "./core/html-preview.js"; import { Fiber, getFiberFromHostInstance } from "bippy"; import type { StackFrame } from "bippy/source"; export type { StackFrame }; @@ -51,9 +51,7 @@ export interface ReactGrabElementContext { * ctx.filePath; // "/src/components/Button.tsx" * ctx.lineNumber; // 12 */ -export const getElementContext = async ( - element: Element, -): Promise => { +export const getElementContext = async (element: Element): Promise => { const [snippet, source, stack] = await Promise.all([ formatElementInfo(element), resolveSource(element), diff --git a/packages/react-grab/src/utils/create-element-selector.ts b/packages/react-grab/src/utils/create-element-selector.ts index 4bc0766b5..556a790f7 100644 --- a/packages/react-grab/src/utils/create-element-selector.ts +++ b/packages/react-grab/src/utils/create-element-selector.ts @@ -1,13 +1,6 @@ import { isAcceptedAttr, findUniqueSelector } from "./find-unique-selector.js"; import { FINDER_TIMEOUT_MS, SELECTOR_ATTR_VALUE_MAX_LENGTH_CHARS } from "../constants.js"; -const escapeCssIdentifier = (value: string): string => { - if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { - return CSS.escape(value); - } - return value.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`); -}; - const getFinderRoot = (element: Element): Element => element.ownerDocument.body ?? element.ownerDocument.documentElement; @@ -40,7 +33,7 @@ const isSelectorUniqueForElement = (element: Element, selector: string): boolean const createFastElementSelector = (element: Element): string | null => { if (element instanceof HTMLElement && element.id) { - const idSelector = `#${escapeCssIdentifier(element.id)}`; + const idSelector = `#${CSS.escape(element.id)}`; if (isSelectorUniqueForElement(element, idSelector)) return idSelector; } @@ -72,7 +65,7 @@ const createNthChildSelector = (element: Element): string => { let currentElement: Element | null = element; while (currentElement) { if (currentElement instanceof HTMLElement && currentElement.id) { - segments.unshift(`#${escapeCssIdentifier(currentElement.id)}`); + segments.unshift(`#${CSS.escape(currentElement.id)}`); break; } @@ -83,8 +76,7 @@ const createNthChildSelector = (element: Element): string => { } const siblings = Array.from(parentElement.children); - const siblingIndex = siblings.indexOf(currentElement); - const nthChild = siblingIndex >= 0 ? siblingIndex + 1 : 1; + const nthChild = siblings.indexOf(currentElement) + 1; segments.unshift(`${currentElement.tagName.toLowerCase()}:nth-child(${nthChild})`); diff --git a/packages/react-grab/src/utils/generate-snippet.ts b/packages/react-grab/src/utils/generate-snippet.ts index 0c94b670e..4acd05f6b 100644 --- a/packages/react-grab/src/utils/generate-snippet.ts +++ b/packages/react-grab/src/utils/generate-snippet.ts @@ -1,20 +1,11 @@ -import { formatElementInfo } from "../core/context.js"; - -interface GenerateSnippetOptions { - maxLines?: number; -} +import { formatElementInfo, type StackContextOptions } from "../core/context.js"; export const generateSnippet = async ( elements: Element[], - options: GenerateSnippetOptions = {}, + options: StackContextOptions = {}, ): Promise => { const elementSnippetResults = await Promise.allSettled( elements.map((element) => formatElementInfo(element, options)), ); - - const elementSnippets = elementSnippetResults.map((result) => - result.status === "fulfilled" ? result.value : "", - ); - - return elementSnippets; + return elementSnippetResults.map((result) => (result.status === "fulfilled" ? result.value : "")); }; diff --git a/packages/react-grab/src/utils/get-preview-text-content.ts b/packages/react-grab/src/utils/get-preview-text-content.ts index 985d3e893..595216bc8 100644 --- a/packages/react-grab/src/utils/get-preview-text-content.ts +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -1,21 +1,19 @@ import { PREVIEW_DESCENDANT_TEXT_TAGS, PREVIEW_SKIPPED_TEXT_TAGS, + PREVIEW_TEXT_MAX_LENGTH, } from "../constants.js"; const collapseTextContent = (text: string): string => text.replace(/\s+/g, " ").trim(); const getDirectTextContent = (element: Element): string => { - let directText = ""; + const textParts: string[] = []; for (const node of element.childNodes) { if (node.nodeType !== Node.TEXT_NODE) continue; - - const trimmed = collapseTextContent(node.textContent ?? ""); - if (trimmed) { - directText += (directText ? " " : "") + trimmed; - } + const collapsedText = collapseTextContent(node.textContent ?? ""); + if (collapsedText) textParts.push(collapsedText); } - return directText; + return textParts.join(" "); }; const shouldSkipElementText = (element: Element): boolean => { @@ -24,19 +22,32 @@ const shouldSkipElementText = (element: Element): boolean => { return PREVIEW_SKIPPED_TEXT_TAGS.has(element.tagName.toLowerCase()); }; -const collectDescendantText = (node: Node, parts: string[]): void => { +// Returns the remaining character budget so the walk can stop once it has +// collected enough to fill PREVIEW_TEXT_MAX_LENGTH, instead of serializing an +// entire (potentially huge) syntax-highlighted subtree only to truncate it. +const collectDescendantText = ( + node: Node, + textParts: string[], + remainingCharacterBudget: number, +): number => { if (node.nodeType === Node.TEXT_NODE) { - const trimmed = collapseTextContent(node.textContent ?? ""); - if (trimmed) parts.push(trimmed); - return; + const collapsedText = collapseTextContent(node.textContent ?? ""); + if (!collapsedText) return remainingCharacterBudget; + textParts.push(collapsedText); + return remainingCharacterBudget - collapsedText.length; } - if (!(node instanceof Element)) return; - if (shouldSkipElementText(node)) return; + if (!(node instanceof Element) || shouldSkipElementText(node)) return remainingCharacterBudget; for (const childNode of node.childNodes) { - collectDescendantText(childNode, parts); + remainingCharacterBudget = collectDescendantText( + childNode, + textParts, + remainingCharacterBudget, + ); + if (remainingCharacterBudget <= 0) break; } + return remainingCharacterBudget; }; export const getPreviewTextContent = (element: Element, tagName: string): string => { @@ -46,9 +57,7 @@ export const getPreviewTextContent = (element: Element, tagName: string): string if (!PREVIEW_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(" ")); + const textParts: string[] = []; + collectDescendantText(element, textParts, PREVIEW_TEXT_MAX_LENGTH); + return textParts.join(" "); }; diff --git a/packages/react-grab/src/utils/is-next-project-runtime.ts b/packages/react-grab/src/utils/is-next-project-runtime.ts new file mode 100644 index 000000000..9be6f9df7 --- /dev/null +++ b/packages/react-grab/src/utils/is-next-project-runtime.ts @@ -0,0 +1,11 @@ +let cachedIsNextProject: boolean | undefined; + +export const isNextProjectRuntime = (shouldRevalidate?: boolean): boolean => { + if (shouldRevalidate) { + cachedIsNextProject = undefined; + } + cachedIsNextProject ??= + typeof document !== "undefined" && + Boolean(document.getElementById("__NEXT_DATA__") || document.querySelector("nextjs-portal")); + return cachedIsNextProject; +}; diff --git a/packages/react-grab/src/utils/open-file.ts b/packages/react-grab/src/utils/open-file.ts index 96f8cf601..2c868d17f 100644 --- a/packages/react-grab/src/utils/open-file.ts +++ b/packages/react-grab/src/utils/open-file.ts @@ -1,4 +1,4 @@ -import { isNextProjectRuntime } from "../core/context.js"; +import { isNextProjectRuntime } from "./is-next-project-runtime.js"; import { getNextBasePath } from "./get-next-base-path.js"; import { normalizeFilePath } from "./normalize-file-path.js"; @@ -29,13 +29,15 @@ export const requestOpenFile = async ( lineNumber: number | undefined, transformUrl?: (url: string, filePath: string, lineNumber?: number) => string, ): Promise => { - filePath = normalizeFilePath(filePath); + const normalizedFilePath = normalizeFilePath(filePath); - const wasOpenedByDevServer = await tryDevServerOpen(filePath, lineNumber).catch(() => false); + const wasOpenedByDevServer = await tryDevServerOpen(normalizedFilePath, lineNumber).catch( + () => false, + ); if (wasOpenedByDevServer) return; const lineParam = lineNumber ? `&line=${lineNumber}` : ""; - const rawUrl = `${OPEN_FILE_BASE_URL}/open-file?url=${encodeURIComponent(filePath)}${lineParam}`; - const url = transformUrl ? transformUrl(rawUrl, filePath, lineNumber) : rawUrl; + const rawUrl = `${OPEN_FILE_BASE_URL}/open-file?url=${encodeURIComponent(normalizedFilePath)}${lineParam}`; + const url = transformUrl ? transformUrl(rawUrl, normalizedFilePath, lineNumber) : rawUrl; window.open(url, "_blank", "noopener,noreferrer"); }; diff --git a/packages/react-grab/src/utils/parse-package-name.ts b/packages/react-grab/src/utils/parse-package-name.ts index 2c0dab211..7a0c44e3f 100644 --- a/packages/react-grab/src/utils/parse-package-name.ts +++ b/packages/react-grab/src/utils/parse-package-name.ts @@ -1,8 +1,8 @@ import { normalizeFileName } from "bippy/source"; import { safeDecodeURIComponent } from "./safe-decode-uri-component.js"; -const NODE_MODULES_PATTERN = /(?:^|[/\\])node_modules[/\\]/g; -const VITE_OPTIMIZED_DEPS_PATTERN = /[/\\]\.vite[/\\]deps[^/\\]*[/\\]/g; +const NODE_MODULES_PATTERN = /(?:^|[/\\])node_modules[/\\]/; +const VITE_OPTIMIZED_DEPS_PATTERN = /[/\\]\.vite[/\\]deps[^/\\]*[/\\]/; const FILE_EXTENSION_PATTERN = /\.[mc]?[jt]sx?$/i; const VITE_INTERNAL_CHUNK_PATTERN = /^chunk-[A-Za-z0-9_-]+$/; const PATH_SEPARATOR_PATTERN = /[/\\]/; @@ -39,13 +39,8 @@ const extractAfterLastMarker = ( pattern: RegExp, read: (afterMarker: string) => string | null, ): string | null => { - let lastMatch: RegExpExecArray | null = null; - let match: RegExpExecArray | null; - while ((match = pattern.exec(input)) !== null) { - lastMatch = match; - } - if (!lastMatch) return null; - return read(input.slice(lastMatch.index + lastMatch[0].length)); + const parts = input.split(pattern); + return parts.length > 1 ? read(parts[parts.length - 1]) : null; }; const extractNameAtVersion = (segment: string | undefined): string | null => diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/source-frame-policy.ts index 32d3c4883..ac5b51651 100644 --- a/packages/react-grab/src/utils/source-frame-policy.ts +++ b/packages/react-grab/src/utils/source-frame-policy.ts @@ -2,76 +2,26 @@ import { isSourceFile } from "bippy/source"; import { normalizeFilePath } from "./normalize-file-path.js"; import { resolvePackageName } from "./parse-package-name.js"; -// design-system wrappers (e.g. shadcn's components/ui) are rarely the file a -// user wants to edit, so grabs resolve to the consuming app source instead. -const DEFAULT_IGNORED_SOURCE_PATHS: readonly string[] = ["components/ui"]; -const PATH_SEPARATOR_PATTERN = /[/\\]/; -// Keyed by fileName; classification depends solely on fileName. -const defaultClassificationCache = new Map(); - export interface SourcePathClassification { kind: "app-source" | "ignored-app-source" | "package-source" | "unknown"; packageName: string | null; } -const splitPathSegments = (path: string): string[] => - normalizeFilePath(path).split(PATH_SEPARATOR_PATTERN).filter(Boolean); - -const DEFAULT_IGNORED_SOURCE_PATH_SEGMENTS = DEFAULT_IGNORED_SOURCE_PATHS.map((sourcePath) => - splitPathSegments(sourcePath), -); - -const matchesPathSegments = (pathSegments: string[], patternSegments: string[]): boolean => { - if (patternSegments.length === 0 || patternSegments.length > pathSegments.length) return false; - - for (let pathIndex = 0; pathIndex <= pathSegments.length - patternSegments.length; pathIndex++) { - let didMatch = true; - for (let patternIndex = 0; patternIndex < patternSegments.length; patternIndex++) { - if (pathSegments[pathIndex + patternIndex] !== patternSegments[patternIndex]) { - didMatch = false; - break; - } - } - if (didMatch) return true; - } - - return false; -}; - -const matchesIgnoredSourcePath = (fileName: string): boolean => { - const pathSegments = splitPathSegments(fileName); - - for (const ignoredSourcePathSegments of DEFAULT_IGNORED_SOURCE_PATH_SEGMENTS) { - if (matchesPathSegments(pathSegments, ignoredSourcePathSegments)) return true; - } - - return false; -}; +// design-system wrappers (e.g. shadcn's components/ui) are rarely the file a +// user wants to edit, so grabs resolve to the consuming app source instead. +const IGNORED_SOURCE_PATH_PATTERN = /(?:^|[/\\])components[/\\]ui(?:[/\\]|$)/; export const classifySourcePath = ( fileName: string | null | undefined, ): SourcePathClassification => { if (!fileName) return { kind: "unknown", packageName: null }; - const cachedClassification = defaultClassificationCache.get(fileName); - if (cachedClassification) return cachedClassification; - - const classification = classifySourcePathUncached(fileName); - defaultClassificationCache.set(fileName, classification); - return classification; -}; - -const classifySourcePathUncached = (fileName: string): SourcePathClassification => { const packageName = resolvePackageName(fileName); - if (packageName) { - return { kind: "package-source", packageName }; - } + if (packageName) return { kind: "package-source", packageName }; - if (!isSourceFile(fileName)) { - return { kind: "unknown", packageName: null }; - } + if (!isSourceFile(fileName)) return { kind: "unknown", packageName: null }; - if (matchesIgnoredSourcePath(fileName)) { + if (IGNORED_SOURCE_PATH_PATTERN.test(normalizeFilePath(fileName))) { return { kind: "ignored-app-source", packageName: null }; } diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index 29e2ce3ef..637dedfb0 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -3,21 +3,31 @@ import type { StackFrame } from "bippy/source"; import { formatStackContext, selectResolvedSource, - type FramesBySourceKind, + type ResolvedSource, } from "../src/core/context.js"; -const emptyFramesByKind = (): FramesBySourceKind => ({ - "app-source": [], - "ignored-app-source": [], - "package-source": [], -}); - -const fiberSource = { +const fiberSource: ResolvedSource = { filePath: "/src/app/page.tsx", lineNumber: 1, columnNumber: 1, componentName: "Page", - sourceFileName: "/src/app/page.tsx", + kind: "app-source", +}; + +const ignoredFiberSource: ResolvedSource = { + filePath: "/src/components/ui/button.tsx", + lineNumber: 1, + columnNumber: 1, + componentName: "Button", + kind: "ignored-app-source", +}; + +const packageFiberSource: ResolvedSource = { + filePath: "/app/node_modules/react-tabs/dist/index.js", + lineNumber: 1, + columnNumber: 1, + componentName: "Tabs", + kind: "package-source", }; const appFrame: StackFrame = { @@ -43,60 +53,42 @@ const packageFrame: StackFrame = { describe("selectResolvedSource", () => { it("prefers the fiber source when it is app-source", () => { - const framesByKind = emptyFramesByKind(); - framesByKind["app-source"].push(appFrame); - - expect(selectResolvedSource(fiberSource, "app-source", framesByKind)).toBe(fiberSource); + expect(selectResolvedSource(fiberSource, [appFrame])).toBe(fiberSource); }); it("prefers an app-source frame over a non-app fiber source", () => { - const framesByKind = emptyFramesByKind(); - framesByKind["app-source"].push(appFrame); - - expect(selectResolvedSource(fiberSource, "ignored-app-source", framesByKind)).toMatchObject({ + expect(selectResolvedSource(ignoredFiberSource, [appFrame])).toMatchObject({ filePath: "/src/app/widget.tsx", componentName: "Widget", }); }); it("prefers an ignored-app-source fiber over ignored or package frames", () => { - const framesByKind = emptyFramesByKind(); - framesByKind["ignored-app-source"].push(ignoredFrame); - framesByKind["package-source"].push(packageFrame); - - expect(selectResolvedSource(fiberSource, "ignored-app-source", framesByKind)).toBe(fiberSource); + expect(selectResolvedSource(ignoredFiberSource, [ignoredFrame, packageFrame])).toBe( + ignoredFiberSource, + ); }); it("falls back to an ignored-app-source frame over a package frame", () => { - const framesByKind = emptyFramesByKind(); - framesByKind["ignored-app-source"].push(ignoredFrame); - framesByKind["package-source"].push(packageFrame); - - expect(selectResolvedSource(null, "unknown", framesByKind)).toMatchObject({ + expect(selectResolvedSource(null, [ignoredFrame, packageFrame])).toMatchObject({ filePath: "/src/components/ui/button.tsx", componentName: "Button", }); }); it("prefers a package-source fiber over package frames", () => { - const framesByKind = emptyFramesByKind(); - framesByKind["package-source"].push(packageFrame); - - expect(selectResolvedSource(fiberSource, "package-source", framesByKind)).toBe(fiberSource); + expect(selectResolvedSource(packageFiberSource, [packageFrame])).toBe(packageFiberSource); }); it("falls back to a package frame as the last resort", () => { - const framesByKind = emptyFramesByKind(); - framesByKind["package-source"].push(packageFrame); - - expect(selectResolvedSource(null, "unknown", framesByKind)).toMatchObject({ + expect(selectResolvedSource(null, [packageFrame])).toMatchObject({ filePath: "/app/node_modules/react-tabs/dist/index.js", componentName: "Tabs", }); }); it("returns null when no fiber source or frames resolve", () => { - expect(selectResolvedSource(null, "unknown", emptyFramesByKind())).toBe(null); + expect(selectResolvedSource(null, [])).toBe(null); }); it("picks the first named frame within a kind over an earlier anonymous frame", () => { @@ -105,10 +97,8 @@ describe("selectResolvedSource", () => { lineNumber: 2, columnNumber: 1, }; - const framesByKind = emptyFramesByKind(); - framesByKind["app-source"].push(anonymousFrame, appFrame); - expect(selectResolvedSource(null, "unknown", framesByKind)).toMatchObject({ + expect(selectResolvedSource(null, [anonymousFrame, appFrame])).toMatchObject({ filePath: "/src/app/widget.tsx", componentName: "Widget", }); @@ -158,17 +148,7 @@ describe("formatStackContext", () => { }); it("requests a selector hint for an ignored components/ui leading source", () => { - const result = formatStackContext( - [], - {}, - { - filePath: "/src/components/ui/button.tsx", - lineNumber: 1, - columnNumber: 1, - componentName: "Button", - sourceFileName: "src/components/ui/button.tsx", - }, - ); + const result = formatStackContext([], {}, ignoredFiberSource); expect(result.shouldAppendSelectorHint).toBe(true); }); diff --git a/packages/react-grab/tests/source-frame-policy.test.ts b/packages/react-grab/tests/source-frame-policy.test.ts index 124a56fbc..b0c4b8a46 100644 --- a/packages/react-grab/tests/source-frame-policy.test.ts +++ b/packages/react-grab/tests/source-frame-policy.test.ts @@ -22,6 +22,10 @@ describe("classifySourcePath", () => { kind: "package-source", packageName: "@radix-ui/react-tabs", }); + expect(classifySourcePath("/app/node_modules/react-tabs/dist/index.js")).toEqual({ + kind: "package-source", + packageName: "react-tabs", + }); }); // The relative prefix is the only signal distinguishing a scoped dependency diff --git a/skills/react-grab/SKILL.md b/skills/react-grab/SKILL.md index c64ba4376..d23c3d6a5 100644 --- a/skills/react-grab/SKILL.md +++ b/skills/react-grab/SKILL.md @@ -52,9 +52,9 @@ While acting on a grab: npx react-grab@latest pull --max-age 0 --wait 0 ``` - Empty output means nothing new — keep going. If it prints a grab, the user has - moved on: stop the current task, cancel any background processes you started for - it, and act on the newest grab instead. +Empty output means nothing new — keep going. If it prints a grab, the user has +moved on: stop the current task, cancel any background processes you started for +it, and act on the newest grab instead. ## Acting on a grab From e14a25e137d1765f94055c34a3b8be67d36f11e0 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Tue, 9 Jun 2026 18:17:11 -0700 Subject: [PATCH 61/65] Deduplicate copy payload types and clarify names Move the thrice-declared wire shapes (SourceLocation, ReactGrabStackFrame, ReactGrabEntry) to global types, with SourceLocation extending SourceInfo. Inline the dead-null resolveStackFrameSource branch, flatten the formatStackContext emit closure, collapse getTraceContext's duplicated leading-source fallback, and drop always-constant parameters. Revert the CLI GrabEntry to its four-field shape since it only reads commentText. Rename source-frame-policy to classify-source-path after its export, and replace vague locals (resolvedElement, resolved, cached, promise, name, value) with descriptive names throughout the touched files. --- packages/cli/src/utils/clipboard.ts | 15 -- packages/react-grab/src/core/context.ts | 142 +++++++----------- packages/react-grab/src/core/copy.ts | 48 ++---- packages/react-grab/src/core/html-preview.ts | 34 +++-- .../react-grab/src/core/next-server-frames.ts | 32 ++-- packages/react-grab/src/types.ts | 23 +++ ...rame-policy.ts => classify-source-path.ts} | 0 packages/react-grab/src/utils/copy-content.ts | 27 +--- ...y.test.ts => classify-source-path.test.ts} | 2 +- 9 files changed, 123 insertions(+), 200 deletions(-) rename packages/react-grab/src/utils/{source-frame-policy.ts => classify-source-path.ts} (100%) rename packages/react-grab/tests/{source-frame-policy.test.ts => classify-source-path.test.ts} (96%) diff --git a/packages/cli/src/utils/clipboard.ts b/packages/cli/src/utils/clipboard.ts index b9efd53c9..51f5d4aee 100644 --- a/packages/cli/src/utils/clipboard.ts +++ b/packages/cli/src/utils/clipboard.ts @@ -37,21 +37,6 @@ interface GrabEntry { componentName?: string; content?: string; commentText?: string; - source?: { - filePath: string; - lineNumber: number | null; - columnNumber?: number | null; - componentName: string | null; - } | null; - stackContext?: string; - frames?: Array<{ - functionName?: string; - fileName?: string; - lineNumber?: number; - columnNumber?: number; - isServer?: boolean; - isSymbolicated?: boolean; - }>; } interface GrabRecord { diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 1c86249e2..3fde67b33 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -9,7 +9,10 @@ import { } from "bippy"; import { DEFAULT_MAX_CONTEXT_LINES, MAX_TRACE_CONTEXT_LINES } from "../constants.js"; import { normalizeFilePath } from "../utils/normalize-file-path.js"; -import { classifySourcePath, type SourcePathClassification } from "../utils/source-frame-policy.js"; +import { + classifySourcePath, + type SourcePathClassification, +} from "../utils/classify-source-path.js"; import { createElementSelector } from "../utils/create-element-selector.js"; import { isNextProjectRuntime } from "../utils/is-next-project-runtime.js"; import { enrichServerFrameLocations, symbolicateServerFrames } from "./next-server-frames.js"; @@ -18,6 +21,7 @@ import { isInternalComponentName, isUsefulComponentName, } from "../utils/is-useful-component-name.js"; +import type { SourceLocation } from "../types.js"; const isSourceComponentName = (name: string): boolean => { if (name.length <= 1) return false; @@ -64,13 +68,13 @@ const fetchStackForElement = async (element: Element): Promise => { if (!isInstrumentationActive()) return Promise.resolve([]); - const resolvedElement = findNearestFiberElement(element); - const cached = stackCache.get(resolvedElement); - if (cached) return cached; + const nearestFiberElement = findNearestFiberElement(element); + const cachedStackPromise = stackCache.get(nearestFiberElement); + if (cachedStackPromise) return cachedStackPromise; - const promise = fetchStackForElement(resolvedElement); - stackCache.set(resolvedElement, promise); - return promise; + const stackPromise = fetchStackForElement(nearestFiberElement); + stackCache.set(nearestFiberElement, stackPromise); + return stackPromise; }; export const getNearestComponentName = async (element: Element): Promise => { @@ -79,20 +83,13 @@ export const getNearestComponentName = async (element: Element): Promise }; const getCachedFiberSource = (element: Element): Promise => { - const resolvedElement = findNearestFiberElement(element); - const cached = fiberSourceCache.get(resolvedElement); - if (cached) return cached; + const nearestFiberElement = findNearestFiberElement(element); + const cachedFiberSourcePromise = fiberSourceCache.get(nearestFiberElement); + if (cachedFiberSourcePromise) return cachedFiberSourcePromise; // Evict null resolutions so a later grab can retry once the fiber's source // metadata is attached, while still deduping concurrent in-flight lookups. - const promise = getFiberSource(resolvedElement).then((source) => { - if (!source) fiberSourceCache.delete(resolvedElement); + const fiberSourcePromise = getFiberSource(nearestFiberElement).then((source) => { + if (!source) fiberSourceCache.delete(nearestFiberElement); return source; }); - fiberSourceCache.set(resolvedElement, promise); - return promise; -}; - -const resolveStackFrameSource = ( - frame: StackFrame, - kind: SourcePathClassification["kind"], -): ResolvedSource | null => { - if (!frame.fileName) return null; - return { - filePath: normalizeFilePath(frame.fileName), - lineNumber: frame.lineNumber ?? null, - columnNumber: frame.columnNumber ?? null, - componentName: toSourceComponentName(frame.functionName), - kind, - }; + fiberSourceCache.set(nearestFiberElement, fiberSourcePromise); + return fiberSourcePromise; }; const SOURCE_KIND_PREFERENCE_ORDER = [ @@ -173,11 +156,16 @@ export const selectResolvedSource = ( ): ResolvedSource | null => { for (const kind of SOURCE_KIND_PREFERENCE_ORDER) { if (fiberSource?.kind === kind) return fiberSource; - const kindFrames = stack.filter((frame) => classifySourcePath(frame.fileName).kind === kind); - const frame = pickSourceFrame(kindFrames); - if (frame) { - const frameSource = resolveStackFrameSource(frame, kind); - if (frameSource) return frameSource; + const framesOfKind = stack.filter((frame) => classifySourcePath(frame.fileName).kind === kind); + const preferredFrame = pickSourceFrame(framesOfKind); + if (preferredFrame?.fileName) { + return { + filePath: normalizeFilePath(preferredFrame.fileName), + lineNumber: preferredFrame.lineNumber ?? null, + columnNumber: preferredFrame.columnNumber ?? null, + componentName: toSourceComponentName(preferredFrame.functionName), + kind, + }; } } return null; @@ -213,9 +201,9 @@ const getComponentNamesFromFiber = (element: Element, maxCount: number): string[ (currentFiber) => { if (componentNames.length >= maxCount) return true; if (isCompositeFiber(currentFiber)) { - const name = getDisplayName(currentFiber.type); - if (name && isUsefulComponentName(name)) { - componentNames.push(name); + const displayName = getDisplayName(currentFiber.type); + if (displayName && isUsefulComponentName(displayName)) { + componentNames.push(displayName); } } return false; @@ -326,18 +314,9 @@ export const formatStackContext = ( let didDedupeLeadingComponent = false; let hasTrustedSource = false; - const emit = (line: StackFrameLine) => { - if (line.isTrustedSource) { - hasTrustedSource = true; - } - lines.push(line.text); - }; - if (leadingSource) { - emit({ - text: formatSourceContextLine(leadingSource, isNextProject), - isTrustedSource: leadingSource.kind === "app-source", - }); + hasTrustedSource = leadingSource.kind === "app-source"; + lines.push(formatSourceContextLine(leadingSource, isNextProject)); } for (const frame of stack) { @@ -373,7 +352,8 @@ export const formatStackContext = ( ); if (frameLine === null) continue; - emit(frameLine); + if (frameLine.isTrustedSource) hasTrustedSource = true; + lines.push(frameLine.text); previousLibraryFrameKey = libraryFrameKey; } @@ -399,34 +379,24 @@ const getTraceContext = async ( element: Element, options: StackContextOptions = {}, ): Promise => { - const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; const leadingSource = await resolveLeadingSource(element); const stack = await getStack(element); - if (stack) { - const stackContext = formatStackContext(stack, options, leadingSource); - if (stackContext.text) return stackContext; - } - - if (leadingSource) { - return { - text: formatSourceContextLine(leadingSource, isNextProjectRuntime()), - shouldAppendSelectorHint: leadingSource.kind !== "app-source", - }; - } + const stackContext = formatStackContext(stack ?? [], options, leadingSource); + if (stackContext.text) return stackContext; - const componentNames = getComponentNamesFromFiber(findNearestFiberElement(element), maxLines); + const componentNames = getComponentNamesFromFiber( + findNearestFiberElement(element), + options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES, + ); if (componentNames.length > 0) { return { - text: componentNames.map((name) => `\n in ${name}`).join(""), + text: componentNames.map((componentName) => `\n in ${componentName}`).join(""), shouldAppendSelectorHint: true, }; } - return { - text: "", - shouldAppendSelectorHint: true, - }; + return { text: "", shouldAppendSelectorHint: true }; }; export const getStackContext = async ( @@ -437,15 +407,11 @@ export const getStackContext = async ( return traceContext.text; }; -const composeElementContext = ( - element: Element, - htmlPreview: string, - traceContext: TraceContextResult, -): string => { +const composeElementContext = (element: Element, traceContext: TraceContextResult): string => { const selectorHint = traceContext.shouldAppendSelectorHint ? `\n selector: ${createElementSelector(element)}` : ""; - return `${htmlPreview}${traceContext.text}${selectorHint}`; + return `${traceContext.text}${selectorHint}`; }; export const getElementReferenceContext = async ( @@ -453,19 +419,15 @@ export const getElementReferenceContext = async ( options: StackContextOptions = {}, ): Promise => { const traceContext = await getTraceContext(element, options); - const contextText = composeElementContext(element, "", traceContext); - return `${getInlineHTMLPreview(element)}${contextText.replace(/\n\s+/g, " ")}`; + return `${getInlineHTMLPreview(element)}${composeElementContext(element, traceContext).replace(/\n\s+/g, " ")}`; }; export const formatElementInfo = async ( element: Element, options: StackContextOptions = {}, ): Promise => { - const resolvedElement = findNearestFiberElement(element); - const htmlPreview = getHTMLPreview(resolvedElement); - return composeElementContext( - resolvedElement, - htmlPreview, - await getTraceContext(resolvedElement, options), - ); + const nearestFiberElement = findNearestFiberElement(element); + const htmlPreview = getHTMLPreview(nearestFiberElement); + const traceContext = await getTraceContext(nearestFiberElement, options); + return `${htmlPreview}${composeElementContext(nearestFiberElement, traceContext)}`; }; diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index dd9112619..c4f90df33 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -3,6 +3,7 @@ import { copyContent } from "../utils/copy-content.js"; import { normalizeError } from "../utils/normalize-error.js"; import { getTagName } from "../utils/get-tag-name.js"; import type { StackFrame } from "bippy/source"; +import type { ReactGrabEntry, ReactGrabStackFrame } from "../types.js"; interface CopyFlowOptions { getContent?: (elements: Element[]) => Promise | string; @@ -17,36 +18,13 @@ interface CopyFlowHooks { onCopyError: (error: Error) => void; } -interface CopyPayloadEntry { - tagName?: string; - componentName?: string; - content: string; - commentText?: string; - source?: { - filePath: string; - lineNumber: number | null; - columnNumber: number | null; - componentName: string | null; - } | null; - stackContext?: string; - frames?: Array<{ - functionName?: string; - fileName?: string; - lineNumber?: number; - columnNumber?: number; - isServer?: boolean; - isSymbolicated?: boolean; - }>; -} - interface CopyPayload { content: string; - entries?: CopyPayloadEntry[]; + entries?: ReactGrabEntry[]; } -const formatStackFramePayload = ( - frame: StackFrame, -): NonNullable[number] => ({ +// Strips bippy's raw `source` stack-line text and `args` from the wire payload. +const formatStackFramePayload = (frame: StackFrame): ReactGrabStackFrame => ({ functionName: frame.functionName, fileName: frame.fileName, lineNumber: frame.lineNumber, @@ -55,17 +33,16 @@ const formatStackFramePayload = ( isSymbolicated: frame.isSymbolicated, }); -const buildElementPayloadEntry = async (element: Element): Promise => { +const buildElementPayloadEntry = async (element: Element): Promise => { const [referenceContext, source, stack] = await Promise.all([ getElementReferenceContext(element), resolveSource(element), getStack(element), ]); - const inlineReference = `[${referenceContext}]`; return { tagName: getTagName(element), componentName: source?.componentName ?? undefined, - content: inlineReference, + content: `[${referenceContext}]`, source, stackContext: referenceContext, frames: (stack ?? []).map(formatStackFramePayload), @@ -74,7 +51,7 @@ const buildElementPayloadEntry = async (element: Element): Promise => { const rawEntries = await Promise.all(elements.map(buildElementPayloadEntry)); - const entriesByContent = new Map(); + const entriesByContent = new Map(); for (const entry of rawEntries) { if (!entriesByContent.has(entry.content)) { entriesByContent.set(entry.content, entry); @@ -88,12 +65,11 @@ const buildClipboardPayload = async (elements: Element[]): Promise { +): ReactGrabEntry[] | undefined => { if (!payload?.entries) return undefined; - if (finalContent === rawContent) return payload.entries; + if (finalContent === payload.content) return payload.entries; if (payload.entries.length !== 1) return undefined; return [ { @@ -116,8 +92,8 @@ export const runCopyFlow = async ( let finalContent = ""; try { - const payload = options.getContent - ? { content: await options.getContent(elements), entries: undefined } + const payload: CopyPayload | null = options.getContent + ? { content: await options.getContent(elements) } : await buildClipboardPayload(elements); const rawContent = payload?.content; @@ -128,7 +104,7 @@ export const runCopyFlow = async ( : transformedContent; didCopy = copyContent(finalContent, { componentName: options.componentName, - entries: getMetadataEntries(payload, rawContent, finalContent, prependedPrompt), + entries: getMetadataEntries(payload, finalContent, prependedPrompt), }); } } catch (error) { diff --git a/packages/react-grab/src/core/html-preview.ts b/packages/react-grab/src/core/html-preview.ts index c30672c7a..18e82328c 100644 --- a/packages/react-grab/src/core/html-preview.ts +++ b/packages/react-grab/src/core/html-preview.ts @@ -10,40 +10,42 @@ import { truncateString } from "../utils/truncate-string.js"; import { isInternalAttribute } from "../utils/strip-internal-attributes.js"; import { getPreviewTextContent } from "../utils/get-preview-text-content.js"; -const truncateAttrValue = (value: string): string => - truncateString(value, PREVIEW_ATTR_VALUE_MAX_LENGTH); +const truncateAttrValue = (attributeValue: string): string => + truncateString(attributeValue, PREVIEW_ATTR_VALUE_MAX_LENGTH); const formatPriorityAttrs = (element: Element): string => { const priorityAttrs: string[] = []; - for (const name of PREVIEW_PRIORITY_ATTRS) { - const value = element.getAttribute(name); - if (value) priorityAttrs.push(`${name}="${value}"`); + for (const attributeName of PREVIEW_PRIORITY_ATTRS) { + const attributeValue = element.getAttribute(attributeName); + if (attributeValue) priorityAttrs.push(`${attributeName}="${attributeValue}"`); } return priorityAttrs.length > 0 ? ` ${priorityAttrs.join(" ")}` : ""; }; -const isClassOrStyleAttr = (name: string): boolean => - name === "class" || name === "className" || name === "style"; +const isClassOrStyleAttr = (attributeName: string): boolean => + attributeName === "class" || attributeName === "className" || attributeName === "style"; const formatAttrsForPreview = (element: Element): string => { const identifyingParts: string[] = []; const remainingParts: string[] = []; let classAttr = ""; - for (const { name, value } of element.attributes) { - if (isInternalAttribute(name)) continue; - if (isClassOrStyleAttr(name)) { - if (name !== "style" && value) { - classAttr = ` class="${truncateAttrValue(value)}"`; + for (const { name: attributeName, value: attributeValue } of element.attributes) { + if (isInternalAttribute(attributeName)) continue; + if (isClassOrStyleAttr(attributeName)) { + if (attributeName !== "style" && attributeValue) { + classAttr = ` class="${truncateAttrValue(attributeValue)}"`; } continue; } - if (PREVIEW_IDENTIFYING_ATTRS.has(name)) { - identifyingParts.push(value ? ` ${name}="${value}"` : ` ${name}`); - } else if (value) { - remainingParts.push(` ${name}="${truncateAttrValue(value)}"`); + if (PREVIEW_IDENTIFYING_ATTRS.has(attributeName)) { + identifyingParts.push( + attributeValue ? ` ${attributeName}="${attributeValue}"` : ` ${attributeName}`, + ); + } else if (attributeValue) { + remainingParts.push(` ${attributeName}="${truncateAttrValue(attributeValue)}"`); } } diff --git a/packages/react-grab/src/core/next-server-frames.ts b/packages/react-grab/src/core/next-server-frames.ts index acc7a5a73..631013e22 100644 --- a/packages/react-grab/src/core/next-server-frames.ts +++ b/packages/react-grab/src/core/next-server-frames.ts @@ -87,27 +87,27 @@ export const symbolicateServerFrames = async (frames: StackFrame[]): Promise { if (!frame.isServer || frame.fileName || !frame.functionName) return frame; - const resolved = serverFramesByName.get(frame.functionName); - if (!resolved) return frame; + const matchingServerFrame = serverFramesByName.get(frame.functionName); + if (!matchingServerFrame) return frame; return { ...frame, - fileName: resolved.fileName, - lineNumber: resolved.lineNumber, - columnNumber: resolved.columnNumber, + fileName: matchingServerFrame.fileName, + lineNumber: matchingServerFrame.lineNumber, + columnNumber: matchingServerFrame.columnNumber, }; }); }; diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 0c59662a1..8a79c9825 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -615,3 +615,26 @@ export interface SelectionLabelProps { onHoverChange?: (isHovered: boolean) => void; hideArrow?: boolean; } + +export interface SourceLocation extends SourceInfo { + columnNumber: number | null; +} + +export interface ReactGrabStackFrame { + functionName?: string; + fileName?: string; + lineNumber?: number; + columnNumber?: number; + isServer?: boolean; + isSymbolicated?: boolean; +} + +export interface ReactGrabEntry { + tagName?: string; + componentName?: string; + content: string; + commentText?: string; + source?: SourceLocation | null; + stackContext?: string; + frames?: ReactGrabStackFrame[]; +} diff --git a/packages/react-grab/src/utils/source-frame-policy.ts b/packages/react-grab/src/utils/classify-source-path.ts similarity index 100% rename from packages/react-grab/src/utils/source-frame-policy.ts rename to packages/react-grab/src/utils/classify-source-path.ts diff --git a/packages/react-grab/src/utils/copy-content.ts b/packages/react-grab/src/utils/copy-content.ts index fe5743735..3ac064232 100644 --- a/packages/react-grab/src/utils/copy-content.ts +++ b/packages/react-grab/src/utils/copy-content.ts @@ -1,33 +1,8 @@ import { VERSION } from "../constants.js"; +import type { ReactGrabEntry } from "../types.js"; const REACT_GRAB_MIME_TYPE = "application/x-react-grab"; -interface ReactGrabEntry { - tagName?: string; - componentName?: string; - content: string; - commentText?: string; - source?: ReactGrabSourceInfo | null; - stackContext?: string; - frames?: ReactGrabStackFrame[]; -} - -interface ReactGrabSourceInfo { - filePath: string; - lineNumber: number | null; - columnNumber: number | null; - componentName: string | null; -} - -interface ReactGrabStackFrame { - functionName?: string; - fileName?: string; - lineNumber?: number; - columnNumber?: number; - isServer?: boolean; - isSymbolicated?: boolean; -} - interface CopyContentOptions { componentName?: string; tagName?: string; diff --git a/packages/react-grab/tests/source-frame-policy.test.ts b/packages/react-grab/tests/classify-source-path.test.ts similarity index 96% rename from packages/react-grab/tests/source-frame-policy.test.ts rename to packages/react-grab/tests/classify-source-path.test.ts index b0c4b8a46..570d1bc05 100644 --- a/packages/react-grab/tests/source-frame-policy.test.ts +++ b/packages/react-grab/tests/classify-source-path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { classifySourcePath } from "../src/utils/source-frame-policy.js"; +import { classifySourcePath } from "../src/utils/classify-source-path.js"; describe("classifySourcePath", () => { it("classifies application source paths", () => { From fb29e48826083ab3b67280250b41da4abff93664 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Thu, 11 Jun 2026 22:53:58 -0700 Subject: [PATCH 62/65] Replace ignored-path handling with line budget extension --- packages/react-grab/src/core/context.ts | 38 +++++------ packages/react-grab/src/core/copy.ts | 7 +- .../src/utils/classify-source-path.ts | 11 +-- .../src/utils/parse-package-name.ts | 57 ++++++++++++---- .../tests/classify-source-path.test.ts | 22 +++--- packages/react-grab/tests/context.test.ts | 67 ++++++------------- 6 files changed, 100 insertions(+), 102 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 3fde67b33..0972b062b 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -120,8 +120,6 @@ const getFiberSource = async (element: Element): Promise columnNumber: source.columnNumber ?? null, componentName: toSourceComponentName(source.functionName) ?? getSourceComponentName(fiber._debugOwner), - // Classify the raw path: normalizeFilePath strips the leading "./" that - // scoped-package detection relies on. kind: classifySourcePath(source.fileName).kind, }; } catch { @@ -144,11 +142,7 @@ const getCachedFiberSource = (element: Element): Promise return fiberSourcePromise; }; -const SOURCE_KIND_PREFERENCE_ORDER = [ - "app-source", - "ignored-app-source", - "package-source", -] as const; +const SOURCE_KIND_PREFERENCE_ORDER = ["app-source", "package-source"] as const; export const selectResolvedSource = ( fiberSource: ResolvedSource | null, @@ -255,9 +249,9 @@ const formatStackFrameLine = ( isNextProject: boolean, ): StackFrameLine | null => { const libraryPackage = sourceClassification.packageName; - // Only app-owned frames contribute a file path. Ignored UI-wrapper frames - // render by component name (e.g. "in Button") so they stay as context without - // surfacing a wrapper path that would compete with the resolved app source. + // Only app-owned frames contribute a file path; library frames render by + // component name (e.g. "in Tabs (@radix-ui/react-tabs)") so node_modules + // paths never compete with the resolved app source. const appSourceFilePath = sourceClassification.kind === "app-source" ? frame.fileName : null; if (frame.isServer && !appSourceFilePath && (componentName || !frame.functionName)) { @@ -305,14 +299,15 @@ export const formatStackContext = ( leadingSource: ResolvedSource | null = null, ): TraceContextResult => { const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options; - // max, not min: the dig-past-low-signal cap must sit above the soft budget - // (min would collapse it onto maxLines and disable digging entirely). + // max, not min: the extended cap must sit above the soft budget (min would + // collapse it onto maxLines and disable extension entirely). const hardMaxLines = Math.max(maxLines, MAX_TRACE_CONTEXT_LINES); const isNextProject = isNextProjectRuntime(); const lines: string[] = []; let previousLibraryFrameKey: string | null = null; let didDedupeLeadingComponent = false; let hasTrustedSource = false; + let untrustedLineCount = 0; if (leadingSource) { hasTrustedSource = leadingSource.kind === "app-source"; @@ -320,9 +315,10 @@ export const formatStackContext = ( } for (const frame of stack) { - // Past the soft budget, keep digging until a trusted app frame or the hard cap. - if (lines.length >= hardMaxLines) break; - if (lines.length >= maxLines && hasTrustedSource) break; + // Low-signal lines (no app file path) do not consume the budget: each one + // extends the allowance by one line, up to the hard cap, so library noise + // never crowds out app source locations. + if (lines.length >= Math.min(maxLines + untrustedLineCount, hardMaxLines)) break; const sourceClassification = classifySourcePath(frame.fileName); @@ -353,6 +349,7 @@ export const formatStackContext = ( if (frameLine === null) continue; if (frameLine.isTrustedSource) hasTrustedSource = true; + else untrustedLineCount += 1; lines.push(frameLine.text); previousLibraryFrameKey = libraryFrameKey; } @@ -363,16 +360,11 @@ export const formatStackContext = ( }; }; -// Ignored components/ui sources are promoted to the leading line because their -// frames are dropped from the stack body yet still back the selection metadata, -// so the copied snippet would otherwise omit the resolved path. Package-only -// sources are never promoted: surfacing node_modules paths is what this avoids. +// Package sources are never promoted to the leading line: surfacing +// node_modules paths is what this avoids. const resolveLeadingSource = async (element: Element): Promise => { const fiberSource = await getCachedFiberSource(element); - if (fiberSource?.kind === "app-source") return fiberSource; - - const fallbackSource = selectResolvedSource(fiberSource, (await getStack(element)) ?? []); - return fallbackSource?.kind === "ignored-app-source" ? fallbackSource : null; + return fiberSource?.kind === "app-source" ? fiberSource : null; }; const getTraceContext = async ( diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index c4f90df33..de7bdb6e0 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,4 +1,4 @@ -import { getElementReferenceContext, getStack, resolveSource } from "./context.js"; +import { getElementReferenceContext, getStack, getStackContext, resolveSource } from "./context.js"; import { copyContent } from "../utils/copy-content.js"; import { normalizeError } from "../utils/normalize-error.js"; import { getTagName } from "../utils/get-tag-name.js"; @@ -34,8 +34,9 @@ const formatStackFramePayload = (frame: StackFrame): ReactGrabStackFrame => ({ }); const buildElementPayloadEntry = async (element: Element): Promise => { - const [referenceContext, source, stack] = await Promise.all([ + const [referenceContext, stackContext, source, stack] = await Promise.all([ getElementReferenceContext(element), + getStackContext(element), resolveSource(element), getStack(element), ]); @@ -44,7 +45,7 @@ const buildElementPayloadEntry = async (element: Element): Promise { @@ -21,9 +16,5 @@ export const classifySourcePath = ( if (!isSourceFile(fileName)) return { kind: "unknown", packageName: null }; - if (IGNORED_SOURCE_PATH_PATTERN.test(normalizeFilePath(fileName))) { - return { kind: "ignored-app-source", packageName: null }; - } - return { kind: "app-source", packageName: null }; }; diff --git a/packages/react-grab/src/utils/parse-package-name.ts b/packages/react-grab/src/utils/parse-package-name.ts index 7a0c44e3f..05abfbe0c 100644 --- a/packages/react-grab/src/utils/parse-package-name.ts +++ b/packages/react-grab/src/utils/parse-package-name.ts @@ -94,33 +94,65 @@ export const parsePackageName = (fileName: string | null | undefined): string | const SCOPED_PACKAGE_PATTERN = /^@[A-Za-z0-9][A-Za-z0-9._-]*$/; const PACKAGE_NAME_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; -// A relative scoped path like `../@acme/app/...` has no node_modules marker to -// prove it is third-party, so a monorepo's own app workspace would otherwise be -// misread as a package. Best-effort allowlist of common first-party app dirs. -const APPLICATION_PACKAGE_NAME_SEGMENTS = new Set(["app", "web", "website", "frontend", "client"]); - -const stripRelativeSourcePathPrefix = (path: string): string | null => { +// A scoped path like `@acme/app/...` or `../@acme/app/...` has no node_modules +// marker to prove it is third-party, so a monorepo's own app workspace would +// otherwise be misread as a package. Best-effort allowlist of common +// first-party workspace names. +const APPLICATION_PACKAGE_NAME_SEGMENTS = new Set([ + "app", + "web", + "website", + "frontend", + "client", + "src", +]); + +// Bundler aliases (`@app/components/...`, `@components/forms/...`) reuse the +// `@x/y` shape without being packages; real package scopes are org names, so +// scopes matching common app directory names are treated as aliases. +const ALIAS_SCOPE_SEGMENTS = new Set([ + "app", + "src", + "components", + "pages", + "features", + "modules", + "hooks", + "lib", + "utils", + "ui", + "shared", + "common", + "core", + "styles", + "assets", +]); + +const stripRelativeSourcePathPrefix = (path: string): string => { let remainingPath = path; - let didStripPrefix = false; while (remainingPath.startsWith("../") || remainingPath.startsWith("./")) { - didStripPrefix = true; remainingPath = remainingPath.slice(remainingPath.startsWith("../") ? 3 : 2); } - return didStripPrefix ? remainingPath : null; + return remainingPath; }; const parseScopedPackageSourceName = (fileName: string): string | null => { const sourcePath = stripRelativeSourcePathPrefix( safeDecodeURIComponent(normalizeFileName(fileName)), ); - if (!sourcePath) return null; + // Absolute paths (e.g. Vite's `/@fs/...`) point at first-party files; only + // relative or bare `@scope/package/...` paths can be unmarked dependencies. + if (sourcePath.startsWith("/")) return null; - const [scope, packageName] = splitPathSegments(sourcePath); + const [scope, packageName, ...innerPathSegments] = splitPathSegments(sourcePath); if ( !scope || !packageName || + innerPathSegments.length === 0 || !SCOPED_PACKAGE_PATTERN.test(scope) || + ALIAS_SCOPE_SEGMENTS.has(scope.slice(1)) || !PACKAGE_NAME_SEGMENT_PATTERN.test(packageName) || + FILE_EXTENSION_PATTERN.test(packageName) || APPLICATION_PACKAGE_NAME_SEGMENTS.has(packageName) ) { return null; @@ -130,7 +162,8 @@ const parseScopedPackageSourceName = (fileName: string): string | null => { }; // parsePackageName keys off node_modules/.vite/CDN markers; the scoped fallback -// handles bare relative imports like `../@acme/ui/...` that carry no marker. +// handles sourcemapped dependency paths like `../@acme/ui/...` or +// `@radix-ui/react-tabs/src/tabs.tsx` that carry no marker. export const resolvePackageName = (fileName: string | null | undefined): string | null => { if (!fileName) return null; return parsePackageName(fileName) ?? parseScopedPackageSourceName(fileName); diff --git a/packages/react-grab/tests/classify-source-path.test.ts b/packages/react-grab/tests/classify-source-path.test.ts index 570d1bc05..b4e63bd82 100644 --- a/packages/react-grab/tests/classify-source-path.test.ts +++ b/packages/react-grab/tests/classify-source-path.test.ts @@ -28,26 +28,30 @@ describe("classifySourcePath", () => { }); }); - // The relative prefix is the only signal distinguishing a scoped dependency - // import from a `@alias/...` path, so a normalized path (leading "./" removed) - // can no longer be detected as a package. Callers must classify the raw path. - it("only detects relative scoped packages while the relative prefix survives", () => { + it("detects scoped packages with or without a relative prefix", () => { expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx").kind).toBe("package-source"); - expect(classifySourcePath("@radix-ui/react-tabs/src/tabs.tsx").kind).not.toBe("package-source"); + expect(classifySourcePath("@radix-ui/react-tabs/src/tabs.tsx").kind).toBe("package-source"); }); - it("classifies default ignored app source paths", () => { + it("does not treat bundler alias paths as packages", () => { + expect(classifySourcePath("@app/components/tabs.tsx").kind).toBe("app-source"); + expect(classifySourcePath("@components/forms/input.tsx").kind).toBe("app-source"); + expect(classifySourcePath("@scope/tabs.tsx").kind).toBe("app-source"); + expect(classifySourcePath("/@fs/Users/dev/project/src/tabs.tsx").kind).toBe("app-source"); + }); + + it("classifies design-system wrapper paths as app source", () => { expect(classifySourcePath("components/ui/button.tsx")).toEqual({ - kind: "ignored-app-source", + kind: "app-source", packageName: null, }); expect(classifySourcePath("src/components/ui/dialog.tsx")).toEqual({ - kind: "ignored-app-source", + kind: "app-source", packageName: null, }); }); - it("does not ignore nearby app source paths", () => { + it("classifies monorepo workspace paths as app source", () => { expect(classifySourcePath("../@company/app/src/tabs.tsx")).toEqual({ kind: "app-source", packageName: null, diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index 637dedfb0..8cde2bccb 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -14,14 +14,6 @@ const fiberSource: ResolvedSource = { kind: "app-source", }; -const ignoredFiberSource: ResolvedSource = { - filePath: "/src/components/ui/button.tsx", - lineNumber: 1, - columnNumber: 1, - componentName: "Button", - kind: "ignored-app-source", -}; - const packageFiberSource: ResolvedSource = { filePath: "/app/node_modules/react-tabs/dist/index.js", lineNumber: 1, @@ -37,13 +29,6 @@ const appFrame: StackFrame = { columnNumber: 2, }; -const ignoredFrame: StackFrame = { - fileName: "/src/components/ui/button.tsx", - functionName: "Button", - lineNumber: 9, - columnNumber: 4, -}; - const packageFrame: StackFrame = { fileName: "/app/node_modules/react-tabs/dist/index.js", functionName: "Tabs", @@ -56,26 +41,13 @@ describe("selectResolvedSource", () => { expect(selectResolvedSource(fiberSource, [appFrame])).toBe(fiberSource); }); - it("prefers an app-source frame over a non-app fiber source", () => { - expect(selectResolvedSource(ignoredFiberSource, [appFrame])).toMatchObject({ + it("prefers an app-source frame over a package fiber source", () => { + expect(selectResolvedSource(packageFiberSource, [appFrame])).toMatchObject({ filePath: "/src/app/widget.tsx", componentName: "Widget", }); }); - it("prefers an ignored-app-source fiber over ignored or package frames", () => { - expect(selectResolvedSource(ignoredFiberSource, [ignoredFrame, packageFrame])).toBe( - ignoredFiberSource, - ); - }); - - it("falls back to an ignored-app-source frame over a package frame", () => { - expect(selectResolvedSource(null, [ignoredFrame, packageFrame])).toMatchObject({ - filePath: "/src/components/ui/button.tsx", - componentName: "Button", - }); - }); - it("prefers a package-source fiber over package frames", () => { expect(selectResolvedSource(packageFiberSource, [packageFrame])).toBe(packageFiberSource); }); @@ -106,26 +78,37 @@ describe("selectResolvedSource", () => { }); describe("formatStackContext", () => { - it("keeps UI-component frames by name while still surfacing the app frame", () => { + it("surfaces design-system wrapper frames with their file path", () => { const result = formatStackContext([ { fileName: "src/components/ui/button.tsx", functionName: "Button" }, { fileName: "src/app/page.tsx", functionName: "Page" }, ]); expect(result.text).toContain("in Button"); - expect(result.text).not.toContain("button.tsx"); + expect(result.text).toContain("components/ui/button.tsx"); expect(result.text).toContain("in Page"); expect(result.text).toContain("app/page.tsx"); }); - it("omits anonymous UI-component frames that carry no name", () => { - const result = formatStackContext([ - { fileName: "src/components/ui/button.tsx" }, - { fileName: "src/app/page.tsx", functionName: "Page" }, - ]); + it("extends the line budget by one per low-signal package line", () => { + const result = formatStackContext( + [ + { fileName: "node_modules/react-tabs/dist/index.js", functionName: "Tabs" }, + { fileName: "src/app/widget.tsx", functionName: "Widget" }, + { fileName: "src/app/section.tsx", functionName: "Section" }, + { fileName: "src/app/page.tsx", functionName: "Page" }, + { fileName: "src/app/layout.tsx", functionName: "Layout" }, + ], + { maxLines: 3 }, + ); - expect(result.text).not.toContain("button.tsx"); - expect(result.text).toContain("in Page"); + const lines = result.text.split("\n").filter(Boolean); + expect(lines).toHaveLength(4); + expect(result.text).toContain("in Tabs (react-tabs)"); + expect(result.text).toContain("app/widget.tsx"); + expect(result.text).toContain("app/section.tsx"); + expect(result.text).toContain("app/page.tsx"); + expect(result.text).not.toContain("app/layout.tsx"); }); it("digs past low-signal package frames to surface a deeper app source", () => { @@ -146,10 +129,4 @@ describe("formatStackContext", () => { expect(result.shouldAppendSelectorHint).toBe(false); }); - - it("requests a selector hint for an ignored components/ui leading source", () => { - const result = formatStackContext([], {}, ignoredFiberSource); - - expect(result.shouldAppendSelectorHint).toBe(true); - }); }); From 830693d52ea2a92860d863e0db810be4f51fe0aa Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 12 Jun 2026 00:24:53 -0700 Subject: [PATCH 63/65] Rename source classification kind to source with shorter values --- packages/react-grab/src/core/context.ts | 26 ++++++++-------- .../src/utils/classify-source-path.ts | 10 +++---- .../tests/classify-source-path.test.ts | 30 +++++++++---------- packages/react-grab/tests/context.test.ts | 6 ++-- 4 files changed, 37 insertions(+), 35 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 0972b062b..b0607f589 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -91,7 +91,7 @@ export const getNearestComponentName = async (element: Element): Promise { @@ -120,7 +120,7 @@ const getFiberSource = async (element: Element): Promise columnNumber: source.columnNumber ?? null, componentName: toSourceComponentName(source.functionName) ?? getSourceComponentName(fiber._debugOwner), - kind: classifySourcePath(source.fileName).kind, + source: classifySourcePath(source.fileName).source, }; } catch { return null; @@ -142,23 +142,25 @@ const getCachedFiberSource = (element: Element): Promise return fiberSourcePromise; }; -const SOURCE_KIND_PREFERENCE_ORDER = ["app-source", "package-source"] as const; +const SOURCE_PREFERENCE_ORDER = ["app", "package"] as const; export const selectResolvedSource = ( fiberSource: ResolvedSource | null, stack: StackFrame[], ): ResolvedSource | null => { - for (const kind of SOURCE_KIND_PREFERENCE_ORDER) { - if (fiberSource?.kind === kind) return fiberSource; - const framesOfKind = stack.filter((frame) => classifySourcePath(frame.fileName).kind === kind); - const preferredFrame = pickSourceFrame(framesOfKind); + for (const source of SOURCE_PREFERENCE_ORDER) { + if (fiberSource?.source === source) return fiberSource; + const framesOfSource = stack.filter( + (frame) => classifySourcePath(frame.fileName).source === source, + ); + const preferredFrame = pickSourceFrame(framesOfSource); if (preferredFrame?.fileName) { return { filePath: normalizeFilePath(preferredFrame.fileName), lineNumber: preferredFrame.lineNumber ?? null, columnNumber: preferredFrame.columnNumber ?? null, componentName: toSourceComponentName(preferredFrame.functionName), - kind, + source, }; } } @@ -167,7 +169,7 @@ export const selectResolvedSource = ( export const resolveSource = async (element: Element): Promise => { const fiberSource = await getCachedFiberSource(element); - if (fiberSource?.kind === "app-source") return fiberSource; + if (fiberSource?.source === "app") return fiberSource; return selectResolvedSource(fiberSource, (await getStack(element)) ?? []); }; @@ -252,7 +254,7 @@ const formatStackFrameLine = ( // Only app-owned frames contribute a file path; library frames render by // component name (e.g. "in Tabs (@radix-ui/react-tabs)") so node_modules // paths never compete with the resolved app source. - const appSourceFilePath = sourceClassification.kind === "app-source" ? frame.fileName : null; + const appSourceFilePath = sourceClassification.source === "app" ? frame.fileName : null; if (frame.isServer && !appSourceFilePath && (componentName || !frame.functionName)) { const serverTag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; @@ -310,7 +312,7 @@ export const formatStackContext = ( let untrustedLineCount = 0; if (leadingSource) { - hasTrustedSource = leadingSource.kind === "app-source"; + hasTrustedSource = leadingSource.source === "app"; lines.push(formatSourceContextLine(leadingSource, isNextProject)); } @@ -364,7 +366,7 @@ export const formatStackContext = ( // node_modules paths is what this avoids. const resolveLeadingSource = async (element: Element): Promise => { const fiberSource = await getCachedFiberSource(element); - return fiberSource?.kind === "app-source" ? fiberSource : null; + return fiberSource?.source === "app" ? fiberSource : null; }; const getTraceContext = async ( diff --git a/packages/react-grab/src/utils/classify-source-path.ts b/packages/react-grab/src/utils/classify-source-path.ts index 4476681e6..ea7de45a2 100644 --- a/packages/react-grab/src/utils/classify-source-path.ts +++ b/packages/react-grab/src/utils/classify-source-path.ts @@ -2,19 +2,19 @@ import { isSourceFile } from "bippy/source"; import { resolvePackageName } from "./parse-package-name.js"; export interface SourcePathClassification { - kind: "app-source" | "package-source" | "unknown"; + source: "app" | "package" | "unknown"; packageName: string | null; } export const classifySourcePath = ( fileName: string | null | undefined, ): SourcePathClassification => { - if (!fileName) return { kind: "unknown", packageName: null }; + if (!fileName) return { source: "unknown", packageName: null }; const packageName = resolvePackageName(fileName); - if (packageName) return { kind: "package-source", packageName }; + if (packageName) return { source: "package", packageName }; - if (!isSourceFile(fileName)) return { kind: "unknown", packageName: null }; + if (!isSourceFile(fileName)) return { source: "unknown", packageName: null }; - return { kind: "app-source", packageName: null }; + return { source: "app", packageName: null }; }; diff --git a/packages/react-grab/tests/classify-source-path.test.ts b/packages/react-grab/tests/classify-source-path.test.ts index b4e63bd82..4145ac119 100644 --- a/packages/react-grab/tests/classify-source-path.test.ts +++ b/packages/react-grab/tests/classify-source-path.test.ts @@ -4,60 +4,60 @@ import { classifySourcePath } from "../src/utils/classify-source-path.js"; describe("classifySourcePath", () => { it("classifies application source paths", () => { expect(classifySourcePath("src/app/employees/employee-tabs.tsx")).toEqual({ - kind: "app-source", + source: "app", packageName: null, }); }); it("classifies package source paths", () => { expect(classifySourcePath("../@rippling/pebble/Tabs/Renderers.js")).toEqual({ - kind: "package-source", + source: "package", packageName: "@rippling/pebble", }); expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx")).toEqual({ - kind: "package-source", + source: "package", packageName: "@radix-ui/react-tabs", }); expect(classifySourcePath("/app/node_modules/@radix-ui/react-tabs/dist/index.min.js")).toEqual({ - kind: "package-source", + source: "package", packageName: "@radix-ui/react-tabs", }); expect(classifySourcePath("/app/node_modules/react-tabs/dist/index.js")).toEqual({ - kind: "package-source", + source: "package", packageName: "react-tabs", }); }); it("detects scoped packages with or without a relative prefix", () => { - expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx").kind).toBe("package-source"); - expect(classifySourcePath("@radix-ui/react-tabs/src/tabs.tsx").kind).toBe("package-source"); + expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx").source).toBe("package"); + expect(classifySourcePath("@radix-ui/react-tabs/src/tabs.tsx").source).toBe("package"); }); it("does not treat bundler alias paths as packages", () => { - expect(classifySourcePath("@app/components/tabs.tsx").kind).toBe("app-source"); - expect(classifySourcePath("@components/forms/input.tsx").kind).toBe("app-source"); - expect(classifySourcePath("@scope/tabs.tsx").kind).toBe("app-source"); - expect(classifySourcePath("/@fs/Users/dev/project/src/tabs.tsx").kind).toBe("app-source"); + expect(classifySourcePath("@app/components/tabs.tsx").source).toBe("app"); + expect(classifySourcePath("@components/forms/input.tsx").source).toBe("app"); + expect(classifySourcePath("@scope/tabs.tsx").source).toBe("app"); + expect(classifySourcePath("/@fs/Users/dev/project/src/tabs.tsx").source).toBe("app"); }); it("classifies design-system wrapper paths as app source", () => { expect(classifySourcePath("components/ui/button.tsx")).toEqual({ - kind: "app-source", + source: "app", packageName: null, }); expect(classifySourcePath("src/components/ui/dialog.tsx")).toEqual({ - kind: "app-source", + source: "app", packageName: null, }); }); it("classifies monorepo workspace paths as app source", () => { expect(classifySourcePath("../@company/app/src/tabs.tsx")).toEqual({ - kind: "app-source", + source: "app", packageName: null, }); expect(classifySourcePath("src/components/ui-button.tsx")).toEqual({ - kind: "app-source", + source: "app", packageName: null, }); }); diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index 8cde2bccb..f3cb1748a 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -11,7 +11,7 @@ const fiberSource: ResolvedSource = { lineNumber: 1, columnNumber: 1, componentName: "Page", - kind: "app-source", + source: "app", }; const packageFiberSource: ResolvedSource = { @@ -19,7 +19,7 @@ const packageFiberSource: ResolvedSource = { lineNumber: 1, columnNumber: 1, componentName: "Tabs", - kind: "package-source", + source: "package", }; const appFrame: StackFrame = { @@ -63,7 +63,7 @@ describe("selectResolvedSource", () => { expect(selectResolvedSource(null, [])).toBe(null); }); - it("picks the first named frame within a kind over an earlier anonymous frame", () => { + it("picks the first named frame within a source type over an earlier anonymous frame", () => { const anonymousFrame: StackFrame = { fileName: "/src/app/anonymous.tsx", lineNumber: 2, From 80a506b2886e45ca52c098ad7030d6dfb9e8d9d9 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 12 Jun 2026 01:57:45 -0700 Subject: [PATCH 64/65] Rename source classification field to origin --- packages/react-grab/src/core/context.ts | 26 ++++++++-------- .../src/utils/classify-source-path.ts | 10 +++---- .../tests/classify-source-path.test.ts | 30 +++++++++---------- packages/react-grab/tests/context.test.ts | 6 ++-- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index b0607f589..2ac18051f 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -91,7 +91,7 @@ export const getNearestComponentName = async (element: Element): Promise { @@ -120,7 +120,7 @@ const getFiberSource = async (element: Element): Promise columnNumber: source.columnNumber ?? null, componentName: toSourceComponentName(source.functionName) ?? getSourceComponentName(fiber._debugOwner), - source: classifySourcePath(source.fileName).source, + origin: classifySourcePath(source.fileName).origin, }; } catch { return null; @@ -142,25 +142,25 @@ const getCachedFiberSource = (element: Element): Promise return fiberSourcePromise; }; -const SOURCE_PREFERENCE_ORDER = ["app", "package"] as const; +const ORIGIN_PREFERENCE_ORDER = ["app", "package"] as const; export const selectResolvedSource = ( fiberSource: ResolvedSource | null, stack: StackFrame[], ): ResolvedSource | null => { - for (const source of SOURCE_PREFERENCE_ORDER) { - if (fiberSource?.source === source) return fiberSource; - const framesOfSource = stack.filter( - (frame) => classifySourcePath(frame.fileName).source === source, + for (const origin of ORIGIN_PREFERENCE_ORDER) { + if (fiberSource?.origin === origin) return fiberSource; + const framesOfOrigin = stack.filter( + (frame) => classifySourcePath(frame.fileName).origin === origin, ); - const preferredFrame = pickSourceFrame(framesOfSource); + const preferredFrame = pickSourceFrame(framesOfOrigin); if (preferredFrame?.fileName) { return { filePath: normalizeFilePath(preferredFrame.fileName), lineNumber: preferredFrame.lineNumber ?? null, columnNumber: preferredFrame.columnNumber ?? null, componentName: toSourceComponentName(preferredFrame.functionName), - source, + origin, }; } } @@ -169,7 +169,7 @@ export const selectResolvedSource = ( export const resolveSource = async (element: Element): Promise => { const fiberSource = await getCachedFiberSource(element); - if (fiberSource?.source === "app") return fiberSource; + if (fiberSource?.origin === "app") return fiberSource; return selectResolvedSource(fiberSource, (await getStack(element)) ?? []); }; @@ -254,7 +254,7 @@ const formatStackFrameLine = ( // Only app-owned frames contribute a file path; library frames render by // component name (e.g. "in Tabs (@radix-ui/react-tabs)") so node_modules // paths never compete with the resolved app source. - const appSourceFilePath = sourceClassification.source === "app" ? frame.fileName : null; + const appSourceFilePath = sourceClassification.origin === "app" ? frame.fileName : null; if (frame.isServer && !appSourceFilePath && (componentName || !frame.functionName)) { const serverTag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; @@ -312,7 +312,7 @@ export const formatStackContext = ( let untrustedLineCount = 0; if (leadingSource) { - hasTrustedSource = leadingSource.source === "app"; + hasTrustedSource = leadingSource.origin === "app"; lines.push(formatSourceContextLine(leadingSource, isNextProject)); } @@ -366,7 +366,7 @@ export const formatStackContext = ( // node_modules paths is what this avoids. const resolveLeadingSource = async (element: Element): Promise => { const fiberSource = await getCachedFiberSource(element); - return fiberSource?.source === "app" ? fiberSource : null; + return fiberSource?.origin === "app" ? fiberSource : null; }; const getTraceContext = async ( diff --git a/packages/react-grab/src/utils/classify-source-path.ts b/packages/react-grab/src/utils/classify-source-path.ts index ea7de45a2..c1d301e8d 100644 --- a/packages/react-grab/src/utils/classify-source-path.ts +++ b/packages/react-grab/src/utils/classify-source-path.ts @@ -2,19 +2,19 @@ import { isSourceFile } from "bippy/source"; import { resolvePackageName } from "./parse-package-name.js"; export interface SourcePathClassification { - source: "app" | "package" | "unknown"; + origin: "app" | "package" | "unknown"; packageName: string | null; } export const classifySourcePath = ( fileName: string | null | undefined, ): SourcePathClassification => { - if (!fileName) return { source: "unknown", packageName: null }; + if (!fileName) return { origin: "unknown", packageName: null }; const packageName = resolvePackageName(fileName); - if (packageName) return { source: "package", packageName }; + if (packageName) return { origin: "package", packageName }; - if (!isSourceFile(fileName)) return { source: "unknown", packageName: null }; + if (!isSourceFile(fileName)) return { origin: "unknown", packageName: null }; - return { source: "app", packageName: null }; + return { origin: "app", packageName: null }; }; diff --git a/packages/react-grab/tests/classify-source-path.test.ts b/packages/react-grab/tests/classify-source-path.test.ts index 4145ac119..32006ee2a 100644 --- a/packages/react-grab/tests/classify-source-path.test.ts +++ b/packages/react-grab/tests/classify-source-path.test.ts @@ -4,60 +4,60 @@ import { classifySourcePath } from "../src/utils/classify-source-path.js"; describe("classifySourcePath", () => { it("classifies application source paths", () => { expect(classifySourcePath("src/app/employees/employee-tabs.tsx")).toEqual({ - source: "app", + origin: "app", packageName: null, }); }); it("classifies package source paths", () => { expect(classifySourcePath("../@rippling/pebble/Tabs/Renderers.js")).toEqual({ - source: "package", + origin: "package", packageName: "@rippling/pebble", }); expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx")).toEqual({ - source: "package", + origin: "package", packageName: "@radix-ui/react-tabs", }); expect(classifySourcePath("/app/node_modules/@radix-ui/react-tabs/dist/index.min.js")).toEqual({ - source: "package", + origin: "package", packageName: "@radix-ui/react-tabs", }); expect(classifySourcePath("/app/node_modules/react-tabs/dist/index.js")).toEqual({ - source: "package", + origin: "package", packageName: "react-tabs", }); }); it("detects scoped packages with or without a relative prefix", () => { - expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx").source).toBe("package"); - expect(classifySourcePath("@radix-ui/react-tabs/src/tabs.tsx").source).toBe("package"); + expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx").origin).toBe("package"); + expect(classifySourcePath("@radix-ui/react-tabs/src/tabs.tsx").origin).toBe("package"); }); it("does not treat bundler alias paths as packages", () => { - expect(classifySourcePath("@app/components/tabs.tsx").source).toBe("app"); - expect(classifySourcePath("@components/forms/input.tsx").source).toBe("app"); - expect(classifySourcePath("@scope/tabs.tsx").source).toBe("app"); - expect(classifySourcePath("/@fs/Users/dev/project/src/tabs.tsx").source).toBe("app"); + expect(classifySourcePath("@app/components/tabs.tsx").origin).toBe("app"); + expect(classifySourcePath("@components/forms/input.tsx").origin).toBe("app"); + expect(classifySourcePath("@scope/tabs.tsx").origin).toBe("app"); + expect(classifySourcePath("/@fs/Users/dev/project/src/tabs.tsx").origin).toBe("app"); }); it("classifies design-system wrapper paths as app source", () => { expect(classifySourcePath("components/ui/button.tsx")).toEqual({ - source: "app", + origin: "app", packageName: null, }); expect(classifySourcePath("src/components/ui/dialog.tsx")).toEqual({ - source: "app", + origin: "app", packageName: null, }); }); it("classifies monorepo workspace paths as app source", () => { expect(classifySourcePath("../@company/app/src/tabs.tsx")).toEqual({ - source: "app", + origin: "app", packageName: null, }); expect(classifySourcePath("src/components/ui-button.tsx")).toEqual({ - source: "app", + origin: "app", packageName: null, }); }); diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts index f3cb1748a..2d53b52c6 100644 --- a/packages/react-grab/tests/context.test.ts +++ b/packages/react-grab/tests/context.test.ts @@ -11,7 +11,7 @@ const fiberSource: ResolvedSource = { lineNumber: 1, columnNumber: 1, componentName: "Page", - source: "app", + origin: "app", }; const packageFiberSource: ResolvedSource = { @@ -19,7 +19,7 @@ const packageFiberSource: ResolvedSource = { lineNumber: 1, columnNumber: 1, componentName: "Tabs", - source: "package", + origin: "package", }; const appFrame: StackFrame = { @@ -63,7 +63,7 @@ describe("selectResolvedSource", () => { expect(selectResolvedSource(null, [])).toBe(null); }); - it("picks the first named frame within a source type over an earlier anonymous frame", () => { + it("picks the first named frame within an origin over an earlier anonymous frame", () => { const anonymousFrame: StackFrame = { fileName: "/src/app/anonymous.tsx", lineNumber: 2, From a149248bbf9e795aa2654627a70095a85bbff64d Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Fri, 12 Jun 2026 04:15:23 -0700 Subject: [PATCH 65/65] Keep per-entry metadata when multi-element copy content is transformed --- packages/react-grab/e2e/selection.spec.ts | 4 +++- packages/react-grab/src/core/context.ts | 17 ++++++++++------- packages/react-grab/src/core/copy.ts | 20 ++++++++++++-------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/react-grab/e2e/selection.spec.ts b/packages/react-grab/e2e/selection.spec.ts index af75321dc..608995e3d 100644 --- a/packages/react-grab/e2e/selection.spec.ts +++ b/packages/react-grab/e2e/selection.spec.ts @@ -57,7 +57,9 @@ test.describe("Element Selection", () => { expect(clipboardMetadata.content).toContain("Todo List"); expect(clipboardMetadata.entries).toHaveLength(1); expect(clipboardMetadata.entries[0].content).toContain("Todo List"); - expect(clipboardMetadata.entries[0]).toHaveProperty("source"); + // The e2e dev server produces owner-stack frames without file names, so no + // source can resolve here; pin the explicit null to catch shape drift. + expect(clipboardMetadata.entries[0]).toHaveProperty("source", null); expect(clipboardMetadata.entries[0].stackContext).toContain("TodoList"); expect(Array.isArray(clipboardMetadata.entries[0].frames)).toBe(true); expect(clipboardMetadata.entries[0].frames.length).toBeGreaterThan(0); diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 2ac18051f..e65c78f98 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -309,18 +309,19 @@ export const formatStackContext = ( let previousLibraryFrameKey: string | null = null; let didDedupeLeadingComponent = false; let hasTrustedSource = false; - let untrustedLineCount = 0; + let budgetedLineCount = 0; if (leadingSource) { hasTrustedSource = leadingSource.origin === "app"; + budgetedLineCount += 1; lines.push(formatSourceContextLine(leadingSource, isNextProject)); } for (const frame of stack) { - // Low-signal lines (no app file path) do not consume the budget: each one - // extends the allowance by one line, up to the hard cap, so library noise - // never crowds out app source locations. - if (lines.length >= Math.min(maxLines + untrustedLineCount, hardMaxLines)) break; + // Low-signal lines (no app file path) are free: they never consume the + // soft budget, only the hard cap, so library noise never crowds out app + // source locations. + if (budgetedLineCount >= maxLines || lines.length >= hardMaxLines) break; const sourceClassification = classifySourcePath(frame.fileName); @@ -350,8 +351,10 @@ export const formatStackContext = ( ); if (frameLine === null) continue; - if (frameLine.isTrustedSource) hasTrustedSource = true; - else untrustedLineCount += 1; + if (frameLine.isTrustedSource) { + hasTrustedSource = true; + budgetedLineCount += 1; + } lines.push(frameLine.text); previousLibraryFrameKey = libraryFrameKey; } diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index de7bdb6e0..203c76495 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -71,14 +71,18 @@ const getMetadataEntries = ( ): ReactGrabEntry[] | undefined => { if (!payload?.entries) return undefined; if (finalContent === payload.content) return payload.entries; - if (payload.entries.length !== 1) return undefined; - return [ - { - ...payload.entries[0], - content: finalContent, - commentText: prependedPrompt, - }, - ]; + if (payload.entries.length === 1) { + return [ + { + ...payload.entries[0], + content: finalContent, + commentText: prependedPrompt, + }, + ]; + } + // Transformed multi-element content no longer maps 1:1 onto entries, so keep + // each entry's own reference content and only attach the prompt. + return payload.entries.map((entry) => ({ ...entry, commentText: prependedPrompt })); }; export const runCopyFlow = async (