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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/e2e-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"preview": "vp preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"lucide-react": "^1.14.0",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-grab": "workspace:*"
Expand Down
35 changes: 35 additions & 0 deletions apps/e2e-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { SquareIcon } from "lucide-react";
import * as Dialog from "@radix-ui/react-dialog";

interface Todo {
id: number;
Expand Down Expand Up @@ -585,6 +587,35 @@ const PointerUpModalSection = () => {
);
};

const LibraryIconSection = () => {
return (
<section className="border rounded-lg p-4" data-testid="library-icon-section">
<h2 className="text-lg font-bold mb-4">Library Icon</h2>
<div data-testid="library-icon-host" className="inline-flex">
<SquareIcon size={24} aria-hidden="true" />
</div>
</section>
);
};

const RadixDialogSection = () => {
return (
<section className="border rounded-lg p-4" data-testid="radix-dialog-section">
<h2 className="text-lg font-bold mb-4">Radix Dialog (scoped library)</h2>
<div data-testid="radix-dialog-host" className="inline-flex">
<Dialog.Root>
<Dialog.Trigger
data-testid="radix-dialog-trigger"
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Open Dialog
</Dialog.Trigger>
</Dialog.Root>
</div>
</section>
);
};

const HiddenToggleSection = () => {
const [isVisible, setIsVisible] = useState(true);
const elementRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -648,6 +679,10 @@ export default function App() {

<PointerUpModalSection />

<LibraryIconSection />

<RadixDialogSection />

<HiddenToggleSection />

<div
Expand Down
43 changes: 43 additions & 0 deletions packages/react-grab/e2e/element-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,49 @@ test.describe("Element Context Fallback", () => {
const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toContain("TodoItem");
});

test("should include third-party component name for library icons", async ({ reactGrab }) => {
await reactGrab.activate();

const icon = "[data-testid='library-icon-host'] svg";
await reactGrab.hoverElement(icon);
await reactGrab.waitForSelectionBox();
await reactGrab.clickElement(icon);

const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toContain("<svg");
// lucide-react ships the component under the display name "Square"
// (SquareIcon is just an export alias). Without the third-party
// component fix, this frame would be filtered out entirely and the
// stack would jump straight to LibraryIconSection.
expect(clipboard).toMatch(/in\s+Square\b/);
// The library frame should also be tagged with the originating package
// (parsed from its node_modules file path) so the agent knows where the
// component came from without us leaking the bundled file path.
expect(clipboard).toMatch(/in\s+Square\s+\(lucide-react\)/);
expect(clipboard).toContain("LibraryIconSection");
});

test("should preserve the scope when tagging frames from scoped packages", async ({
reactGrab,
}) => {
await reactGrab.activate();

const trigger = "[data-testid='radix-dialog-trigger']";
await reactGrab.hoverElement(trigger);
await reactGrab.waitForSelectionBox();
await reactGrab.clickElement(trigger);

const clipboard = await reactGrab.getClipboardContent();
// Vite's optimized-deps directory flattens scoped packages
// (`@radix-ui_react-dialog.js`); the parser must round-trip that back
// to the canonical `@scope/name` form, not drop the scope or the slash.
expect(clipboard).toMatch(/\(@radix-ui\/react-dialog\)/);
// The user's wrapper component must still survive the maxLines budget
// even with a library frame consuming a slot — this is the coalescing
// guarantee in action.
expect(clipboard).toContain("RadixDialogSection");
});
});

