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/react-grab/e2e/element-context.spec.ts b/packages/react-grab/e2e/element-context.spec.ts index a955c9427..42a99b9eb 100644 --- a/packages/react-grab/e2e/element-context.spec.ts +++ b/packages/react-grab/e2e/element-context.spec.ts @@ -139,5 +139,208 @@ test.describe("Element Context Fallback", () => { expect(clipboard).toContain("long-dom-element"); expect(clipboard.length).toBeLessThanOrEqual(510); }); + + test("should include descendant text for syntax highlighted code blocks", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate(() => { + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { + position: "fixed", + top: "200px", + left: "200px", + width: "600px", + height: "160px", + zIndex: "999", + }); + + const codeBlock = document.createElement("pre"); + codeBlock.className = "shiki shiki-themes github-light github-dark"; + codeBlock.tabIndex = 0; + codeBlock.innerHTML = ` + + git add .github/workflows/react-doctor.yml + git commit -m "Add React Doctor to CI" + git push + + `; + + wrapper.appendChild(codeBlock); + document.body.appendChild(wrapper); + }); + + const didCopy = await reactGrab.copyElementViaApi("pre.shiki"); + expect(didCopy).toBe(true); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain(""); + }); + + test("should include descendant text for nested link labels", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { + position: "fixed", + top: "200px", + left: "200px", + width: "320px", + height: "80px", + zIndex: "999", + }); + + const link = document.createElement("a"); + link.href = "/docs/ci-and-prs/github-actions-setup"; + link.className = "flex h-8 w-full items-center gap-2 rounded-md px-2"; + link.innerHTML = ` + + GitHub Actions setup + `; + + wrapper.appendChild(link); + document.body.appendChild(wrapper); + }); + + const didCopy = await reactGrab.copyElementViaApi( + "a[href='/docs/ci-and-prs/github-actions-setup']", + ); + expect(didCopy).toBe(true); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain('"); + }); + + test("should skip preview text for hidden selected roots", async ({ reactGrab }) => { + await reactGrab.page.evaluate(() => { + const wrapper = document.createElement("div"); + Object.assign(wrapper.style, { + position: "fixed", + top: "200px", + left: "200px", + width: "200px", + height: "80px", + zIndex: "999", + }); + + const hiddenLabel = document.createElement("span"); + hiddenLabel.setAttribute("aria-hidden", "true"); + hiddenLabel.setAttribute("data-testid", "decorative-hidden-label"); + hiddenLabel.textContent = "Decorative Hidden Label"; + + wrapper.appendChild(hiddenLabel); + document.body.appendChild(wrapper); + }); + + const didCopy = await reactGrab.copyElementViaApi("[data-testid='decorative-hidden-label']"); + expect(didCopy).toBe(true); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain('aria-hidden="true"'); + expect(clipboard).not.toContain("Decorative Hidden Label"); + }); + + test("should 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"); + 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/e2e/selection.spec.ts b/packages/react-grab/e2e/selection.spec.ts index 11079edff..608995e3d 100644 --- a/packages/react-grab/e2e/selection.spec.ts +++ b/packages/react-grab/e2e/selection.spec.ts @@ -57,6 +57,12 @@ test.describe("Element Selection", () => { expect(clipboardMetadata.content).toContain("Todo List"); expect(clipboardMetadata.entries).toHaveLength(1); expect(clipboardMetadata.entries[0].content).toContain("Todo List"); + // 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); }); // PR #349 ("fix: keep page interactive while grabbing") intentionally diff --git a/packages/react-grab/package.json b/packages/react-grab/package.json index 4da96f9e5..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": "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/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/constants.ts b/packages/react-grab/src/constants.ts index efaf6384d..b22698a35 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -20,6 +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; +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; @@ -71,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", @@ -105,6 +105,9 @@ export const PREVIEW_IDENTIFYING_ATTRS = new Set([ "open", ]); +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/core/context.ts b/packages/react-grab/src/core/context.ts index 850a446c4..e65c78f98 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -1,12 +1,4 @@ -import { - isSourceFile, - getOwnerStack, - getSource, - formatOwnerStack, - hasDebugStack, - parseStack, - type StackFrame, -} from "bippy/source"; +import { getOwnerStack, getSource, type StackFrame } from "bippy/source"; import { getFiberFromHostInstance, isInstrumentationActive, @@ -15,38 +7,21 @@ 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, -} 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 { 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 { + 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"; +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; -}; +import type { SourceLocation } from "../types.js"; const isSourceComponentName = (name: string): boolean => { if (name.length <= 1) return false; @@ -56,167 +31,8 @@ const isSourceComponentName = (name: string): boolean => { return true; }; -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 toSourceComponentName = (name: string | null | undefined): string | null => + name && isSourceComponentName(name) ? name : null; const findNearestFiberElement = (element: Element): Element => { if (!isInstrumentationActive()) return element; @@ -229,6 +45,7 @@ const findNearestFiberElement = (element: Element): Element => { }; const stackCache = new WeakMap>(); +const fiberSourceCache = new WeakMap>(); const fetchStackForElement = async (element: Element): Promise => { try { @@ -251,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 => { @@ -266,117 +83,108 @@ export const getNearestComponentName = async (element: Element): Promise { + const namedFrame = frames.find((frame) => Boolean(toSourceComponentName(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); - return name && isSourceComponentName(name) ? name : null; + return toSourceComponentName(getDisplayName(fiber.type)); }; -// 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. -// 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. +// 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; try { const source = await getSource(fiber); - if (!source?.fileName || !isSourceFile(source.fileName)) return null; + if (!source?.fileName) return null; return { filePath: normalizeFilePath(source.fileName), 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), + origin: classifySourcePath(source.fileName).origin, }; } catch { return null; } }; -export const resolveSource = async (element: Element): Promise => { - const fiberSource = await getFiberSource(element); - if (fiberSource) return fiberSource; - - const stack = await getStack(element); - if (!stack || stack.length === 0) return null; - - const sourceFrames = stack.filter((frame) => frame.fileName && isSourceFile(frame.fileName)); - - const namedFrame = sourceFrames.find( - (frame) => frame.functionName && isSourceComponentName(frame.functionName), - ); - - const resolvedFrame = namedFrame ?? sourceFrames[0]; - if (!resolvedFrame?.fileName) return null; +const getCachedFiberSource = (element: Element): Promise => { + const nearestFiberElement = findNearestFiberElement(element); + const cachedFiberSourcePromise = fiberSourceCache.get(nearestFiberElement); + if (cachedFiberSourcePromise) return cachedFiberSourcePromise; - return { - filePath: normalizeFilePath(resolvedFrame.fileName), - lineNumber: resolvedFrame.lineNumber ?? null, - columnNumber: resolvedFrame.columnNumber ?? null, - componentName: - resolvedFrame.functionName && isSourceComponentName(resolvedFrame.functionName) - ? resolvedFrame.functionName - : null, - }; + // 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 fiberSourcePromise = getFiberSource(nearestFiberElement).then((source) => { + if (!source) fiberSourceCache.delete(nearestFiberElement); + return source; + }); + fiberSourceCache.set(nearestFiberElement, fiberSourcePromise); + return fiberSourcePromise; }; -export const getComponentDisplayName = (element: Element): string | null => { - if (!isInstrumentationActive()) return null; - const resolvedElement = findNearestFiberElement(element); - const fiber = getFiberFromHostInstance(resolvedElement); - if (!fiber) return null; +const ORIGIN_PREFERENCE_ORDER = ["app", "package"] as const; - let currentFiber = fiber.return; - while (currentFiber) { - if (isCompositeFiber(currentFiber)) { - const name = getDisplayName(currentFiber.type); - if (name && isUsefulComponentName(name)) { - return name; - } +export const selectResolvedSource = ( + fiberSource: ResolvedSource | null, + stack: StackFrame[], +): ResolvedSource | null => { + 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(framesOfOrigin); + if (preferredFrame?.fileName) { + return { + filePath: normalizeFilePath(preferredFrame.fileName), + lineNumber: preferredFrame.lineNumber ?? null, + columnNumber: preferredFrame.columnNumber ?? null, + componentName: toSourceComponentName(preferredFrame.functionName), + origin, + }; } - currentFiber = currentFiber.return; } - return null; }; -interface StackContextOptions { +export const resolveSource = async (element: Element): Promise => { + const fiberSource = await getCachedFiberSource(element); + if (fiberSource?.origin === "app") return fiberSource; + + return selectResolvedSource(fiberSource, (await getStack(element)) ?? []); +}; + +export const getComponentDisplayName = (element: Element): string | null => + getComponentNamesFromFiber(findNearestFiberElement(element), 1)[0] ?? null; + +export 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; - }); -}; +interface TraceContextResult { + text: string; + shouldAppendSelectorHint: boolean; +} const getComponentNamesFromFiber = (element: Element, maxCount: number): string[] => { if (!isInstrumentationActive()) return []; @@ -389,9 +197,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; @@ -417,7 +225,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 @@ -431,35 +239,97 @@ const formatSourceContextLine = (source: ResolvedSource, isNextProject: boolean) : `\n in ${location}`; }; -const formatStackContext = ( +interface StackFrameLine { + text: string; + isTrustedSource: boolean; +} + +const formatStackFrameLine = ( + frame: StackFrame, + sourceClassification: SourcePathClassification, + componentName: string | null, + isNextProject: boolean, +): StackFrameLine | null => { + const libraryPackage = sourceClassification.packageName; + // 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.origin === "app" ? frame.fileName : null; + + if (frame.isServer && !appSourceFilePath && (componentName || !frame.functionName)) { + const serverTag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; + return { + text: `\n in ${componentName ?? ""} (${serverTag})`, + isTrustedSource: false, + }; + } + + if (!appSourceFilePath && componentName) { + return { + text: libraryPackage + ? `\n in ${componentName} (${libraryPackage})` + : `\n in ${componentName}`, + isTrustedSource: false, + }; + } + + if (libraryPackage) { + return { text: `\n in ${libraryPackage}`, isTrustedSource: false }; + } + + if (appSourceFilePath) { + return { + text: formatSourceContextLine( + { + componentName, + filePath: appSourceFilePath, + lineNumber: frame.lineNumber ?? null, + columnNumber: frame.columnNumber ?? null, + }, + isNextProject, + ), + isTrustedSource: true, + }; + } + + return null; +}; + +export const formatStackContext = ( stack: StackFrame[], options: StackContextOptions = {}, leadingSource: ResolvedSource | null = null, -): string => { +): TraceContextResult => { const { maxLines = DEFAULT_MAX_CONTEXT_LINES } = options; + // 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 previousLibraryPackage: string | null = null; + let previousLibraryFrameKey: string | null = null; let didDedupeLeadingComponent = false; + let hasTrustedSource = false; + let budgetedLineCount = 0; if (leadingSource) { + hasTrustedSource = leadingSource.origin === "app"; + budgetedLineCount += 1; lines.push(formatSourceContextLine(leadingSource, isNextProject)); } - const emit = (line: string, libraryPackage: string | null) => { - lines.push(line); - previousLibraryPackage = libraryPackage; - }; - for (const frame of stack) { - if (lines.length >= maxLines) 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 resolvedSource = frame.fileName && isSourceFile(frame.fileName) ? frame.fileName : null; - const libraryPackage = resolvedSource ? null : parsePackageName(frame.fileName); - if (libraryPackage && libraryPackage === previousLibraryPackage) continue; + const sourceClassification = classifySourcePath(frame.fileName); - const componentName = - frame.functionName && isSourceComponentName(frame.functionName) ? frame.functionName : null; + const componentName = toSourceComponentName(frame.functionName); + const libraryFrameKey = sourceClassification.packageName + ? `${sourceClassification.packageName}:${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 @@ -473,224 +343,88 @@ const formatStackContext = ( continue; } - if (frame.isServer && !resolvedSource && (componentName || !frame.functionName)) { - const tag = libraryPackage ? `${libraryPackage} at Server` : "at Server"; - emit(`\n in ${componentName ?? ""} (${tag})`, libraryPackage); - continue; - } - - if (!resolvedSource && componentName) { - emit( - libraryPackage ? `\n in ${componentName} (${libraryPackage})` : `\n in ${componentName}`, - libraryPackage, - ); - continue; - } - - if (resolvedSource) { - emit( - formatSourceContextLine( - { - componentName, - filePath: resolvedSource, - lineNumber: frame.lineNumber ?? null, - columnNumber: frame.columnNumber ?? null, - }, - isNextProject, - ), - null, - ); + const frameLine = formatStackFrameLine( + frame, + sourceClassification, + componentName, + isNextProject, + ); + if (frameLine === null) continue; + + if (frameLine.isTrustedSource) { + hasTrustedSource = true; + budgetedLineCount += 1; } + lines.push(frameLine.text); + previousLibraryFrameKey = libraryFrameKey; } - return lines.join(""); + return { + text: lines.join(""), + shouldAppendSelectorHint: !hasTrustedSource, + }; }; -export const getStackContext = async ( +// 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); + return fiberSource?.origin === "app" ? fiberSource : null; +}; + +const getTraceContext = async ( element: Element, options: StackContextOptions = {}, -): Promise => { - const maxLines = options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES; - const leadingSource = await getFiberSource(element); +): Promise => { + const leadingSource = await resolveLeadingSource(element); const stack = await getStack(element); - if (stack && hasFormattableFrames(stack)) { - return formatStackContext(stack, options, leadingSource); - } + const stackContext = formatStackContext(stack ?? [], options, leadingSource); + if (stackContext.text) return stackContext; - if (leadingSource) { - return formatSourceContextLine(leadingSource, isNextProjectRuntime()); - } - - const componentNames = getComponentNamesFromFiber(findNearestFiberElement(element), maxLines); + const componentNames = getComponentNamesFromFiber( + findNearestFiberElement(element), + options.maxLines ?? DEFAULT_MAX_CONTEXT_LINES, + ); if (componentNames.length > 0) { - return componentNames.map((name) => `\n in ${name}`).join(""); + return { + text: componentNames.map((componentName) => `\n in ${componentName}`).join(""), + shouldAppendSelectorHint: true, + }; } - return ""; + return { text: "", shouldAppendSelectorHint: true }; }; -export const getElementContext = async ( +export const getStackContext = async ( element: Element, options: StackContextOptions = {}, ): Promise => { - const resolvedElement = findNearestFiberElement(element); - const html = getHTMLPreview(resolvedElement); - const stackContext = await getStackContext(resolvedElement, options); - - if (stackContext) { - return `${html}${stackContext}`; - } - - return getFallbackContext(resolvedElement); + const traceContext = await getTraceContext(element, options); + return traceContext.text; }; -const getFallbackContext = (element: Element): string => { - if (!(element instanceof HTMLElement)) { - return getInlineHTMLPreview(element); - } - - const tagName = getTagName(element); - const attrsText = formatAttrsForPreview(element); - const directText = getDirectTextContent(element); - const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH); - - if (truncatedText.length > 0) { - return `<${tagName}${attrsText}>\n ${truncatedText}\n`; - } - return `<${tagName}${attrsText} />`; +const composeElementContext = (element: Element, traceContext: TraceContextResult): string => { + const selectorHint = traceContext.shouldAppendSelectorHint + ? `\n selector: ${createElementSelector(element)}` + : ""; + return `${traceContext.text}${selectorHint}`; }; -const truncateAttrValue = (value: string): string => - truncateString(value, PREVIEW_ATTR_VALUE_MAX_LENGTH); - -interface FormatPriorityAttrsOptions { - truncate?: boolean; - maxAttrs?: number; -} - -const formatPriorityAttrs = ( +export const getElementReferenceContext = async ( 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 getDirectTextContent = (element: Element): string => { - let directText = ""; - for (const node of element.childNodes) { - if (node.nodeType === Node.TEXT_NODE) { - const trimmed = node.textContent?.trim() ?? ""; - if (trimmed) { - directText += (directText ? " " : "") + trimmed; - } - } - } - return directText; -}; - -const formatChildElements = (elements: Array): string => { - if (elements.length === 0) return ""; - if (elements.length <= 2) { - 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 directText = getDirectTextContent(element); - const truncatedText = truncateString(directText, PREVIEW_TEXT_MAX_LENGTH); - - if (truncatedText) { - return `<${tagName}${attrsText}>${truncatedText}`; - } - return `<${tagName}${attrsText} />`; + options: StackContextOptions = {}, +): Promise => { + const traceContext = await getTraceContext(element, options); + return `${getInlineHTMLPreview(element)}${composeElementContext(element, traceContext).replace(/\n\s+/g, " ")}`; }; -export const getHTMLPreview = (element: Element): string => { - const tagName = getTagName(element); - const attrsText = formatAttrsForPreview(element); - const directText = getDirectTextContent(element); - - 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) content += `\n ${topElementsStr}`; - if (directText.length > 0) { - content += `\n ${truncateString(directText, PREVIEW_TEXT_MAX_LENGTH)}`; - } - const bottomElementsStr = formatChildElements(bottomElements); - if (bottomElementsStr) content += `\n ${bottomElementsStr}`; - - if (content.length > 0) { - return `<${tagName}${attrsText}>${content}\n`; - } - return `<${tagName}${attrsText} />`; +export const formatElementInfo = async ( + element: Element, + options: StackContextOptions = {}, +): Promise => { + 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 e63542667..203c76495 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,6 +1,9 @@ -import { getInlineHTMLPreview, getStackContext } 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"; +import type { StackFrame } from "bippy/source"; +import type { ReactGrabEntry, ReactGrabStackFrame } from "../types.js"; interface CopyFlowOptions { getContent?: (elements: Element[]) => Promise | string; @@ -15,16 +18,71 @@ 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}]`; +interface CopyPayload { + content: string; + entries?: ReactGrabEntry[]; +} + +// 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, + columnNumber: frame.columnNumber, + isServer: frame.isServer, + isSymbolicated: frame.isSymbolicated, +}); + +const buildElementPayloadEntry = async (element: Element): Promise => { + const [referenceContext, stackContext, source, stack] = await Promise.all([ + getElementReferenceContext(element), + getStackContext(element), + resolveSource(element), + getStack(element), + ]); + return { + tagName: getTagName(element), + componentName: source?.componentName ?? undefined, + content: `[${referenceContext}]`, + source, + stackContext, + frames: (stack ?? []).map(formatStackFramePayload), + }; +}; + +const buildClipboardPayload = async (elements: Element[]): Promise => { + 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 buildClipboardPayload = async (elements: Element[]): Promise => { - const references = await Promise.all(elements.map(formatElementReference)); - const uniqueReferences = [...new Set(references)]; - return uniqueReferences.length > 0 ? uniqueReferences.join("\n") : null; +const getMetadataEntries = ( + payload: CopyPayload | null, + finalContent: string, + prependedPrompt: string | undefined, +): ReactGrabEntry[] | undefined => { + if (!payload?.entries) return undefined; + if (finalContent === payload.content) return payload.entries; + 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 ( @@ -39,16 +97,20 @@ export const runCopyFlow = async ( let finalContent = ""; try { - const rawContent = options.getContent - ? await options.getContent(elements) + const payload: CopyPayload | null = options.getContent + ? { content: await options.getContent(elements) } : 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: getMetadataEntries(payload, finalContent, prependedPrompt), + }); } } catch (error) { hooks.onCopyError(normalizeError(error)); diff --git a/packages/react-grab/src/core/html-preview.ts b/packages/react-grab/src/core/html-preview.ts new file mode 100644 index 000000000..18e82328c --- /dev/null +++ b/packages/react-grab/src/core/html-preview.ts @@ -0,0 +1,120 @@ +import { + PREVIEW_TEXT_MAX_LENGTH, + PREVIEW_ATTR_VALUE_MAX_LENGTH, + PREVIEW_PRIORITY_ATTRS, + PREVIEW_IDENTIFYING_ATTRS, + PREVIEW_DESCENDANT_TEXT_TAGS, +} from "../constants.js"; +import { getTagName } from "../utils/get-tag-name.js"; +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 = (attributeValue: string): string => + truncateString(attributeValue, PREVIEW_ATTR_VALUE_MAX_LENGTH); + +const formatPriorityAttrs = (element: Element): string => { + const priorityAttrs: string[] = []; + + 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 = (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: 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(attributeName)) { + identifyingParts.push( + attributeValue ? ` ${attributeName}="${attributeValue}"` : ` ${attributeName}`, + ); + } else if (attributeValue) { + remainingParts.push(` ${attributeName}="${truncateAttrValue(attributeValue)}"`); + } + } + + 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 83bab4f16..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"; @@ -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, @@ -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) { - openFile(filePath, lineNumber ?? undefined, pluginRegistry.hooks.transformOpenFileUrl); + requestOpenFile( + filePath, + lineNumber ?? undefined, + pluginRegistry.hooks.transformOpenFileUrl, + ); } return true; }; @@ -3723,7 +3721,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/next-server-frames.ts b/packages/react-grab/src/core/next-server-frames.ts new file mode 100644 index 000000000..631013e22 --- /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 symbolicationResults = (await response.json()) as NextJsFrameResult[]; + const symbolicatedFrames = [...frames]; + + for (let resultIndex = 0; resultIndex < serverFrameIndices.length; resultIndex++) { + const symbolicationResult = symbolicationResults[resultIndex]; + if (symbolicationResult?.status !== "fulfilled") continue; + + const originalStackFrame = symbolicationResult.value?.originalStackFrame; + if (!originalStackFrame?.file || originalStackFrame.ignored) continue; + + const originalFrameIndex = serverFrameIndices[resultIndex]; + symbolicatedFrames[originalFrameIndex] = { + ...frames[originalFrameIndex], + fileName: originalStackFrame.file, + lineNumber: originalStackFrame.line1 ?? undefined, + columnNumber: originalStackFrame.column1 ?? undefined, + isSymbolicated: true, + }; + } + + return symbolicatedFrames; + } 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 matchingServerFrame = serverFramesByName.get(frame.functionName); + if (!matchingServerFrame) return frame; + return { + ...frame, + fileName: matchingServerFrame.fileName, + lineNumber: matchingServerFrame.lineNumber, + columnNumber: matchingServerFrame.columnNumber, + }; + }); +}; 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 3513203f3..a1da9575d 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -11,18 +11,18 @@ import { } from "./utils/pointer-events-freeze.js"; import { getComponentDisplayName, - getHTMLPreview, getStack, getStackContext, - getElementContext as formatElementSnippet, + 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 }; 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; @@ -53,7 +53,7 @@ export interface ReactGrabElementContext { */ export const getElementContext = async (element: Element): Promise => { const [snippet, source, stack] = await Promise.all([ - formatElementSnippet(element), + formatElementInfo(element), resolveSource(element), getStack(element).then((result) => result ?? []), ]); @@ -161,7 +161,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/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/classify-source-path.ts b/packages/react-grab/src/utils/classify-source-path.ts new file mode 100644 index 000000000..c1d301e8d --- /dev/null +++ b/packages/react-grab/src/utils/classify-source-path.ts @@ -0,0 +1,20 @@ +import { isSourceFile } from "bippy/source"; +import { resolvePackageName } from "./parse-package-name.js"; + +export interface SourcePathClassification { + origin: "app" | "package" | "unknown"; + packageName: string | null; +} + +export const classifySourcePath = ( + fileName: string | null | undefined, +): SourcePathClassification => { + if (!fileName) return { origin: "unknown", packageName: null }; + + const packageName = resolvePackageName(fileName); + if (packageName) return { origin: "package", packageName }; + + if (!isSourceFile(fileName)) return { origin: "unknown", packageName: null }; + + return { origin: "app", packageName: null }; +}; diff --git a/packages/react-grab/src/utils/copy-content.ts b/packages/react-grab/src/utils/copy-content.ts index 190d11315..3ac064232 100644 --- a/packages/react-grab/src/utils/copy-content.ts +++ b/packages/react-grab/src/utils/copy-content.ts @@ -1,14 +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; -} - interface CopyContentOptions { componentName?: string; tagName?: string; diff --git a/packages/react-grab/src/utils/create-element-selector.ts b/packages/react-grab/src/utils/create-element-selector.ts index 0352286d8..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; @@ -18,6 +11,8 @@ const PREFERRED_SELECTOR_ATTRIBUTE_NAMES = new Set([ "data-cy", "data-qa", "aria-label", + "href", + "src", "role", "name", "title", @@ -38,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; } @@ -70,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; } @@ -81,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 a3f2e9555..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 { getElementContext } 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) => getElementContext(element, options)), - ); - - const elementSnippets = elementSnippetResults.map((result) => - result.status === "fulfilled" ? result.value : "", + elements.map((element) => formatElementInfo(element, options)), ); - - 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 new file mode 100644 index 000000000..595216bc8 --- /dev/null +++ b/packages/react-grab/src/utils/get-preview-text-content.ts @@ -0,0 +1,63 @@ +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 => { + const textParts: string[] = []; + for (const node of element.childNodes) { + if (node.nodeType !== Node.TEXT_NODE) continue; + const collapsedText = collapseTextContent(node.textContent ?? ""); + if (collapsedText) textParts.push(collapsedText); + } + return textParts.join(" "); +}; + +const shouldSkipElementText = (element: Element): boolean => { + if (element.getAttribute("aria-hidden") === "true") return true; + if (element.hasAttribute("hidden")) return true; + return PREVIEW_SKIPPED_TEXT_TAGS.has(element.tagName.toLowerCase()); +}; + +// 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 collapsedText = collapseTextContent(node.textContent ?? ""); + if (!collapsedText) return remainingCharacterBudget; + textParts.push(collapsedText); + return remainingCharacterBudget - collapsedText.length; + } + + if (!(node instanceof Element) || shouldSkipElementText(node)) return remainingCharacterBudget; + + for (const childNode of node.childNodes) { + remainingCharacterBudget = collectDescendantText( + childNode, + textParts, + remainingCharacterBudget, + ); + if (remainingCharacterBudget <= 0) break; + } + return remainingCharacterBudget; +}; + +export const getPreviewTextContent = (element: Element, tagName: string): string => { + if (shouldSkipElementText(element)) return ""; + + const directText = getDirectTextContent(element); + if (!PREVIEW_DESCENDANT_TEXT_TAGS.has(tagName)) return directText; + if (directText && element.children.length === 0) return directText; + + 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 a13edea0d..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"; @@ -24,18 +24,20 @@ 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, ): 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 82dd675fe..05abfbe0c 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 => @@ -96,3 +91,80 @@ 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 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; + while (remainingPath.startsWith("../") || remainingPath.startsWith("./")) { + remainingPath = remainingPath.slice(remainingPath.startsWith("../") ? 3 : 2); + } + return remainingPath; +}; + +const parseScopedPackageSourceName = (fileName: string): string | null => { + const sourcePath = stripRelativeSourcePathPrefix( + safeDecodeURIComponent(normalizeFileName(fileName)), + ); + // 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, ...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; + } + + return `${scope}/${packageName}`; +}; + +// parsePackageName keys off node_modules/.vite/CDN markers; the scoped fallback +// 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 new file mode 100644 index 000000000..32006ee2a --- /dev/null +++ b/packages/react-grab/tests/classify-source-path.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; +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({ + origin: "app", + packageName: null, + }); + }); + + it("classifies package source paths", () => { + expect(classifySourcePath("../@rippling/pebble/Tabs/Renderers.js")).toEqual({ + origin: "package", + packageName: "@rippling/pebble", + }); + expect(classifySourcePath("./@radix-ui/react-tabs/src/tabs.tsx")).toEqual({ + origin: "package", + packageName: "@radix-ui/react-tabs", + }); + expect(classifySourcePath("/app/node_modules/@radix-ui/react-tabs/dist/index.min.js")).toEqual({ + origin: "package", + packageName: "@radix-ui/react-tabs", + }); + expect(classifySourcePath("/app/node_modules/react-tabs/dist/index.js")).toEqual({ + origin: "package", + packageName: "react-tabs", + }); + }); + + it("detects scoped packages with or without a relative prefix", () => { + 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").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({ + origin: "app", + packageName: null, + }); + expect(classifySourcePath("src/components/ui/dialog.tsx")).toEqual({ + origin: "app", + packageName: null, + }); + }); + + it("classifies monorepo workspace paths as app source", () => { + expect(classifySourcePath("../@company/app/src/tabs.tsx")).toEqual({ + origin: "app", + packageName: null, + }); + expect(classifySourcePath("src/components/ui-button.tsx")).toEqual({ + origin: "app", + packageName: null, + }); + }); +}); diff --git a/packages/react-grab/tests/context.test.ts b/packages/react-grab/tests/context.test.ts new file mode 100644 index 000000000..2d53b52c6 --- /dev/null +++ b/packages/react-grab/tests/context.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { StackFrame } from "bippy/source"; +import { + formatStackContext, + selectResolvedSource, + type ResolvedSource, +} from "../src/core/context.js"; + +const fiberSource: ResolvedSource = { + filePath: "/src/app/page.tsx", + lineNumber: 1, + columnNumber: 1, + componentName: "Page", + origin: "app", +}; + +const packageFiberSource: ResolvedSource = { + filePath: "/app/node_modules/react-tabs/dist/index.js", + lineNumber: 1, + columnNumber: 1, + componentName: "Tabs", + origin: "package", +}; + +const appFrame: StackFrame = { + fileName: "/src/app/widget.tsx", + functionName: "Widget", + lineNumber: 5, + columnNumber: 2, +}; + +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", () => { + expect(selectResolvedSource(fiberSource, [appFrame])).toBe(fiberSource); + }); + + 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 a package-source fiber over package frames", () => { + expect(selectResolvedSource(packageFiberSource, [packageFrame])).toBe(packageFiberSource); + }); + + it("falls back to a package frame as the last resort", () => { + 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, [])).toBe(null); + }); + + it("picks the first named frame within an origin over an earlier anonymous frame", () => { + const anonymousFrame: StackFrame = { + fileName: "/src/app/anonymous.tsx", + lineNumber: 2, + columnNumber: 1, + }; + + expect(selectResolvedSource(null, [anonymousFrame, appFrame])).toMatchObject({ + filePath: "/src/app/widget.tsx", + componentName: "Widget", + }); + }); +}); + +describe("formatStackContext", () => { + 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).toContain("components/ui/button.tsx"); + expect(result.text).toContain("in Page"); + expect(result.text).toContain("app/page.tsx"); + }); + + 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 }, + ); + + 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", () => { + 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"); + expect(result.shouldAppendSelectorHint).toBe(false); + }); + + it("does not request a selector hint for a trusted app leading source", () => { + const result = formatStackContext([], {}, fiberSource); + + expect(result.shouldAppendSelectorHint).toBe(false); + }); +}); diff --git a/packages/react-grab/tests/parse-package-name.test.ts b/packages/react-grab/tests/parse-package-name.test.ts new file mode 100644 index 000000000..ef0c49e0d --- /dev/null +++ b/packages/react-grab/tests/parse-package-name.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parsePackageName } from "../src/utils/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("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); + 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/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"] } 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