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 && (