diff --git a/packages/framework-playground/index.html b/packages/framework-playground/index.html new file mode 100644 index 000000000..eb6d14776 --- /dev/null +++ b/packages/framework-playground/index.html @@ -0,0 +1,12 @@ + + + + + + Framework Playground + + +
+ + + diff --git a/packages/framework-playground/package.json b/packages/framework-playground/package.json new file mode 100644 index 000000000..a82575be5 --- /dev/null +++ b/packages/framework-playground/package.json @@ -0,0 +1,25 @@ +{ + "name": "@react-grab/framework-playground", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "latest", + "react-dom": "latest", + "react-grab": "workspace:*", + "solid-js": "latest", + "svelte": "latest", + "vue": "latest" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "latest", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-vue": "latest", + "vite": "latest", + "vite-plugin-solid": "latest" + } +} diff --git a/packages/framework-playground/src/main.js b/packages/framework-playground/src/main.js new file mode 100644 index 000000000..76c01e177 --- /dev/null +++ b/packages/framework-playground/src/main.js @@ -0,0 +1,100 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { createApp } from "vue"; +import { render } from "solid-js/web"; +import { mount } from "svelte"; +import { ReactLogoCard } from "./react-logo-card.react.jsx"; +import { SolidLogoCard } from "./solid-logo-card.solid.jsx"; +import VueLogoCard from "./vue-logo-card.vue"; +import SvelteLogoCard from "./svelte-logo-card.svelte"; +import "./styles.css"; + +if (import.meta.env.DEV) { + import("react-grab").then(({ registerPlugin }) => { + const sourceBar = document.getElementById("source-bar"); + + registerPlugin({ + name: "source-bar", + hooks: { + onCopySuccess: async (elements) => { + if (!sourceBar || elements.length < 1) return; + const api = window.__REACT_GRAB__; + if (!api) return; + + const source = await api.getSource(elements[0]); + const stackContext = await api.getStackContext(elements[0]); + + if (source) { + const location = source.lineNumber + ? `${source.filePath}:${source.lineNumber}` + : source.filePath; + + sourceBar.textContent = source.componentName + ? `${source.componentName} → ${location}` + : location; + } else if (stackContext) { + sourceBar.textContent = stackContext.replace(/^\n\s*in\s*/, ""); + } else { + sourceBar.textContent = ""; + sourceBar.classList.remove("source-bar--visible"); + return; + } + + sourceBar.classList.add("source-bar--visible"); + }, + }, + }); + }); +} + +const APPLICATION_MOUNT_ELEMENT_ID = "app"; +const FRAMEWORK_MOUNT_IDS = { + react: "react-logo-root", + vue: "vue-logo-root", + solid: "solid-logo-root", + svelte: "svelte-logo-root", +}; + +const applicationMountElement = document.getElementById( + APPLICATION_MOUNT_ELEMENT_ID, +); + +if (!applicationMountElement) { + throw new Error("Framework playground root element is missing."); +} + +applicationMountElement.innerHTML = ` +
+
+
+
+
+
+
+
+
+`; + +const getRequiredMountElement = (mountElementId) => { + const mountElement = document.getElementById(mountElementId); + if (!mountElement) { + throw new Error(`Missing framework mount element: ${mountElementId}`); + } + return mountElement; +}; + +createRoot(getRequiredMountElement(FRAMEWORK_MOUNT_IDS.react)).render( + React.createElement(ReactLogoCard), +); + +createApp(VueLogoCard).mount(getRequiredMountElement(FRAMEWORK_MOUNT_IDS.vue)); + +render( + () => SolidLogoCard({}), + getRequiredMountElement(FRAMEWORK_MOUNT_IDS.solid), +); + +mount(SvelteLogoCard, { + target: getRequiredMountElement(FRAMEWORK_MOUNT_IDS.svelte), +}); + diff --git a/packages/framework-playground/src/react-logo-card.react.jsx b/packages/framework-playground/src/react-logo-card.react.jsx new file mode 100644 index 000000000..24cc10cef --- /dev/null +++ b/packages/framework-playground/src/react-logo-card.react.jsx @@ -0,0 +1,34 @@ +export const ReactLogoCard = () => { + return ( + + ); +}; diff --git a/packages/framework-playground/src/solid-logo-card.solid.jsx b/packages/framework-playground/src/solid-logo-card.solid.jsx new file mode 100644 index 000000000..84719d04e --- /dev/null +++ b/packages/framework-playground/src/solid-logo-card.solid.jsx @@ -0,0 +1,38 @@ +import { createSignal } from "solid-js"; + +export const SolidLogoCard = () => { + const [isSelected, setIsSelected] = createSignal(false); + + return ( + + ); +}; diff --git a/packages/framework-playground/src/styles.css b/packages/framework-playground/src/styles.css new file mode 100644 index 000000000..85a729218 --- /dev/null +++ b/packages/framework-playground/src/styles.css @@ -0,0 +1,51 @@ +* { + box-sizing: border-box; + margin: 0; +} + +body { + margin: 0; + background: #000; +} + +.framework-playground { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 48px; +} + +.framework-playground__grid { + display: flex; + gap: 64px; +} + +.framework-logo-button { + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.framework-logo { + width: 120px; + height: 120px; +} + +.source-bar { + font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 13px; + color: #a1a1aa; + height: 20px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.source-bar--visible { + opacity: 1; +} diff --git a/packages/framework-playground/src/svelte-logo-card.svelte b/packages/framework-playground/src/svelte-logo-card.svelte new file mode 100644 index 000000000..578d3d41c --- /dev/null +++ b/packages/framework-playground/src/svelte-logo-card.svelte @@ -0,0 +1,20 @@ + diff --git a/packages/framework-playground/src/vue-logo-card.vue b/packages/framework-playground/src/vue-logo-card.vue new file mode 100644 index 000000000..f29de1d01 --- /dev/null +++ b/packages/framework-playground/src/vue-logo-card.vue @@ -0,0 +1,8 @@ + diff --git a/packages/framework-playground/vite.config.js b/packages/framework-playground/vite.config.js new file mode 100644 index 000000000..847f20e93 --- /dev/null +++ b/packages/framework-playground/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import vue from "@vitejs/plugin-vue"; +import solid from "vite-plugin-solid"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +const REACT_COMPONENT_INCLUDE_PATTERN = /\.react\.jsx$/; +const SOLID_COMPONENT_INCLUDE_PATTERN = /\.solid\.jsx$/; + +export default defineConfig({ + plugins: [ + react({ + include: REACT_COMPONENT_INCLUDE_PATTERN, + }), + solid({ + include: SOLID_COMPONENT_INCLUDE_PATTERN, + }), + vue(), + svelte(), + ], +}); diff --git a/packages/react-grab/e2e/framework-source-metadata.spec.ts b/packages/react-grab/e2e/framework-source-metadata.spec.ts new file mode 100644 index 000000000..7e9b76ca5 --- /dev/null +++ b/packages/react-grab/e2e/framework-source-metadata.spec.ts @@ -0,0 +1,389 @@ +import { test, expect } from "./fixtures.js"; + +const PLACEHOLDER_TARGET_STYLE = { + position: "fixed", + top: "220px", + left: "220px", + zIndex: "9999", + padding: "8px 10px", + background: "white", + border: "1px solid #111", +}; + +test.describe("Framework Source Metadata", () => { + test("should preserve React source and merge framework fallback stack frames", async ({ + reactGrab, + }) => { + const stackCapture = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getSource: (element: Element) => Promise<{ + filePath: string; + lineNumber: number | null; + componentName: string | null; + } | null>; + getStackContext: (element: Element) => Promise; + copyElement: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const targetElement = document.querySelector( + '[data-testid="nested-button"]', + ); + if (!api || !targetElement) return null; + + const sourceBefore = await api.getSource(targetElement); + Reflect.set(targetElement, "__svelte_meta", { + parent: { + type: "component", + file: "src/FakeParent.svelte", + line: 2, + column: 0, + parent: null, + componentTag: "FakeParent", + }, + loc: { + file: "src/FakeChild.svelte", + line: 10, + column: 4, + }, + }); + + const sourceAfter = await api.getSource(targetElement); + const stackContext = await api.getStackContext(targetElement); + await api.copyElement(targetElement); + const clipboard = await navigator.clipboard.readText(); + Reflect.deleteProperty(targetElement, "__svelte_meta"); + + return { + sourceBefore, + sourceAfter, + stackContext, + clipboard, + }; + }); + + expect(stackCapture).not.toBeNull(); + expect(stackCapture?.sourceBefore).not.toBeNull(); + expect(stackCapture?.sourceAfter).toEqual(stackCapture?.sourceBefore); + expect(stackCapture?.sourceAfter?.filePath).toContain("/src/App.tsx"); + expect(stackCapture?.stackContext).toContain("/src/App.tsx"); + expect(stackCapture?.stackContext).toContain("src/FakeChild.svelte:10:5"); + expect(stackCapture?.clipboard).toContain("/src/App.tsx"); + expect(stackCapture?.clipboard).toContain("src/FakeChild.svelte:10:5"); + }); + + test("should resolve Solid runtime handler source without build plugins", async ({ + reactGrab, + }) => { + const solidFilePath = "src/components/counter.tsx"; + + await reactGrab.page.evaluate( + ({ filePath, targetStyle }) => { + const parentSolidFilePath = "src/app-root.tsx"; + const element = document.createElement("button"); + element.id = "solid-metadata-target"; + element.textContent = "Solid Metadata Target"; + Object.assign(element.style, targetStyle); + let count = 0; + const setCount = ( + updater: (currentValue: number) => number, + ): number => { + count = updater(count); + return count; + }; + const solidHandler = () => setCount((countValue) => countValue + 1); + Reflect.set(element, "$$click", solidHandler); + document.body.appendChild(element); + + ( + window as { + __REACT_GRAB_SOLID_RUNTIME_MODULES__?: Array<{ + url: string; + content: string; + }>; + } + ).__REACT_GRAB_SOLID_RUNTIME_MODULES__ = [ + { + url: `http://127.0.0.1:5175/${filePath}`, + content: ` + const template = () => { + const element = document.createElement("button"); + element.$$click = ${String(solidHandler)}; + return element; + }; + createComponent(AppRoot, { location: "${parentSolidFilePath}:4:1" }); + createComponent(template, { location: "${filePath}:14:2" }); + `, + }, + ]; + }, + { filePath: solidFilePath, targetStyle: PLACEHOLDER_TARGET_STYLE }, + ); + + const source = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getSource: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const element = document.querySelector("#solid-metadata-target"); + if (!api || !element) return null; + return api.getSource(element); + }); + + expect(source).toEqual({ + filePath: solidFilePath, + lineNumber: 14, + componentName: null, + }); + + const stackContext = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getStackContext: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const element = document.querySelector("#solid-metadata-target"); + if (!api || !element) return ""; + return api.getStackContext(element); + }); + + expect(stackContext).toContain(`${solidFilePath}:14:2`); + expect(stackContext).toContain("src/app-root.tsx:4:1"); + + await reactGrab.activate(); + await reactGrab.hoverElement("#solid-metadata-target"); + await reactGrab.waitForSelectionBox(); + await reactGrab.waitForSelectionSource(); + + const selectionFilePath = await reactGrab.page.evaluate(() => { + const api = ( + window as { + __REACT_GRAB__?: { + getState: () => { selectionFilePath: string | null }; + }; + } + ).__REACT_GRAB__; + return api?.getState().selectionFilePath ?? null; + }); + expect(selectionFilePath).toBe(solidFilePath); + + await reactGrab.clickElement("#solid-metadata-target"); + + const clipboard = await reactGrab.getClipboardContent(); + expect(clipboard).toContain(`${solidFilePath}:14:2`); + expect(clipboard).toContain("src/app-root.tsx:4:1"); + }); + + test("should resolve Vue inspector metadata with line and column", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate((targetStyle) => { + const element = document.createElement("div"); + element.id = "vue-inspector-target"; + element.textContent = "Vue Inspector Target"; + element.setAttribute("data-v-inspector", "src/components/Card.vue:33:9"); + Object.assign(element.style, targetStyle); + ( + element as { + __vueParentComponent?: Record; + } + ).__vueParentComponent = { + type: { + __file: "/workspace/vue/src/components/Card.vue", + __name: "VueCard", + }, + }; + document.body.appendChild(element); + }, PLACEHOLDER_TARGET_STYLE); + + const source = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getSource: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const element = document.querySelector("#vue-inspector-target"); + if (!api || !element) return null; + return api.getSource(element); + }); + + expect(source).toEqual({ + filePath: "src/components/Card.vue", + lineNumber: 33, + componentName: "VueCard", + }); + + const stackContext = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getStackContext: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const element = document.querySelector("#vue-inspector-target"); + if (!api || !element) return ""; + return api.getStackContext(element); + }); + + expect(stackContext).toContain("VueCard"); + expect(stackContext).toContain("src/components/Card.vue:33:9"); + }); + + test("should resolve Vue runtime metadata when inspector attribute is absent", async ({ + reactGrab, + }) => { + const vueRuntimeFilePath = "/workspace/vue/src/components/Fallback.vue"; + + await reactGrab.page.evaluate( + ({ filePath, targetStyle }) => { + const vueParentFilePath = "/workspace/vue/src/App.vue"; + const element = document.createElement("div"); + element.id = "vue-runtime-target"; + element.textContent = "Vue Runtime Target"; + Object.assign(element.style, targetStyle); + ( + element as { + __vueParentComponent?: Record; + } + ).__vueParentComponent = { + type: { + __file: filePath, + __name: "VueFallback", + }, + parent: { + type: { + __file: vueParentFilePath, + __name: "AppRoot", + }, + parent: null, + }, + }; + document.body.appendChild(element); + }, + { filePath: vueRuntimeFilePath, targetStyle: PLACEHOLDER_TARGET_STYLE }, + ); + + const source = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getSource: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const element = document.querySelector("#vue-runtime-target"); + if (!api || !element) return null; + return api.getSource(element); + }); + + expect(source).toEqual({ + filePath: vueRuntimeFilePath, + lineNumber: null, + componentName: "VueFallback", + }); + + const stackContext = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getStackContext: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const element = document.querySelector("#vue-runtime-target"); + if (!api || !element) return ""; + return api.getStackContext(element); + }); + + expect(stackContext).toContain("VueFallback"); + expect(stackContext).toContain(vueRuntimeFilePath); + expect(stackContext).toContain("AppRoot"); + expect(stackContext).toContain("/workspace/vue/src/App.vue"); + }); + + test("should resolve Svelte metadata from __svelte_meta", async ({ + reactGrab, + }) => { + await reactGrab.page.evaluate((targetStyle) => { + const element = document.createElement("button"); + element.id = "svelte-metadata-target"; + element.textContent = "Svelte Metadata Target"; + Object.assign(element.style, targetStyle); + ( + element as { + __svelte_meta?: Record; + } + ).__svelte_meta = { + parent: { + type: "component", + file: "src/App.svelte", + line: 19, + column: 4, + parent: { + type: "component", + file: "src/routes/+layout.svelte", + line: 3, + column: 1, + parent: null, + componentTag: "Shell", + }, + componentTag: "Counter", + }, + loc: { + file: "src/lib/Counter.svelte", + line: 8, + column: 0, + }, + }; + document.body.appendChild(element); + }, PLACEHOLDER_TARGET_STYLE); + + const source = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getSource: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const element = document.querySelector("#svelte-metadata-target"); + if (!api || !element) return null; + return api.getSource(element); + }); + + expect(source).toEqual({ + filePath: "src/lib/Counter.svelte", + lineNumber: 8, + componentName: "Counter", + }); + + const stackContext = await reactGrab.page.evaluate(async () => { + const api = ( + window as { + __REACT_GRAB__?: { + getStackContext: (element: Element) => Promise; + }; + } + ).__REACT_GRAB__; + const element = document.querySelector("#svelte-metadata-target"); + if (!api || !element) return ""; + return api.getStackContext(element); + }); + + expect(stackContext).toContain("Counter"); + expect(stackContext).toContain("src/lib/Counter.svelte:8:1"); + expect(stackContext).toContain("src/App.svelte:19:5"); + expect(stackContext).toContain("Shell"); + expect(stackContext).toContain("src/routes/+layout.svelte:3:2"); + }); +}); diff --git a/packages/react-grab/src/core/agent/manager.ts b/packages/react-grab/src/core/agent/manager.ts index 46e99b319..207c564fa 100644 --- a/packages/react-grab/src/core/agent/manager.ts +++ b/packages/react-grab/src/core/agent/manager.ts @@ -19,7 +19,7 @@ import { createElementBounds } from "../../utils/create-element-bounds.js"; import { isElementConnected } from "../../utils/is-element-connected.js"; import { generateSnippet } from "../../utils/generate-snippet.js"; import { recalculateSessionPosition } from "../../utils/recalculate-session-position.js"; -import { getNearestComponentName } from "../context.js"; +import { resolveElementComponentName } from "../source/index.js"; import { DISMISS_ANIMATION_BUFFER_MS, FADE_DURATION_MS, @@ -405,7 +405,7 @@ export const createAgentManager = ( const componentName = elements.length > 1 ? undefined - : (await getNearestComponentName(firstElement)) || undefined; + : (await resolveElementComponentName(firstElement)) || undefined; session = createSession( context, diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 37765212d..726d42510 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -1,520 +1,55 @@ -import { - isSourceFile, - normalizeFileName, - getOwnerStack, - formatOwnerStack, - hasDebugStack, - parseStack, - StackFrame, -} from "bippy/source"; -import { isCapitalized } from "../utils/is-capitalized.js"; -import { - getFiberFromHostInstance, - isInstrumentationActive, - getDisplayName, - isCompositeFiber, - traverseFiber, - type Fiber, -} from "bippy"; import { PREVIEW_TEXT_MAX_LENGTH, PREVIEW_ATTR_VALUE_MAX_LENGTH, PREVIEW_MAX_ATTRS, PREVIEW_PRIORITY_ATTRS, - SYMBOLICATION_TIMEOUT_MS, } from "../constants.js"; import { getTagName } from "../utils/get-tag-name.js"; import { truncateString } from "../utils/truncate-string.js"; - -const NON_COMPONENT_PREFIXES = new Set([ - "_", - "$", - "motion.", - "styled.", - "chakra.", - "ark.", - "Primitive.", - "Slot.", -]); - -const NEXT_INTERNAL_COMPONENT_NAMES = new Set([ - "InnerLayoutRouter", - "RedirectErrorBoundary", - "RedirectBoundary", - "HTTPAccessFallbackErrorBoundary", - "HTTPAccessFallbackBoundary", - "LoadingBoundary", - "ErrorBoundary", - "InnerScrollAndFocusHandler", - "ScrollAndFocusHandler", - "RenderFromTemplateContext", - "OuterLayoutRouter", - "body", - "html", - "DevRootHTTPAccessFallbackBoundary", - "AppDevOverlayErrorBoundary", - "AppDevOverlay", - "HotReload", - "Router", - "ErrorBoundaryHandler", - "AppRouter", - "ServerRoot", - "SegmentStateProvider", - "RootErrorBoundary", - "LoadableComponent", - "MotionDOMComponent", -]); - -const REACT_INTERNAL_COMPONENT_NAMES = new Set([ - "Suspense", - "Fragment", - "StrictMode", - "Profiler", - "SuspenseList", -]); - -let cachedIsNextProject: boolean | undefined; - -export const checkIsNextProject = (revalidate?: boolean): boolean => { - if (revalidate) { - cachedIsNextProject = undefined; - } - cachedIsNextProject ??= - typeof document !== "undefined" && - Boolean( - document.getElementById("__NEXT_DATA__") || - document.querySelector("nextjs-portal"), - ); - return cachedIsNextProject; -}; - -const checkIsInternalComponentName = (name: string): boolean => { - if (NEXT_INTERNAL_COMPONENT_NAMES.has(name)) return true; - if (REACT_INTERNAL_COMPONENT_NAMES.has(name)) return true; - for (const prefix of NON_COMPONENT_PREFIXES) { - if (name.startsWith(prefix)) return true; - } - return false; -}; - -export const checkIsSourceComponentName = (name: string): boolean => { - if (name.length <= 1) return false; - if (checkIsInternalComponentName(name)) return false; - if (!isCapitalized(name)) return false; - if (name.startsWith("Primitive.")) return false; - if (name.includes("Provider") || name.includes("Context")) return false; - 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); - const querySuffixIndex = url.lastIndexOf("?"); - if (environmentEndIndex > -1 && querySuffixIndex > -1) { - return decodeURI(url.slice(environmentEndIndex + 1, querySuffixIndex)); - } - } - 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. The dev server reads the bundler's source maps - // (webpack or turbopack) and returns the original file path, line, and column for each frame. - // - // We POST an array of frames and get back PromiseSettledResult[]: - // - // POST /__nextjs_original-stack-frames - // { frames: [{ file, methodName, lineNumber, column, arguments }], - // isServer: true, isEdgeServer: false, isAppDirectory: true } - // - // Response: [{ status: "fulfilled", - // value: { originalStackFrame: { file, lineNumber, column, ignored } } }] - // - // Introduced by vercel/next.js#75557 (batched POST, replaces legacy per-frame GET). - // Handler: packages/next/src/client/components/react-dev-overlay/server/middleware-webpack.ts - // Types: packages/next/src/client/components/react-dev-overlay/server/shared.ts - const response = await fetch("/__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 i = 0; i < serverFrameIndices.length; i++) { - const result = results[i]; - if (result?.status !== "fulfilled") continue; - - const resolved = result.value?.originalStackFrame; - if (!resolved?.file || resolved.ignored) continue; - - const originalFrameIndex = serverFrameIndices[i]; - resolvedFrames[originalFrameIndex] = { - ...frames[originalFrameIndex], - fileName: resolved.file, - lineNumber: resolved.line1 ?? undefined, - columnNumber: resolved.column1 ?? undefined, - isSymbolicated: true, - }; - } - - return resolvedFrames; - } catch { - return frames; - } finally { - clearTimeout(timeout); - } -}; - -const extractServerFramesFromDebugStack = ( - rootFiber: Fiber, -): Map => { - const serverFramesByName = new Map(); - - traverseFiber( - rootFiber, - (currentFiber) => { - if (!hasDebugStack(currentFiber)) return false; - - const ownerStack = formatOwnerStack(currentFiber._debugStack.stack); - if (!ownerStack) return false; - - for (const frame of parseStack(ownerStack)) { - if (!frame.functionName || !frame.fileName) continue; - if (!isServerComponentUrl(frame.fileName)) continue; - if (serverFramesByName.has(frame.functionName)) continue; - - serverFramesByName.set(frame.functionName, { - ...frame, - isServer: true, - }); - } - return false; - }, - true, - ); - - return serverFramesByName; -}; - -const enrichServerFrameLocations = ( - rootFiber: Fiber, - frames: StackFrame[], -): StackFrame[] => { - const hasUnresolvedServerFrames = frames.some( - (frame) => frame.isServer && !frame.fileName && frame.functionName, - ); - if (!hasUnresolvedServerFrames) return frames; - - const serverFramesByName = extractServerFramesFromDebugStack(rootFiber); - if (serverFramesByName.size === 0) return frames; - - return frames.map((frame) => { - if (!frame.isServer || frame.fileName || !frame.functionName) return frame; - const resolved = serverFramesByName.get(frame.functionName); - if (!resolved) return frame; - return { - ...frame, - fileName: resolved.fileName, - lineNumber: resolved.lineNumber, - columnNumber: resolved.columnNumber, - }; - }); -}; - -const findNearestFiberElement = (element: Element): Element => { - if (!isInstrumentationActive()) return element; - let current: Element | null = element; - while (current) { - if (getFiberFromHostInstance(current)) return current; - current = current.parentElement; - } - return element; -}; - -const stackCache = new WeakMap>(); - -const fetchStackForElement = async ( - element: Element, -): Promise => { - try { - const fiber = getFiberFromHostInstance(element); - if (!fiber) return null; - - const frames = await getOwnerStack(fiber); - - if (checkIsNextProject()) { - const enrichedFrames = enrichServerFrameLocations(fiber, frames); - return await symbolicateServerFrames(enrichedFrames); - } - - return frames; - } catch { - return null; - } -}; - -export const getStack = (element: Element): Promise => { - if (!isInstrumentationActive()) return Promise.resolve([]); - - const resolvedElement = findNearestFiberElement(element); - const cached = stackCache.get(resolvedElement); - if (cached) return cached; - - const promise = fetchStackForElement(resolvedElement); - stackCache.set(resolvedElement, promise); - return promise; -}; - -export const getNearestComponentName = async ( - element: Element, -): Promise => { - if (!isInstrumentationActive()) return null; - const stack = await getStack(element); - if (!stack) return null; - - for (const frame of stack) { - if (frame.functionName && checkIsSourceComponentName(frame.functionName)) { - return frame.functionName; - } - } - - return null; -}; - -export const resolveSourceFromStack = ( - stack: StackFrame[] | null, -): { - filePath: string; - lineNumber: number | undefined; - componentName: string | null; -} | null => { - if (!stack || stack.length === 0) return null; - for (const frame of stack) { - if (frame.fileName && isSourceFile(frame.fileName)) { - return { - filePath: normalizeFileName(frame.fileName), - lineNumber: frame.lineNumber, - componentName: - frame.functionName && checkIsSourceComponentName(frame.functionName) - ? frame.functionName - : null, - }; - } - } - return null; -}; - -const isUsefulComponentName = (name: string): boolean => { - if (!name) return false; - if (checkIsInternalComponentName(name)) return false; - if (name.startsWith("Primitive.")) return false; - if (name === "SlotClone" || name === "Slot") return false; - return true; -}; - -export const getComponentDisplayName = (element: Element): string | null => { - if (!isInstrumentationActive()) return null; - const resolvedElement = findNearestFiberElement(element); - const fiber = getFiberFromHostInstance(resolvedElement); - if (!fiber) return null; - - let currentFiber = fiber.return; - while (currentFiber) { - if (isCompositeFiber(currentFiber)) { - const name = getDisplayName(currentFiber.type); - if (name && isUsefulComponentName(name)) { - return name; - } - } - currentFiber = currentFiber.return; - } - - return null; -}; - -interface StackContextOptions { - maxLines?: number; -} - -const hasSourceFiles = (stack: StackFrame[] | null): boolean => { - if (!stack) return false; - return stack.some( - (frame) => - frame.isServer || (frame.fileName && isSourceFile(frame.fileName)), - ); -}; - -const getComponentNamesFromFiber = ( - element: Element, - maxCount: number, -): string[] => { - if (!isInstrumentationActive()) return []; - const fiber = getFiberFromHostInstance(element); - if (!fiber) return []; - - const componentNames: string[] = []; - traverseFiber( - fiber, - (currentFiber) => { - if (componentNames.length >= maxCount) return true; - if (isCompositeFiber(currentFiber)) { - const name = getDisplayName(currentFiber.type); - if (name && isUsefulComponentName(name)) { - componentNames.push(name); - } - } - return false; - }, - true, - ); - return componentNames; -}; - -export const formatStackContext = ( - stack: StackFrame[], - options: StackContextOptions = {}, -): string => { - const { maxLines = 3 } = options; - const isNextProject = checkIsNextProject(); - const stackContext: string[] = []; - - for (const frame of stack) { - if (stackContext.length >= maxLines) break; - - const hasResolvedSource = frame.fileName && isSourceFile(frame.fileName); - - if ( - frame.isServer && - !hasResolvedSource && - (!frame.functionName || checkIsSourceComponentName(frame.functionName)) - ) { - stackContext.push( - `\n in ${frame.functionName || ""} (at Server)`, - ); - continue; - } - - if (hasResolvedSource) { - let line = "\n in "; - const hasComponentName = - frame.functionName && checkIsSourceComponentName(frame.functionName); - - if (hasComponentName) { - line += `${frame.functionName} (at `; - } - - line += normalizeFileName(frame.fileName!); - - // HACK: bundlers like vite mess up the line/column numbers, so we don't show them - if (isNextProject && frame.lineNumber && frame.columnNumber) { - line += `:${frame.lineNumber}:${frame.columnNumber}`; - } - - if (hasComponentName) { - line += `)`; - } - - stackContext.push(line); - } - } - - return stackContext.join(""); -}; - -export const getStackContext = async ( - element: Element, - options: StackContextOptions = {}, -): Promise => { - const maxLines = options.maxLines ?? 3; - const stack = await getStack(element); - - if (stack && hasSourceFiles(stack)) { - return formatStackContext(stack, options); - } - - const componentNames = getComponentNamesFromFiber(element, maxLines); - if (componentNames.length > 0) { - return componentNames.map((name) => `\n in ${name}`).join(""); - } - - return ""; -}; +import { formatElementStack } from "../utils/format-element-stack.js"; +import { mergeStackContext } from "../utils/merge-stack-context.js"; +import { + findNearestFiberElement, + getReactStackContext, +} from "./source/react.js"; +import { resolveElementStack } from "./source/index.js"; + +export { + checkIsNextProject, + checkIsSourceComponentName, + findNearestFiberElement, + getReactStack as getStack, + getReactComponentName as getNearestComponentName, + resolveSourceFromStack, + getReactDisplayName as getComponentDisplayName, + formatReactStackContext as formatStackContext, + getReactStackContext as getStackContext, +} from "./source/react.js"; export const getElementContext = async ( element: Element, - options: StackContextOptions = {}, + options: { maxLines?: number } = {}, ): Promise => { const resolvedElement = findNearestFiberElement(element); const html = getHTMLPreview(resolvedElement); - const stackContext = await getStackContext(resolvedElement, options); + const { maxLines = 3 } = options; + + const reactStackContext = await getReactStackContext(resolvedElement, { + maxLines, + }); + const stack = await resolveElementStack(resolvedElement); + const frameworkStackContext = formatElementStack(stack, { maxLines }); + + let stackContext = ""; + if (reactStackContext && frameworkStackContext) { + stackContext = mergeStackContext( + reactStackContext, + frameworkStackContext, + maxLines, + ); + } else { + stackContext = reactStackContext || frameworkStackContext; + } if (stackContext) { return `${html}${stackContext}`; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 7eb25c429..cbdb1297b 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -24,16 +24,18 @@ import { waitUntilNextFrame, } from "../utils/native-raf.js"; import { ReactGrabRenderer } from "../components/renderer.js"; +import { checkIsNextProject } from "./context.js"; import { - getStack, - getStackContext, - getNearestComponentName, - checkIsSourceComponentName, - getComponentDisplayName, - resolveSourceFromStack, - checkIsNextProject, -} from "./context.js"; -import { isSourceFile, normalizeFileName } from "bippy/source"; + resolveElementSourceInfo, + resolveElementComponentName, + resolveElementStack, +} from "./source/index.js"; +import { + getReactDisplayName as getComponentDisplayName, + getReactStackContext, +} from "./source/react.js"; +import { formatElementStack } from "../utils/format-element-stack.js"; +import { mergeStackContext } from "../utils/merge-stack-context.js"; import { createNoopApi } from "./noop-api.js"; import { createEventListenerManager } from "./events.js"; import { tryCopyWithFallback } from "./copy.js"; @@ -139,6 +141,7 @@ import { commentPlugin } from "./plugins/comment.js"; import { openPlugin } from "./plugins/open.js"; import { copyHtmlPlugin } from "./plugins/copy-html.js"; import { copyStylesPlugin } from "./plugins/copy-styles.js"; +import { frameworkSourcePlugin } from "./plugins/framework-source.js"; import { freezeAnimations, freezeAllAnimations, @@ -165,6 +168,7 @@ const builtInPlugins = [ copyHtmlPlugin, copyStylesPlugin, openPlugin, + frameworkSourcePlugin, ]; let hasInited = false; @@ -567,38 +571,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ): Promise => { const elementsPayload = await Promise.all( elements.map(async (element) => { - const stack = await getStack(element); - - let componentName: string | null = null; - let filePath: string | undefined; - let lineNumber: number | undefined; - let columnNumber: number | undefined; - - if (stack && stack.length > 0) { - for (const frame of stack) { - const hasSourceComponentName = - frame.functionName && - checkIsSourceComponentName(frame.functionName); - const hasSourceFile = - frame.fileName && isSourceFile(frame.fileName); - - if (hasSourceComponentName && !componentName) { - componentName = frame.functionName!; - } - - if (hasSourceFile && !filePath) { - filePath = normalizeFileName(frame.fileName!); - lineNumber = frame.lineNumber || undefined; - columnNumber = frame.columnNumber || undefined; - } - - if (componentName && filePath) break; - } - } - - if (!componentName) { - componentName = getComponentDisplayName(element); - } + const sourceInfo = await resolveElementSourceInfo(element); + const componentName = + (await resolveElementComponentName(element)) ?? + sourceInfo?.componentName ?? + null; + const filePath = sourceInfo?.filePath; + const lineNumber = sourceInfo?.lineNumber ?? undefined; + const columnNumber = sourceInfo?.columnNumber ?? undefined; const textContent = element instanceof HTMLElement @@ -998,7 +978,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }) : null; - void getNearestComponentName(element).then((componentName) => { + void resolveElementComponentName(element).then((componentName) => { void executeCopyOperation({ positionX: labelPositionX, operation: () => @@ -1286,18 +1266,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return; } - getStack(element) - .then((stack) => { + resolveElementSourceInfo(element) + .then((sourceInfo) => { if (selectionSourceRequestVersion !== currentVersion) return; - if (!stack) return; - for (const frame of stack) { - if (frame.fileName && isSourceFile(frame.fileName)) { - actions.setSelectionSource( - normalizeFileName(frame.fileName), - frame.lineNumber ?? null, - ); - return; - } + if (sourceInfo?.filePath) { + actions.setSelectionSource( + sourceInfo.filePath, + sourceInfo.lineNumber, + ); + return; } clearSource(); }) @@ -3168,7 +3145,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return; } - getNearestComponentName(element) + resolveElementComponentName(element) .then((name) => { if (componentNameRequestVersion !== currentVersion) return; setResolvedComponentName(name ?? undefined); @@ -3316,7 +3293,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { async ({ element, frozenCount }) => { if (!element) return undefined; if (frozenCount > 1) return undefined; - const name = await getNearestComponentName(element); + const name = await resolveElementComponentName(element); return name ?? undefined; }, ); @@ -3325,8 +3302,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { () => store.contextMenuElement, async (element) => { if (!element) return null; - const stack = await getStack(element); - return resolveSourceFromStack(stack); + return resolveElementSourceInfo(element); }, ); @@ -3532,7 +3508,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return buildActionContext({ element, filePath: fileInfo?.filePath, - lineNumber: fileInfo?.lineNumber, + lineNumber: fileInfo?.lineNumber ?? undefined, tagName: contextMenuTagName(), componentName: contextMenuComponentName(), position, @@ -4271,16 +4247,30 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }, copyElement: copyElementAPI, getSource: async (element: Element): Promise => { - const stack = await getStack(element); - const source = resolveSourceFromStack(stack); + const source = await resolveElementSourceInfo(element); if (!source) return null; return { filePath: source.filePath, - lineNumber: source.lineNumber ?? null, + lineNumber: source.lineNumber, componentName: source.componentName, }; }, - getStackContext, + getStackContext: async (element: Element) => { + const maxLines = 3; + const reactStackContext = await getReactStackContext(element, { + maxLines, + }); + const stack = await resolveElementStack(element); + const frameworkStackContext = formatElementStack(stack, { maxLines }); + + if (!reactStackContext) return frameworkStackContext; + if (!frameworkStackContext) return reactStackContext; + return mergeStackContext( + reactStackContext, + frameworkStackContext, + maxLines, + ); + }, getState: (): ReactGrabState => ({ isActive: isActivated(), isDragging: isDragging(), diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index fa4087a30..f1e2f6958 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -20,6 +20,8 @@ import type { SettableOptions, AgentContext, ActionContext, + ElementSourceInfo, + ElementStackContextOptions, } from "../types.js"; import { DEFAULT_THEME, deepMergeTheme } from "./theme.js"; import { @@ -61,6 +63,10 @@ interface PluginStoreState { type HookName = keyof PluginHooks; +const isDefinedValue = ( + value: ValueType | null | undefined, +): value is ValueType => value !== null && value !== undefined; + const createPluginRegistry = (initialOptions: SettableOptions = {}) => { const plugins = new Map(); const directOptionOverrides: Partial = {}; @@ -289,6 +295,25 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { return result; }; + const callHookResolveAsync = async ( + hookName: HookName, + ...extraArgs: unknown[] + ): Promise => { + for (const { config } of plugins.values()) { + const hook = config.hooks?.[hookName] as + | (( + ...hookArgs: unknown[] + ) => T | null | undefined | Promise) + | undefined; + if (!hook) continue; + const result = await hook(...extraArgs); + if (isDefinedValue(result)) { + return result; + } + } + return null; + }; + const hooks = { onActivate: () => callHook("onActivate"), onDeactivate: () => callHook("onDeactivate"), @@ -360,6 +385,19 @@ const createPluginRegistry = (initialOptions: SettableOptions = {}) => { filePath: string, lineNumber?: number, ) => callHookReduceSync("transformOpenFileUrl", url, filePath, lineNumber), + resolveElementSource: async (element: Element) => + callHookResolveAsync("resolveElementSource", element), + resolveElementComponentName: async (element: Element) => + callHookResolveAsync("resolveElementComponentName", element), + resolveElementStackContext: async ( + element: Element, + options?: ElementStackContextOptions, + ) => + callHookResolveAsync( + "resolveElementStackContext", + element, + options, + ), transformSnippet: async (snippet: string, element: Element) => callHookReduce("transformSnippet", snippet, element), }; diff --git a/packages/react-grab/src/core/plugins/framework-source.ts b/packages/react-grab/src/core/plugins/framework-source.ts new file mode 100644 index 000000000..219d14fdc --- /dev/null +++ b/packages/react-grab/src/core/plugins/framework-source.ts @@ -0,0 +1,42 @@ +import type { Plugin } from "../../types.js"; +import { appendStackContext } from "../../utils/append-stack-context.js"; +import { formatElementStack } from "../../utils/format-element-stack.js"; +import { mergeStackContext } from "../../utils/merge-stack-context.js"; +import { getReactStackContext } from "../source/react.js"; +import { + resolveElementSourceInfo, + resolveElementComponentName, + resolveElementStack, +} from "../source/index.js"; + +export const frameworkSourcePlugin: Plugin = { + name: "framework-source", + hooks: { + resolveElementSource: (element) => resolveElementSourceInfo(element), + resolveElementComponentName: (element) => + resolveElementComponentName(element), + resolveElementStackContext: async (element, options) => { + const maxLines = options?.maxLines ?? 3; + const reactStackContext = await getReactStackContext(element, { + maxLines, + }); + const stack = await resolveElementStack(element); + const frameworkStackContext = formatElementStack(stack, { maxLines }); + + if (!reactStackContext) return frameworkStackContext; + if (!frameworkStackContext) return reactStackContext; + return mergeStackContext( + reactStackContext, + frameworkStackContext, + maxLines, + ); + }, + transformSnippet: async (snippet, element) => { + const stack = await resolveElementStack(element); + const stackContext = formatElementStack(stack); + if (!stackContext) return snippet; + if (snippet.includes(stackContext)) return snippet; + return appendStackContext(snippet, stackContext); + }, + }, +}; diff --git a/packages/react-grab/src/core/source/index.ts b/packages/react-grab/src/core/source/index.ts new file mode 100644 index 000000000..1414b55a4 --- /dev/null +++ b/packages/react-grab/src/core/source/index.ts @@ -0,0 +1,67 @@ +import type { ElementSourceInfo } from "../../types.js"; +import { + resolveReactSourceInfo, + getReactComponentName, + getReactDisplayName, +} from "./react.js"; +import { getSvelteStackFrames } from "./svelte.js"; +import { getVueStackFrames } from "./vue.js"; +import { getSolidStackFrames } from "./solid.js"; + +type FrameworkStackResolver = ( + element: Element, +) => ElementSourceInfo[] | Promise; + +const FRAMEWORK_STACK_RESOLVERS: FrameworkStackResolver[] = [ + getSvelteStackFrames, + getVueStackFrames, + getSolidStackFrames, +]; + +const resolveFrameworkStack = async ( + element: Element, +): Promise => { + for (const resolveStackFrames of FRAMEWORK_STACK_RESOLVERS) { + const stackFrames = await resolveStackFrames(element); + if (stackFrames.length < 1) continue; + const validStackFrames = stackFrames.filter( + (stackFrame) => stackFrame.filePath.length > 0, + ); + if (validStackFrames.length < 1) continue; + return validStackFrames; + } + + return []; +}; + +export const resolveElementStack = async ( + element: Element, +): Promise => { + const reactSource = await resolveReactSourceInfo(element); + const frameworkStack = await resolveFrameworkStack(element); + + if (reactSource) return [reactSource, ...frameworkStack]; + return frameworkStack; +}; + +export const resolveElementSourceInfo = async ( + element: Element, +): Promise => { + const stack = await resolveElementStack(element); + return stack[0] ?? null; +}; + +export const resolveElementComponentName = async ( + element: Element, +): Promise => { + const reactComponentName = await getReactComponentName(element); + if (reactComponentName) return reactComponentName; + + const stack = await resolveElementStack(element); + const frameworkComponentName = stack.find( + (frame) => frame.componentName, + )?.componentName; + if (frameworkComponentName) return frameworkComponentName; + + return getReactDisplayName(element); +}; diff --git a/packages/react-grab/src/core/source/parse-location.ts b/packages/react-grab/src/core/source/parse-location.ts new file mode 100644 index 000000000..d6f0d2f6b --- /dev/null +++ b/packages/react-grab/src/core/source/parse-location.ts @@ -0,0 +1,46 @@ +interface ParsedSourceLocation { + filePath: string; + lineNumber: number; + columnNumber: number; +} + +const SOURCE_DELIMITER = ":"; + +const parsePositiveInteger = (value: string): number | null => { + const parsedValue = Number.parseInt(value, 10); + if (Number.isNaN(parsedValue)) return null; + if (parsedValue < 0) return null; + return parsedValue; +}; + +export const parseSourceLocation = ( + location: string, +): ParsedSourceLocation | null => { + const lastDelimiterIndex = location.lastIndexOf(SOURCE_DELIMITER); + if (lastDelimiterIndex === -1) return null; + + const secondLastDelimiterIndex = location.lastIndexOf( + SOURCE_DELIMITER, + lastDelimiterIndex - 1, + ); + if (secondLastDelimiterIndex === -1) return null; + + const filePath = location.slice(0, secondLastDelimiterIndex); + if (!filePath) return null; + + const lineValue = location.slice( + secondLastDelimiterIndex + 1, + lastDelimiterIndex, + ); + const columnValue = location.slice(lastDelimiterIndex + 1); + + const lineNumber = parsePositiveInteger(lineValue); + const columnNumber = parsePositiveInteger(columnValue); + if (lineNumber === null || columnNumber === null) return null; + + return { + filePath, + lineNumber, + columnNumber, + }; +}; diff --git a/packages/react-grab/src/core/source/react.ts b/packages/react-grab/src/core/source/react.ts new file mode 100644 index 000000000..6da5d1e7e --- /dev/null +++ b/packages/react-grab/src/core/source/react.ts @@ -0,0 +1,519 @@ +import { + isSourceFile, + normalizeFileName, + getOwnerStack, + formatOwnerStack, + hasDebugStack, + parseStack, + StackFrame, +} from "bippy/source"; +import { isCapitalized } from "../../utils/is-capitalized.js"; +import { + getFiberFromHostInstance, + isInstrumentationActive, + getDisplayName, + isCompositeFiber, + traverseFiber, + type Fiber, +} from "bippy"; +import { SYMBOLICATION_TIMEOUT_MS } from "../../constants.js"; +import type { ElementSourceInfo } from "../../types.js"; + +const NON_COMPONENT_PREFIXES = new Set([ + "_", + "$", + "motion.", + "styled.", + "chakra.", + "ark.", + "Primitive.", + "Slot.", +]); + +const NEXT_INTERNAL_COMPONENT_NAMES = new Set([ + "InnerLayoutRouter", + "RedirectErrorBoundary", + "RedirectBoundary", + "HTTPAccessFallbackErrorBoundary", + "HTTPAccessFallbackBoundary", + "LoadingBoundary", + "ErrorBoundary", + "InnerScrollAndFocusHandler", + "ScrollAndFocusHandler", + "RenderFromTemplateContext", + "OuterLayoutRouter", + "body", + "html", + "DevRootHTTPAccessFallbackBoundary", + "AppDevOverlayErrorBoundary", + "AppDevOverlay", + "HotReload", + "Router", + "ErrorBoundaryHandler", + "AppRouter", + "ServerRoot", + "SegmentStateProvider", + "RootErrorBoundary", + "LoadableComponent", + "MotionDOMComponent", +]); + +const REACT_INTERNAL_COMPONENT_NAMES = new Set([ + "Suspense", + "Fragment", + "StrictMode", + "Profiler", + "SuspenseList", +]); + +let cachedIsNextProject: boolean | undefined; + +export const checkIsNextProject = (revalidate?: boolean): boolean => { + if (revalidate) { + cachedIsNextProject = undefined; + } + cachedIsNextProject ??= + typeof document !== "undefined" && + Boolean( + document.getElementById("__NEXT_DATA__") || + document.querySelector("nextjs-portal"), + ); + return cachedIsNextProject; +}; + +const checkIsInternalComponentName = (name: string): boolean => { + if (NEXT_INTERNAL_COMPONENT_NAMES.has(name)) return true; + if (REACT_INTERNAL_COMPONENT_NAMES.has(name)) return true; + for (const prefix of NON_COMPONENT_PREFIXES) { + if (name.startsWith(prefix)) return true; + } + return false; +}; + +export const checkIsSourceComponentName = (name: string): boolean => { + if (name.length <= 1) return false; + if (checkIsInternalComponentName(name)) return false; + if (!isCapitalized(name)) return false; + if (name.startsWith("Primitive.")) return false; + if (name.includes("Provider") || name.includes("Context")) return false; + 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); + const querySuffixIndex = url.lastIndexOf("?"); + if (environmentEndIndex > -1 && querySuffixIndex > -1) { + return decodeURI(url.slice(environmentEndIndex + 1, querySuffixIndex)); + } + } + 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. The dev server reads the bundler's source maps + // (webpack or turbopack) and returns the original file path, line, and column for each frame. + // + // We POST an array of frames and get back PromiseSettledResult[]: + // + // POST /__nextjs_original-stack-frames + // { frames: [{ file, methodName, lineNumber, column, arguments }], + // isServer: true, isEdgeServer: false, isAppDirectory: true } + // + // Response: [{ status: "fulfilled", + // value: { originalStackFrame: { file, lineNumber, column, ignored } } }] + // + // Introduced by vercel/next.js#75557 (batched POST, replaces legacy per-frame GET). + // Handler: packages/next/src/client/components/react-dev-overlay/server/middleware-webpack.ts + // Types: packages/next/src/client/components/react-dev-overlay/server/shared.ts + const response = await fetch("/__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 i = 0; i < serverFrameIndices.length; i++) { + const result = results[i]; + if (result?.status !== "fulfilled") continue; + + const resolved = result.value?.originalStackFrame; + if (!resolved?.file || resolved.ignored) continue; + + const originalFrameIndex = serverFrameIndices[i]; + 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, + }; + }); +}; + +export const findNearestFiberElement = (element: Element): Element => { + if (!isInstrumentationActive()) return element; + let current: Element | null = element; + while (current) { + if (getFiberFromHostInstance(current)) return current; + current = current.parentElement; + } + return element; +}; + +const stackCache = new WeakMap>(); + +const fetchStackForElement = async ( + element: Element, +): Promise => { + try { + const fiber = getFiberFromHostInstance(element); + if (!fiber) return null; + + const frames = await getOwnerStack(fiber); + + if (checkIsNextProject()) { + const enrichedFrames = enrichServerFrameLocations(fiber, frames); + return await symbolicateServerFrames(enrichedFrames); + } + + return frames; + } catch { + return null; + } +}; + +export const getReactStack = ( + element: Element, +): Promise => { + if (!isInstrumentationActive()) return Promise.resolve([]); + + const resolvedElement = findNearestFiberElement(element); + const cached = stackCache.get(resolvedElement); + if (cached) return cached; + + const promise = fetchStackForElement(resolvedElement); + stackCache.set(resolvedElement, promise); + return promise; +}; + +export const getReactComponentName = async ( + element: Element, +): Promise => { + if (!isInstrumentationActive()) return null; + const stack = await getReactStack(element); + if (!stack) return null; + + for (const frame of stack) { + if (frame.functionName && checkIsSourceComponentName(frame.functionName)) { + return frame.functionName; + } + } + + return null; +}; + +export const resolveSourceFromStack = ( + stack: StackFrame[] | null, +): { + filePath: string; + lineNumber: number | undefined; + componentName: string | null; +} | null => { + if (!stack || stack.length === 0) return null; + for (const frame of stack) { + if (frame.fileName && isSourceFile(frame.fileName)) { + return { + filePath: normalizeFileName(frame.fileName), + lineNumber: frame.lineNumber, + componentName: + frame.functionName && checkIsSourceComponentName(frame.functionName) + ? frame.functionName + : null, + }; + } + } + return null; +}; + +export const resolveReactSourceInfo = async ( + element: Element, +): Promise => { + const stack = await getReactStack(element); + const sourceFromStack = resolveSourceFromStack(stack); + if (!sourceFromStack) return null; + + return { + filePath: sourceFromStack.filePath, + lineNumber: sourceFromStack.lineNumber ?? null, + columnNumber: null, + componentName: sourceFromStack.componentName, + }; +}; + +const isUsefulComponentName = (name: string): boolean => { + if (!name) return false; + if (checkIsInternalComponentName(name)) return false; + if (name.startsWith("Primitive.")) return false; + if (name === "SlotClone" || name === "Slot") return false; + return true; +}; + +export const getReactDisplayName = (element: Element): string | null => { + if (!isInstrumentationActive()) return null; + const resolvedElement = findNearestFiberElement(element); + const fiber = getFiberFromHostInstance(resolvedElement); + if (!fiber) return null; + + let currentFiber = fiber.return; + while (currentFiber) { + if (isCompositeFiber(currentFiber)) { + const name = getDisplayName(currentFiber.type); + if (name && isUsefulComponentName(name)) { + return name; + } + } + currentFiber = currentFiber.return; + } + + return null; +}; + +interface ReactStackContextOptions { + maxLines?: number; +} + +const hasSourceFiles = (stack: StackFrame[] | null): boolean => { + if (!stack) return false; + return stack.some( + (frame) => + frame.isServer || (frame.fileName && isSourceFile(frame.fileName)), + ); +}; + +const getComponentNamesFromFiber = ( + element: Element, + maxCount: number, +): string[] => { + if (!isInstrumentationActive()) return []; + const fiber = getFiberFromHostInstance(element); + if (!fiber) return []; + + const componentNames: string[] = []; + traverseFiber( + fiber, + (currentFiber) => { + if (componentNames.length >= maxCount) return true; + if (isCompositeFiber(currentFiber)) { + const name = getDisplayName(currentFiber.type); + if (name && isUsefulComponentName(name)) { + componentNames.push(name); + } + } + return false; + }, + true, + ); + return componentNames; +}; + +export const formatReactStackContext = ( + stack: StackFrame[], + options: ReactStackContextOptions = {}, +): string => { + const { maxLines = 3 } = options; + const isNextProject = checkIsNextProject(); + const stackContext: string[] = []; + + for (const frame of stack) { + if (stackContext.length >= maxLines) break; + + const hasResolvedSource = frame.fileName && isSourceFile(frame.fileName); + + if ( + frame.isServer && + !hasResolvedSource && + (!frame.functionName || checkIsSourceComponentName(frame.functionName)) + ) { + stackContext.push( + `\n in ${frame.functionName || ""} (at Server)`, + ); + continue; + } + + if (hasResolvedSource) { + let line = "\n in "; + const hasComponentName = + frame.functionName && checkIsSourceComponentName(frame.functionName); + + if (hasComponentName) { + line += `${frame.functionName} (at `; + } + + line += normalizeFileName(frame.fileName!); + + // HACK: bundlers like vite mess up the line/column numbers, so we don't show them + if (isNextProject && frame.lineNumber && frame.columnNumber) { + line += `:${frame.lineNumber}:${frame.columnNumber}`; + } + + if (hasComponentName) { + line += `)`; + } + + stackContext.push(line); + } + } + + return stackContext.join(""); +}; + +export const getReactStackContext = async ( + element: Element, + options: ReactStackContextOptions = {}, +): Promise => { + const maxLines = options.maxLines ?? 3; + const stack = await getReactStack(element); + + if (stack && hasSourceFiles(stack)) { + return formatReactStackContext(stack, options); + } + + const componentNames = getComponentNamesFromFiber(element, maxLines); + if (componentNames.length > 0) { + return componentNames.map((name) => `\n in ${name}`).join(""); + } + + return ""; +}; diff --git a/packages/react-grab/src/core/source/solid.ts b/packages/react-grab/src/core/source/solid.ts new file mode 100644 index 000000000..c0763c942 --- /dev/null +++ b/packages/react-grab/src/core/source/solid.ts @@ -0,0 +1,312 @@ +import type { ElementSourceInfo } from "../../types.js"; +import { parseSourceLocation } from "./parse-location.js"; + +interface SolidRuntimeModuleRecord { + url: string; + content: string; +} + +interface SolidHandlerCandidate { + source: string; +} + +interface SolidHandlerSourceMatch { + moduleUrl: string; + moduleContent: string; + handlerSourceIndex: number; +} + +interface SolidLocationMatch { + sourceInfo: ElementSourceInfo; + distance: number; +} + +const SOLID_RUNTIME_MODULES_GLOBAL_NAME = + "__REACT_GRAB_SOLID_RUNTIME_MODULES__"; +const SOLID_HANDLER_PREFIX = "$$"; +const SOURCE_LOCATION_PATTERN = /location:\s*["']([^"']+:\d+:\d+)["']/g; +const MAX_SOURCE_CONTEXT_WINDOW_CHARS = 4000; +const SOURCE_CONTEXT_HALF_WINDOW_CHARS = MAX_SOURCE_CONTEXT_WINDOW_CHARS / 2; +const SOURCE_CONTEXT_WINDOW_START_CHARS = 0; +const SOURCE_LINE_START_COLUMN = 1; +const SOURCE_MODULE_PATH_PREFIX = "/src/"; +const CSS_FILE_EXTENSION = ".css"; +const IMAGE_IMPORT_SUFFIX = "?import"; +const MODULE_SOURCE_CACHE = new Map>(); +const SOLID_HANDLER_STACK_CACHE = new Map< + string, + Promise +>(); +const SOLID_HANDLER_SOURCE_LENGTH_MIN_CHARS = 3; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const readString = (value: unknown): string | null => + typeof value === "string" ? value : null; + +const readRuntimeModulesFromWindow = (): SolidRuntimeModuleRecord[] => { + if (typeof window === "undefined") return []; + const rawRuntimeModules = Reflect.get( + window, + SOLID_RUNTIME_MODULES_GLOBAL_NAME, + ); + if (!Array.isArray(rawRuntimeModules)) return []; + + return rawRuntimeModules + .map((rawRuntimeModule) => { + if (!isRecord(rawRuntimeModule)) return null; + const url = readString(rawRuntimeModule.url); + const content = readString(rawRuntimeModule.content); + if (!url || !content) return null; + return { url, content }; + }) + .filter((runtimeModule): runtimeModule is SolidRuntimeModuleRecord => + Boolean(runtimeModule), + ); +}; + +const shouldIncludeSourceModule = (resourceUrl: string): boolean => { + if (resourceUrl.includes(IMAGE_IMPORT_SUFFIX)) return false; + const resourcePath = new URL(resourceUrl, window.location.href).pathname; + if (resourcePath.endsWith(CSS_FILE_EXTENSION)) return false; + return resourcePath.includes(SOURCE_MODULE_PATH_PREFIX); +}; + +const readSourceModuleUrlsFromPerformance = (): string[] => { + if (typeof window === "undefined") return []; + const resourceEntries = performance.getEntriesByType("resource"); + const uniqueModuleUrls = new Set(); + + for (const resourceEntry of resourceEntries) { + const resourceUrl = resourceEntry.name; + if (!resourceUrl) continue; + if (!shouldIncludeSourceModule(resourceUrl)) continue; + uniqueModuleUrls.add(resourceUrl); + } + + return Array.from(uniqueModuleUrls); +}; + +const getSourceModuleContent = async ( + moduleUrl: string, +): Promise => { + const cachedSource = MODULE_SOURCE_CACHE.get(moduleUrl); + if (cachedSource) return cachedSource; + + const sourcePromise = fetch(moduleUrl) + .then((response) => { + if (!response.ok) return null; + return response.text(); + }) + .catch(() => null); + + MODULE_SOURCE_CACHE.set(moduleUrl, sourcePromise); + return sourcePromise; +}; + +const resolveHandlerSourceMatch = async ( + handlerSource: string, +): Promise => { + const runtimeModules = readRuntimeModulesFromWindow(); + if (runtimeModules.length > 0) { + for (const runtimeModule of runtimeModules) { + const handlerSourceIndex = runtimeModule.content.indexOf(handlerSource); + if (handlerSourceIndex === -1) continue; + return { + moduleUrl: runtimeModule.url, + moduleContent: runtimeModule.content, + handlerSourceIndex, + }; + } + } + + const sourceModuleUrls = readSourceModuleUrlsFromPerformance(); + for (const sourceModuleUrl of sourceModuleUrls) { + const sourceModuleContent = await getSourceModuleContent(sourceModuleUrl); + if (!sourceModuleContent) continue; + const handlerSourceIndex = sourceModuleContent.indexOf(handlerSource); + if (handlerSourceIndex === -1) continue; + return { + moduleUrl: sourceModuleUrl, + moduleContent: sourceModuleContent, + handlerSourceIndex, + }; + } + + return null; +}; + +const parseLocationLiteralsByDistance = ( + moduleContent: string, + handlerSourceIndex: number, +): ElementSourceInfo[] => { + const contextWindowStartIndex = Math.max( + SOURCE_CONTEXT_WINDOW_START_CHARS, + handlerSourceIndex - SOURCE_CONTEXT_HALF_WINDOW_CHARS, + ); + const contextWindowEndIndex = Math.min( + moduleContent.length, + handlerSourceIndex + SOURCE_CONTEXT_HALF_WINDOW_CHARS, + ); + const contextWindowText = moduleContent.slice( + contextWindowStartIndex, + contextWindowEndIndex, + ); + const locationMatches: SolidLocationMatch[] = []; + + for (const locationMatch of contextWindowText.matchAll( + SOURCE_LOCATION_PATTERN, + )) { + const rawLocation = locationMatch[1]; + if (!rawLocation) continue; + const parsedLocation = parseSourceLocation(rawLocation); + if (!parsedLocation) continue; + + const matchIndex = locationMatch.index; + if (matchIndex === undefined) continue; + const absoluteMatchIndex = contextWindowStartIndex + matchIndex; + const locationDistance = Math.abs(absoluteMatchIndex - handlerSourceIndex); + locationMatches.push({ + sourceInfo: { + filePath: parsedLocation.filePath, + lineNumber: parsedLocation.lineNumber, + columnNumber: parsedLocation.columnNumber, + componentName: null, + }, + distance: locationDistance, + }); + } + + locationMatches.sort((leftLocationMatch, rightLocationMatch) => { + const leftLineNumber = leftLocationMatch.sourceInfo.lineNumber ?? 0; + const rightLineNumber = rightLocationMatch.sourceInfo.lineNumber ?? 0; + if (rightLineNumber !== leftLineNumber) { + return rightLineNumber - leftLineNumber; + } + return leftLocationMatch.distance - rightLocationMatch.distance; + }); + + const seenLocations = new Set(); + const uniqueLocationFrames: ElementSourceInfo[] = []; + + for (const locationMatch of locationMatches) { + const locationIdentity = `${locationMatch.sourceInfo.filePath}:${locationMatch.sourceInfo.lineNumber}:${locationMatch.sourceInfo.columnNumber}`; + if (seenLocations.has(locationIdentity)) continue; + seenLocations.add(locationIdentity); + uniqueLocationFrames.push(locationMatch.sourceInfo); + } + + return uniqueLocationFrames; +}; + +const toProjectRelativeModulePath = (moduleUrl: string): string | null => { + try { + const parsedUrl = new URL(moduleUrl, window.location.href); + const sourceModulePath = decodeURIComponent(parsedUrl.pathname); + if (!sourceModulePath.includes(SOURCE_MODULE_PATH_PREFIX)) return null; + return sourceModulePath.startsWith("/") + ? sourceModulePath.slice(1) + : sourceModulePath; + } catch { + return null; + } +}; + +const getGeneratedLocationFromModule = ( + moduleContent: string, + handlerSourceIndex: number, +): { lineNumber: number; columnNumber: number } => { + const prefixContent = moduleContent.slice( + SOURCE_CONTEXT_WINDOW_START_CHARS, + handlerSourceIndex, + ); + const sourceLines = prefixContent.split("\n"); + const lineNumber = sourceLines.length; + const previousLine = sourceLines[sourceLines.length - 1] ?? ""; + const columnNumber = previousLine.length + SOURCE_LINE_START_COLUMN; + + return { + lineNumber, + columnNumber, + }; +}; + +const findSolidHandlerCandidate = ( + element: Element, +): SolidHandlerCandidate | null => { + let currentElement: Element | null = element; + + while (currentElement) { + const ownPropertyNames = Object.getOwnPropertyNames(currentElement); + for (const ownPropertyName of ownPropertyNames) { + if (!ownPropertyName.startsWith(SOLID_HANDLER_PREFIX)) continue; + const ownPropertyValue = Reflect.get(currentElement, ownPropertyName); + if (typeof ownPropertyValue !== "function") continue; + const handlerSource = String(ownPropertyValue).trim(); + if (handlerSource.length < SOLID_HANDLER_SOURCE_LENGTH_MIN_CHARS) { + continue; + } + return { + source: handlerSource, + }; + } + currentElement = currentElement.parentElement; + } + + return null; +}; + +const buildGeneratedFrameFromModuleMatch = ( + handlerSourceMatch: SolidHandlerSourceMatch, +): ElementSourceInfo | null => { + const modulePath = toProjectRelativeModulePath(handlerSourceMatch.moduleUrl); + if (!modulePath) return null; + + const generatedLocation = getGeneratedLocationFromModule( + handlerSourceMatch.moduleContent, + handlerSourceMatch.handlerSourceIndex, + ); + + return { + filePath: modulePath, + lineNumber: generatedLocation.lineNumber, + columnNumber: generatedLocation.columnNumber, + componentName: null, + }; +}; + +const resolveSolidStackFramesFromHandler = async ( + handlerSource: string, +): Promise => { + const cachedStackFrames = SOLID_HANDLER_STACK_CACHE.get(handlerSource); + if (cachedStackFrames) return cachedStackFrames; + + const stackFramesPromise = (async () => { + const handlerSourceMatch = await resolveHandlerSourceMatch(handlerSource); + if (!handlerSourceMatch) return []; + + const locationLiteralStackFrames = parseLocationLiteralsByDistance( + handlerSourceMatch.moduleContent, + handlerSourceMatch.handlerSourceIndex, + ); + if (locationLiteralStackFrames.length > 0) + return locationLiteralStackFrames; + + const generatedStackFrame = + buildGeneratedFrameFromModuleMatch(handlerSourceMatch); + if (!generatedStackFrame) return []; + return [generatedStackFrame]; + })(); + + SOLID_HANDLER_STACK_CACHE.set(handlerSource, stackFramesPromise); + return stackFramesPromise; +}; + +export const getSolidStackFrames = ( + element: Element, +): Promise => { + const solidHandlerCandidate = findSolidHandlerCandidate(element); + if (!solidHandlerCandidate) return Promise.resolve([]); + return resolveSolidStackFramesFromHandler(solidHandlerCandidate.source); +}; diff --git a/packages/react-grab/src/core/source/svelte.ts b/packages/react-grab/src/core/source/svelte.ts new file mode 100644 index 000000000..98326a2f3 --- /dev/null +++ b/packages/react-grab/src/core/source/svelte.ts @@ -0,0 +1,113 @@ +import type { ElementSourceInfo } from "../../types.js"; + +const SVELTE_META_PROPERTY_NAME = "__svelte_meta"; +const SVELTE_COLUMN_OFFSET = 1; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const readString = (value: unknown): string | null => + typeof value === "string" ? value : null; + +const readNumber = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +const getNearestSvelteMeta = ( + element: Element, +): Record | null => { + let currentElement: Element | null = element; + while (currentElement) { + const svelteMeta = Reflect.get(currentElement, SVELTE_META_PROPERTY_NAME); + if (isRecord(svelteMeta)) return svelteMeta; + currentElement = currentElement.parentElement; + } + return null; +}; + +const readSvelteLocation = ( + svelteMeta: Record, +): { filePath: string; lineNumber: number; columnNumber: number } | null => { + const location = svelteMeta.loc; + if (!isRecord(location)) return null; + + const filePath = readString(location.file); + const lineNumber = readNumber(location.line); + const rawColumnNumber = readNumber(location.column); + if (!filePath) return null; + if (lineNumber === null || rawColumnNumber === null) return null; + + return { + filePath, + lineNumber, + columnNumber: rawColumnNumber + SVELTE_COLUMN_OFFSET, + }; +}; + +const readComponentNameFromParentMeta = ( + svelteMeta: Record, +) => { + let currentParent = svelteMeta.parent; + while (isRecord(currentParent)) { + const componentTag = readString(currentParent.componentTag); + if (componentTag) return componentTag; + currentParent = currentParent.parent; + } + return null; +}; + +const readSvelteParentStackFrames = ( + svelteMeta: Record, +): ElementSourceInfo[] => { + const parentStackFrames: ElementSourceInfo[] = []; + let currentParent = svelteMeta.parent; + + while (isRecord(currentParent)) { + const filePath = readString(currentParent.file); + const lineNumber = readNumber(currentParent.line); + const rawColumnNumber = readNumber(currentParent.column); + const componentName = readString(currentParent.componentTag); + + if (filePath && lineNumber !== null && rawColumnNumber !== null) { + parentStackFrames.push({ + filePath, + lineNumber, + columnNumber: rawColumnNumber + SVELTE_COLUMN_OFFSET, + componentName, + }); + } + + currentParent = currentParent.parent; + } + + return parentStackFrames; +}; + +export const getSvelteStackFrames = (element: Element): ElementSourceInfo[] => { + const svelteMeta = getNearestSvelteMeta(element); + if (!svelteMeta) return []; + + const sourceLocation = readSvelteLocation(svelteMeta); + if (!sourceLocation) return []; + + const stackFrames: ElementSourceInfo[] = [ + { + filePath: sourceLocation.filePath, + lineNumber: sourceLocation.lineNumber, + columnNumber: sourceLocation.columnNumber, + componentName: readComponentNameFromParentMeta(svelteMeta), + }, + ]; + const seenFrameIdentities = new Set([ + `${sourceLocation.filePath}:${sourceLocation.lineNumber}:${sourceLocation.columnNumber}`, + ]); + + const parentStackFrames = readSvelteParentStackFrames(svelteMeta); + for (const parentStackFrame of parentStackFrames) { + const frameIdentity = `${parentStackFrame.filePath}:${parentStackFrame.lineNumber ?? ""}:${parentStackFrame.columnNumber ?? ""}`; + if (seenFrameIdentities.has(frameIdentity)) continue; + seenFrameIdentities.add(frameIdentity); + stackFrames.push(parentStackFrame); + } + + return stackFrames; +}; diff --git a/packages/react-grab/src/core/source/vue.ts b/packages/react-grab/src/core/source/vue.ts new file mode 100644 index 000000000..2aca82a88 --- /dev/null +++ b/packages/react-grab/src/core/source/vue.ts @@ -0,0 +1,141 @@ +import type { ElementSourceInfo } from "../../types.js"; +import { parseSourceLocation } from "./parse-location.js"; + +const VUE_INSPECTOR_ATTRIBUTE_NAME = "data-v-inspector"; +const VUE_INSPECTOR_SELECTOR = `[${VUE_INSPECTOR_ATTRIBUTE_NAME}]`; +const VUE_PARENT_COMPONENT_PROPERTY_NAME = "__vueParentComponent"; +const VUE_PARENT_COMPONENT_PARENT_PROPERTY_NAME = "parent"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const readString = (value: unknown): string | null => + typeof value === "string" ? value : null; + +const getVueComponentType = ( + component: Record | null, +): Record | null => { + if (!component) return null; + const componentType = component.type; + return isRecord(componentType) ? componentType : null; +}; + +const getVueParentComponent = ( + element: Element, +): Record | null => { + const component = Reflect.get(element, VUE_PARENT_COMPONENT_PROPERTY_NAME); + return isRecord(component) ? component : null; +}; + +const getVueParentComponentFromComponent = ( + component: Record | null, +): Record | null => { + if (!component) return null; + const parentComponent = Reflect.get( + component, + VUE_PARENT_COMPONENT_PARENT_PROPERTY_NAME, + ); + return isRecord(parentComponent) ? parentComponent : null; +}; + +const getNearestVueParentComponent = ( + element: Element, +): Record | null => { + let currentElement: Element | null = element; + while (currentElement) { + const component = getVueParentComponent(currentElement); + if (component) return component; + currentElement = currentElement.parentElement; + } + return null; +}; + +const getVueComponentName = ( + componentType: Record | null, +): string | null => { + if (!componentType) return null; + return readString(componentType.__name) ?? readString(componentType.name); +}; + +const getVueComponentFilePath = ( + componentType: Record | null, +): string | null => { + if (!componentType) return null; + return readString(componentType.__file); +}; + +const getVueComponentChain = (element: Element): Record[] => { + const componentChain: Record[] = []; + const nearestComponent = getNearestVueParentComponent(element); + let currentComponent: Record | null = nearestComponent; + + while (currentComponent) { + componentChain.push(currentComponent); + currentComponent = getVueParentComponentFromComponent(currentComponent); + } + + return componentChain; +}; + +const getVueRuntimeStackFrames = (element: Element): ElementSourceInfo[] => + getVueComponentChain(element) + .map((component): ElementSourceInfo | null => { + const componentType = getVueComponentType(component); + const filePath = getVueComponentFilePath(componentType); + if (!filePath) return null; + return { + filePath, + lineNumber: null, + columnNumber: null, + componentName: getVueComponentName(componentType), + }; + }) + .filter((frame): frame is ElementSourceInfo => Boolean(frame)); + +const resolveFromInspectorAttribute = ( + element: Element, +): ElementSourceInfo | null => { + const sourceElement = element.closest(VUE_INSPECTOR_SELECTOR); + if (!sourceElement) return null; + + const sourceLocation = sourceElement.getAttribute( + VUE_INSPECTOR_ATTRIBUTE_NAME, + ); + if (!sourceLocation) return null; + + const parsedLocation = parseSourceLocation(sourceLocation); + if (!parsedLocation) return null; + + const nearestComponent = getNearestVueParentComponent(element); + const nearestComponentType = getVueComponentType(nearestComponent); + const componentName = getVueComponentName(nearestComponentType); + + return { + filePath: parsedLocation.filePath, + lineNumber: parsedLocation.lineNumber, + columnNumber: parsedLocation.columnNumber, + componentName, + }; +}; + +export const getVueStackFrames = (element: Element): ElementSourceInfo[] => { + const combinedStackFrames: ElementSourceInfo[] = []; + const seenFrameIdentities = new Set(); + + const inspectorInfo = resolveFromInspectorAttribute(element); + if (inspectorInfo) { + const inspectorFrameIdentity = `${inspectorInfo.filePath}|${inspectorInfo.componentName ?? ""}`; + combinedStackFrames.push(inspectorInfo); + seenFrameIdentities.add(inspectorFrameIdentity); + } + + const runtimeStackFrames = getVueRuntimeStackFrames(element); + for (const runtimeStackFrame of runtimeStackFrames) { + const runtimeFrameIdentity = `${runtimeStackFrame.filePath}|${runtimeStackFrame.componentName ?? ""}`; + if (seenFrameIdentities.has(runtimeFrameIdentity)) continue; + seenFrameIdentities.add(runtimeFrameIdentity); + combinedStackFrames.push(runtimeStackFrame); + } + + return combinedStackFrames; +}; diff --git a/packages/react-grab/src/index.ts b/packages/react-grab/src/index.ts index 03066ad27..876ec5b4b 100644 --- a/packages/react-grab/src/index.ts +++ b/packages/react-grab/src/index.ts @@ -7,6 +7,7 @@ export { } from "./core/index.js"; export { commentPlugin } from "./core/plugins/comment.js"; export { openPlugin } from "./core/plugins/open.js"; +export { frameworkSourcePlugin } from "./core/plugins/framework-source.js"; export { generateSnippet } from "./utils/generate-snippet.js"; export type { Options, diff --git a/packages/react-grab/src/primitives.ts b/packages/react-grab/src/primitives.ts index fe142ada9..7f7db6f98 100644 --- a/packages/react-grab/src/primitives.ts +++ b/packages/react-grab/src/primitives.ts @@ -6,12 +6,13 @@ import { import { freezePseudoStates } from "./utils/freeze-pseudo-states.js"; import { freezeUpdates } from "./utils/freeze-updates.js"; import { unfreezePseudoStates } from "./utils/freeze-pseudo-states.js"; +import { getHTMLPreview } from "./core/context.js"; +import { getReactStack } from "./core/source/react.js"; import { - getComponentDisplayName, - getHTMLPreview, - getStack, - getStackContext, -} from "./core/context.js"; + resolveElementComponentName, + resolveElementStack, +} from "./core/source/index.js"; +import { formatElementStack } from "./utils/format-element-stack.js"; import { Fiber, getFiberFromHostInstance } from "bippy"; import type { StackFrame } from "bippy/source"; export type { StackFrame }; @@ -31,23 +32,24 @@ export interface ReactGrabElementContext { } /** - * Gathers comprehensive context for a DOM element, including its React fiber, - * component name, source stack, HTML preview, CSS selector, and computed styles. + * Gathers comprehensive context for a DOM element across all supported frameworks + * (React, Vue, Svelte, Solid), including component name, source stack, HTML preview, + * CSS selector, and computed styles. The `fiber` field is React-specific. * * @example * const context = await getElementContext(document.querySelector('.my-button')!); * console.log(context.componentName); // "SubmitButton" * console.log(context.selector); // "button.my-button" * console.log(context.stackString); // "\n in SubmitButton (at Button.tsx:12:5)" - * console.log(context.stack[0]); // { functionName: "SubmitButton", fileName: "Button.tsx", lineNumber: 12, columnNumber: 5 } */ export const getElementContext = async ( element: Element, ): Promise => { - const stack = (await getStack(element)) ?? []; - const stackString = await getStackContext(element); + const stack = (await getReactStack(element)) ?? []; + const elementStack = await resolveElementStack(element); + const stackString = formatElementStack(elementStack); const htmlPreview = getHTMLPreview(element); - const componentName = getComponentDisplayName(element); + const componentName = await resolveElementComponentName(element); const fiber = getFiberFromHostInstance(element); const selector = createElementSelector(element); const styles = extractElementCss(element); diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 44135b9e6..5fa2fd237 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -275,6 +275,17 @@ export interface PerformWithFeedbackOptions { position?: { x: number; y: number }; } +export interface ElementStackContextOptions { + maxLines?: number; +} + +export interface ElementSourceInfo { + filePath: string; + lineNumber: number | null; + columnNumber: number | null; + componentName: string | null; +} + export interface PluginHooks { onActivate?: () => void; onDeactivate?: () => void; @@ -328,6 +339,16 @@ export interface PluginHooks { filePath: string, lineNumber?: number, ) => string; + resolveElementSource?: ( + element: Element, + ) => ElementSourceInfo | null | Promise; + resolveElementComponentName?: ( + element: Element, + ) => string | null | Promise; + resolveElementStackContext?: ( + element: Element, + options?: ElementStackContextOptions, + ) => string | null | Promise; transformSnippet?: ( snippet: string, element: Element, diff --git a/packages/react-grab/src/utils/format-element-stack.ts b/packages/react-grab/src/utils/format-element-stack.ts new file mode 100644 index 000000000..4bc292dae --- /dev/null +++ b/packages/react-grab/src/utils/format-element-stack.ts @@ -0,0 +1,36 @@ +import type { + ElementSourceInfo, + ElementStackContextOptions, +} from "../types.js"; + +const formatSourceLocation = (sourceInfo: ElementSourceInfo): string => { + const locationParts = [sourceInfo.filePath]; + if (sourceInfo.lineNumber !== null) { + locationParts.push(String(sourceInfo.lineNumber)); + } + if (sourceInfo.columnNumber !== null) { + locationParts.push(String(sourceInfo.columnNumber)); + } + return locationParts.join(":"); +}; + +export const formatStackFrame = (stackFrame: ElementSourceInfo): string => { + const sourceLocation = formatSourceLocation(stackFrame); + if (stackFrame.componentName) { + return `\n in ${stackFrame.componentName} (at ${sourceLocation})`; + } + return `\n in ${sourceLocation}`; +}; + +export const formatElementStack = ( + stack: ElementSourceInfo[], + options: ElementStackContextOptions = {}, +): string => { + const { maxLines = 3 } = options; + if (maxLines < 1 || stack.length < 1) return ""; + + return stack + .slice(0, maxLines) + .map((stackFrame) => formatStackFrame(stackFrame)) + .join(""); +}; diff --git a/packages/react-grab/src/utils/merge-stack-context.ts b/packages/react-grab/src/utils/merge-stack-context.ts new file mode 100644 index 000000000..256bb39f1 --- /dev/null +++ b/packages/react-grab/src/utils/merge-stack-context.ts @@ -0,0 +1,32 @@ +const STACK_CONTEXT_LINE_SEPARATOR = "\n"; +const STACK_CONTEXT_LINE_PREFIX = "in "; +const STACK_CONTEXT_LINE_INDENT = "\n "; + +const getStackContextLines = (stackContext: string): string[] => + stackContext + .split(STACK_CONTEXT_LINE_SEPARATOR) + .map((stackLine) => stackLine.trim()) + .filter((stackLine) => stackLine.startsWith(STACK_CONTEXT_LINE_PREFIX)) + .map((stackLine) => `${STACK_CONTEXT_LINE_INDENT}${stackLine}`); + +export const mergeStackContext = ( + primaryStackContext: string, + secondaryStackContext: string, + maxLines: number, +): string => { + if (maxLines < 1) return ""; + + const mergedStackLines: string[] = []; + const seenStackLines = new Set(); + + for (const stackContext of [primaryStackContext, secondaryStackContext]) { + const stackContextLines = getStackContextLines(stackContext); + for (const stackContextLine of stackContextLines) { + if (seenStackLines.has(stackContextLine)) continue; + seenStackLines.add(stackContextLine); + mergedStackLines.push(stackContextLine); + } + } + + return mergedStackLines.slice(0, maxLines).join(""); +}; diff --git a/packages/website/components/install-tabs.tsx b/packages/website/components/install-tabs.tsx index 3afb48c98..12ce2d793 100644 --- a/packages/website/components/install-tabs.tsx +++ b/packages/website/components/install-tabs.tsx @@ -467,7 +467,9 @@ export const InstallTabs = ({ {shouldShowPromptExpandButton && (