test.describe("Non-React Elements Fallback", () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/react-grab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@
"prebuild": "mkdir -p dist && tailwindcss -i ./src/styles.css -o ./dist/styles.css -m && tsx scripts/css-rem-to-px.ts",
"build": "NODE_ENV=production vp pack && cp dist/index.iife.js dist/index.global.js",
"dev": "concurrently \"pnpm:css:watch\" \"vp pack --watch\"",
"test": "playwright test",
"test": "vp test run && playwright test",
"test:unit": "vp test run",
"test:expect": "bun e2e/react-grab.expect.ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm build",
Expand Down
86 changes: 52 additions & 34 deletions packages/react-grab/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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 { normalizeFilePath } from "../utils/normalize-file-path.js";
import { parsePackageName } from "../utils/parse-package-name.js";
import { isInternalAttribute } from "../utils/strip-internal-attributes.js";

const NON_COMPONENT_PREFIXES = new Set([
Expand Down Expand Up @@ -393,6 +394,7 @@ const hasFormattableFrames = (stack: StackFrame[] | null): boolean => {
if (frame.isServer && (!frame.functionName || isSourceComponentName(frame.functionName))) {
return true;
}
if (frame.functionName && isSourceComponentName(frame.functionName)) return true;
return false;
});
};
Expand Down Expand Up @@ -420,54 +422,70 @@ const getComponentNamesFromFiber = (element: Element, maxCount: number): string[
return componentNames;
};

const formatResolvedSourceLine = (
frame: StackFrame,
filePath: string,
componentName: string | null,
isNextProject: boolean,
): string => {
// HACK: bundlers like Vite produce unreliable line/column numbers from
// owner stacks, so we only include them for Next.js where the dev server
// symbolicates frames via source maps.
const location =
isNextProject && frame.lineNumber
? `${normalizeFilePath(filePath)}:${frame.lineNumber}${frame.columnNumber ? `:${frame.columnNumber}` : ""}`
: normalizeFilePath(filePath);
return componentName ? `\n in ${componentName} (at ${location})` : `\n in ${location}`;
};

const formatStackContext = (stack: StackFrame[], options: StackContextOptions = {}): string => {
const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options;
const isNextProject = checkIsNextProject();
const formattedLines: string[] = [];
const lines: string[] = [];
// Tracks the last library we emitted so consecutive same-package frames
// (a deeply nested Radix/MUI tree) collapse to one line and don't evict
// the user's own component frames from the tight maxLines budget.
let previousLibraryPackage: string | null = null;

const emit = (line: string, libraryPackage: string | null) => {
lines.push(line);
previousLibraryPackage = libraryPackage;
};

for (const frame of stack) {
if (formattedLines.length >= maxLines) break;
if (lines.length >= maxLines) break;

const hasResolvedSource = frame.fileName && isSourceFile(frame.fileName);
const resolvedSource = frame.fileName && isSourceFile(frame.fileName) ? frame.fileName : null;
const libraryPackage = resolvedSource ? null : parsePackageName(frame.fileName);
if (libraryPackage && libraryPackage === previousLibraryPackage) continue;

if (
frame.isServer &&
!hasResolvedSource &&
(!frame.functionName || isSourceComponentName(frame.functionName))
) {
formattedLines.push(`\n in ${frame.functionName || "<anonymous>"} (at Server)`);
const componentName =
frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null;

if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) {
const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server";
emit(`\n in ${componentName ?? "<anonymous>"} (${tag})`, libraryPackage);
continue;
}

if (hasResolvedSource) {
let line = "\n in ";
const hasComponentName = frame.functionName && isSourceComponentName(frame.functionName);

if (hasComponentName) {
line += `${frame.functionName} (at `;
}

line += normalizeFilePath(frame.fileName!);

// HACK: bundlers like Vite produce unreliable line/column numbers from
// owner stacks, so we only include them for Next.js where the dev
// server symbolicates frames via source maps.
if (isNextProject && frame.lineNumber) {
line += `:${frame.lineNumber}`;
if (frame.columnNumber) {
line += `:${frame.columnNumber}`;
}
}

if (hasComponentName) {
line += `)`;
}
// Library frames (from node_modules, vendor bundles, etc.) bypass the
// user-source filter so the agent still sees names like `SquareIcon`
// that the user actually selected, tagged with the originating package
// when we can recover it from the file path.
if (!resolvedSource && componentName) {
emit(
libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`,
libraryPackage,
);
continue;
}

formattedLines.push(line);
if (resolvedSource) {
emit(formatResolvedSourceLine(frame, resolvedSource, componentName, isNextProject), null);
}
}

return formattedLines.join("");
return lines.join("");
};

export const getStackContext = async (
Expand Down
126 changes: 126 additions & 0 deletions packages/react-grab/src/utils/parse-package-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { normalizeFileName } from "bippy/source";

// Anchored to a path boundary (start of string, `/`, or `\`) so we don't
// accidentally match inside a longer identifier like `mynode_modules/`.
// Uses the global flag because callers iterate to find the *last* match
// (which collapses pnpm's `.pnpm/<pkg>@<ver>/node_modules/<pkg>/...`,
// Yarn PnP's `.zip/node_modules/<pkg>/...`, and Bun's
// `.bun/<pkg>@<ver>@@@1/node_modules/<pkg>/...` to the real package layer).
const NODE_MODULES_REGEX = /(?:^|[/\\])node_modules[/\\]/g;

// Vite's pre-bundled optimized deps live at `node_modules/.vite/deps/`, but
// while the dev server re-optimizes it temporarily writes to `deps_temp/`
// (or `deps_temp_<hash>/` on force re-optimize in Vite 5+). The trailing
// suffix is opaque to us, so the regex captures any `deps*` sibling.
const VITE_OPTIMIZED_DEPS_REGEX = /[/\\]\.vite[/\\]deps[^/\\]*[/\\]/g;

const FILE_EXTENSION_REGEX = /\.[mc]?[jt]sx?$/i;
const VITE_INTERNAL_CHUNK_REGEX = /^chunk-[A-Za-z0-9_-]+$/;
const PATH_SEPARATOR_REGEX = /[/\\]/;

// Captures the `<name>` from a `<name>@<version>` segment, where `<version>`
// starts with a digit (`react@19.0.0`, `lodash@4`) or `v<digit>` (skypack's
// pinned URLs like `lucide-react@v1.14.0-abcdef`). Strict enough to reject
// incidental `@` occurrences in user paths (`me@work`, `foo@bar.com`,
// twitter `@handle`) without needing a hardcoded allow-list of CDN hosts
// that would rot as new CDNs appear.
const NAME_AT_VERSION_REGEX = /^(.+)@v?\d/;

// Splits on `/` or `\` and drops empty segments, so paths with consecutive
// separators (e.g. `/proj//node_modules//lucide-react/...` from poorly
// concatenated bundler URLs) still produce a clean segment list.
const splitPathSegments = (path: string): string[] =>
path.split(PATH_SEPARATOR_REGEX).filter(Boolean);

// Reads `<scope>?/<name>` from the path tail that follows a `node_modules`
// boundary, rejecting hoist meta-directories like `.pnpm`, `.vite`, `.bin`,
// `.cache` (whose real packages live one level deeper and are picked up
// either by the `.vite/deps/` recognizer or by the last-match collapse
// past the meta-directory to the inner `node_modules/`).
const readNodeModulesPackage = (afterMarker: string): string | null => {
const [first, second] = splitPathSegments(afterMarker);
if (!first || first.startsWith(".")) return null;
if (!first.startsWith("@")) return first;
return second ? `${first}/${second}` : null;
};

// Vite flattens scoped optimized deps because filenames cannot contain a
// slash: `@radix-ui/react-dialog` is written as `@radix-ui_react-dialog.js`.
// Internal split chunks are emitted as `chunk-<hash>.js` and have no
// recoverable package origin, so we drop them.
const readViteOptimizedDepPackage = (afterMarker: string): string | null => {
const firstSegment = splitPathSegments(afterMarker)[0];
if (!firstSegment) return null;
const stem = firstSegment.replace(FILE_EXTENSION_REGEX, "");
if (VITE_INTERNAL_CHUNK_REGEX.test(stem)) return null;
if (!stem.startsWith("@")) return stem;
const scopeBoundary = stem.indexOf("_");
if (scopeBoundary === -1) return null;
return `${stem.slice(0, scopeBoundary)}/${stem.slice(scopeBoundary + 1)}`;
};

const matchAfterLastPattern = (
path: string,
pattern: RegExp,
read: (afterMarker: string) => string | null,
): string | null => {
const lastMatch = [...path.matchAll(pattern)].at(-1);
if (!lastMatch) return null;
return read(path.slice((lastMatch.index ?? 0) + lastMatch[0].length));
};

const matchNameAtVersion = (segment: string | undefined): string | null =>
segment?.match(NAME_AT_VERSION_REGEX)?.[1] ?? null;

// Walks a CDN URL pathname looking for the first segment shaped like
// `<name>@<version>` (with an optional preceding `@scope` segment).
// Tolerates path prefixes used by various CDNs: `/npm/`, `/v135/`,
// `/stable/`, `/pin/`, etc.
const findVersionedPackageInPath = (pathname: string): string | null => {
const segments = splitPathSegments(pathname);
for (const [index, segment] of segments.entries()) {
if (segment.startsWith("@")) {
const name = matchNameAtVersion(segments[index + 1]);
if (name) return `${segment}/${name}`;
continue;
}
const name = matchNameAtVersion(segment);
if (name) return name;
}
return null;
};

const matchVersionedPackageInUrl = (rawFileName: string): string | null => {
let url: URL;
try {
url = new URL(rawFileName);
} catch {
return null;
}
// file:// URLs have no hostname; their pathname is a real filesystem
// path that should fall through to the node_modules matcher rather
// than be treated as a CDN URL.
if (!url.hostname) return null;
return findVersionedPackageInPath(url.pathname);
};

// Recovers the npm package a stack frame originated from across the bundler
// matrix we care about (Vite, Webpack, Rollup, esbuild, Parcel, Turbopack,
// Next.js with source maps, plain Node, Yarn PnP, pnpm, Bun), plus a few
// common CDN URL shapes. Returns `null` whenever the path is ambiguous or
// clearly belongs to user source so callers can fall back to file-path-style
// output.
export const parsePackageName = (fileName: string | null | undefined): string | null => {
if (!fileName) return null;

const cdnPackage = matchVersionedPackageInUrl(fileName);
if (cdnPackage) return cdnPackage;

const normalized = normalizeFileName(fileName);
if (!normalized) return null;

return (
matchAfterLastPattern(normalized, VITE_OPTIMIZED_DEPS_REGEX, readViteOptimizedDepPackage) ??
matchAfterLastPattern(normalized, NODE_MODULES_REGEX, readNodeModulesPackage)
);
};
Loading
Loading