Created
February 6, 2026 15:41
-
-
Save jtmuller5/401460039199e7886bb85f732b18bf0f to your computer and use it in GitHub Desktop.
Click-to-component plugin for React Grab, designed for TanStack Start
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useEffect } from "react"; | |
| /* | |
| Add the following to your root component, e.g., in __root.tsx: | |
| <ReactGrab editor="vscode" projectRoot="/Users/you/project" /> | |
| */ | |
| type Editor = "vscode" | "vscode-insiders" | "cursor" | (string & {}); | |
| interface ClickToComponentOptions { | |
| editor?: Editor; | |
| pathModifier?: (path: string) => string; | |
| altClick?: boolean; | |
| } | |
| // Get React fiber from DOM element | |
| function getFiberFromElement(element: Element): Fiber | null { | |
| for (const key of Object.keys(element)) { | |
| if ( | |
| key.startsWith("__reactFiber$") || | |
| key.startsWith("__reactInternalInstance$") | |
| ) { | |
| return (element as unknown as Record<string, Fiber>)[key]; | |
| } | |
| } | |
| return null; | |
| } | |
| interface Fiber { | |
| return: Fiber | null; | |
| _debugSource?: { fileName: string; lineNumber: number }; | |
| _debugOwner?: Fiber; | |
| _debugStack?: Error; | |
| type?: { name?: string } | string; | |
| } | |
| // Parse stack trace from _debugStack to get source location | |
| // Stack frames can be URLs like: http://localhost:7777/_build/@fs/Users/.../file.tsx:6:39 | |
| function parseDebugStack( | |
| stack: Error | undefined, | |
| ): { filePath: string; lineNumber: number } | null { | |
| if (!stack?.stack) return null; | |
| const lines = stack.stack.split("\n"); | |
| for (const line of lines) { | |
| // Match URL format: http://localhost:.../@fs/path/to/file.tsx:line:col | |
| // The /@fs/ prefix indicates a real filesystem path | |
| const fsMatch = line.match(/@fs(\/[^:]+):(\d+):\d+/); | |
| if (fsMatch) { | |
| return { filePath: fsMatch[1], lineNumber: parseInt(fsMatch[2], 10) }; | |
| } | |
| // Match URL format with /_build/src/: http://localhost:.../_build/src/file.tsx:line:col | |
| const buildMatch = line.match(/\/_build\/(src\/[^:]+):(\d+):\d+/); | |
| if (buildMatch) { | |
| return { | |
| filePath: buildMatch[1], | |
| lineNumber: parseInt(buildMatch[2], 10), | |
| }; | |
| } | |
| // Match traditional format: "at ComponentName (file.tsx:123:45)" | |
| const traditionalMatch = line.match( | |
| /at\s+(?:\S+\s+)?\(?([^:\s()]+):(\d+):\d+\)?/, | |
| ); | |
| if (traditionalMatch && !traditionalMatch[1].includes("node_modules")) { | |
| return { | |
| filePath: traditionalMatch[1], | |
| lineNumber: parseInt(traditionalMatch[2], 10), | |
| }; | |
| } | |
| } | |
| return null; | |
| } | |
| // Check if path is a library file (not our project) | |
| function isLibraryPath(path: string): boolean { | |
| return ( | |
| path.includes("node_modules") || | |
| path.startsWith("/@") || | |
| /(?:\.\.\/)+@/.test(path) || | |
| path.includes("@tanstack") || | |
| path.includes("@vinxi") || | |
| path.includes("react-dom") || | |
| path.includes("react/") | |
| ); | |
| } | |
| // Walk fiber tree to find first project component source | |
| function findProjectSource( | |
| element: Element, | |
| ): { filePath: string; lineNumber: number } | null { | |
| let fiber = getFiberFromElement(element); | |
| const visited = new Set<Fiber>(); | |
| while (fiber) { | |
| if (visited.has(fiber)) break; | |
| visited.add(fiber); | |
| // React 19 uses _debugStack instead of _debugSource | |
| // Try _debugStack first (React 19), then _debugSource (React 18) | |
| let filePath: string | undefined; | |
| let lineNumber: number | undefined; | |
| if (fiber._debugStack) { | |
| const parsed = parseDebugStack(fiber._debugStack); | |
| if (parsed) { | |
| filePath = parsed.filePath; | |
| lineNumber = parsed.lineNumber; | |
| } | |
| } else if (fiber._debugSource) { | |
| filePath = fiber._debugSource.fileName; | |
| lineNumber = fiber._debugSource.lineNumber; | |
| } | |
| if (filePath && !isLibraryPath(filePath)) { | |
| return { filePath, lineNumber: lineNumber ?? 1 }; | |
| } | |
| // Walk up via _debugOwner first (component that rendered this), | |
| // then fall back to return (parent fiber) | |
| fiber = fiber._debugOwner ?? fiber.return; | |
| } | |
| return null; | |
| } | |
| const createClickToComponentPlugin = ( | |
| options: ClickToComponentOptions = {}, | |
| ) => { | |
| const { editor = "vscode-insiders", pathModifier, altClick = true } = options; | |
| const buildUrl = (filePath: string, line?: number) => { | |
| const path = pathModifier ? pathModifier(filePath) : filePath; | |
| if (!path) return null; // Skip if pathModifier returns empty | |
| const fullPath = `${path}:${line ?? 1}:1`; | |
| return fullPath[0] === "/" | |
| ? `${editor}://file${fullPath}` | |
| : `${editor}://file/${fullPath}`; | |
| }; | |
| const openFile = (filePath: string, lineNumber?: number) => { | |
| const url = buildUrl(filePath, lineNumber); | |
| if (url) window.location.assign(url); | |
| }; | |
| return { | |
| name: "click-to-component", | |
| hooks: { | |
| onOpenFile: (filePath: string, lineNumber?: number) => { | |
| openFile(filePath, lineNumber); | |
| return true; | |
| }, | |
| }, | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| setup: (_api: any) => { | |
| if (!altClick) return; | |
| let isAltHeld = false; | |
| let target: HTMLElement | null = null; | |
| const onKeyDown = (e: KeyboardEvent) => { | |
| if (e.altKey) isAltHeld = true; | |
| }; | |
| const onKeyUp = () => { | |
| isAltHeld = false; | |
| target?.removeAttribute("data-ctc"); | |
| target = null; | |
| }; | |
| const onMouseMove = (e: MouseEvent) => { | |
| if (!isAltHeld || !(e.target instanceof HTMLElement)) return; | |
| target?.removeAttribute("data-ctc"); | |
| target = e.target; | |
| target.setAttribute("data-ctc", ""); | |
| }; | |
| const onClick = (e: MouseEvent) => { | |
| if (!isAltHeld || !target) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const currentTarget = target; | |
| // Use our custom fiber walking to skip library components | |
| const source = findProjectSource(currentTarget); | |
| if (source?.filePath) { | |
| openFile(source.filePath, source.lineNumber); | |
| } | |
| isAltHeld = false; | |
| currentTarget.removeAttribute("data-ctc"); | |
| target = null; | |
| }; | |
| const style = document.createElement("style"); | |
| style.textContent = `[data-ctc] { outline: -webkit-focus-ring-color auto 1px !important; cursor: pointer !important; }`; | |
| document.head.appendChild(style); | |
| window.addEventListener("keydown", onKeyDown); | |
| window.addEventListener("keyup", onKeyUp); | |
| window.addEventListener("mousemove", onMouseMove); | |
| window.addEventListener("click", onClick, { capture: true }); | |
| return { | |
| cleanup: () => { | |
| window.removeEventListener("keydown", onKeyDown); | |
| window.removeEventListener("keyup", onKeyUp); | |
| window.removeEventListener("mousemove", onMouseMove); | |
| window.removeEventListener("click", onClick, { capture: true }); | |
| style.remove(); | |
| }, | |
| }; | |
| }, | |
| }; | |
| }; | |
| type ClickToComponentPlugin = ReturnType<typeof createClickToComponentPlugin>; | |
| interface ReactGrabProps { | |
| /** Editor to open files in. Default: "cursor" */ | |
| editor?: Editor; | |
| /** Project root path. Auto-detected from Vite if not provided */ | |
| projectRoot?: string; | |
| } | |
| export function ReactGrab({ | |
| editor = "vscode-insiders", | |
| projectRoot, | |
| }: ReactGrabProps = {}) { | |
| useEffect(() => { | |
| if (import.meta.env.PROD) return; | |
| let cleanup: (() => void) | undefined; | |
| void import("react-grab").then((m) => { | |
| const api = m.getGlobalApi(); | |
| if (!api) return; | |
| api.setEnabled(true); | |
| const handleKeyDown = (event: KeyboardEvent) => { | |
| if (event.metaKey && event.key === " ") { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| setTimeout(() => { | |
| if (api.isActive()) { | |
| api.deactivate(); | |
| } else { | |
| api.activate(); | |
| } | |
| }, 0); | |
| } | |
| }; | |
| document.addEventListener("keydown", handleKeyDown, true); | |
| cleanup = () => | |
| document.removeEventListener("keydown", handleKeyDown, true); | |
| ( | |
| window as { | |
| __REACT_GRAB__?: { | |
| registerPlugin: (plugin: ClickToComponentPlugin) => void; | |
| }; | |
| } | |
| ).__REACT_GRAB__?.registerPlugin( | |
| createClickToComponentPlugin({ | |
| editor, | |
| pathModifier: (path) => { | |
| // TanStack Start/Vinxi builds to /_build/src/... | |
| // Strip that prefix to get the real source path | |
| if (path.startsWith("/_build/")) { | |
| path = path.slice("/_build/".length); | |
| } | |
| // If we have a project root and path is relative, make it absolute | |
| if (projectRoot) { | |
| if (path.startsWith("src/")) { | |
| return `${projectRoot}/${path}`; | |
| } | |
| if (path.startsWith("/src/")) { | |
| return `${projectRoot}${path}`; | |
| } | |
| } | |
| // Skip library source files - not useful to navigate to | |
| // Handles: /@tanstack/..., /node_modules/..., ../../../../@tanstack/... | |
| if ( | |
| path.startsWith("/@") || | |
| path.startsWith("/node_modules/") || | |
| /(?:\.\.\/)+@/.test(path) | |
| ) { | |
| return ""; | |
| } | |
| // If path is already absolute, use it directly | |
| if (path.startsWith("/")) { | |
| return path; | |
| } | |
| // Relative path with project root | |
| if (projectRoot) { | |
| return `${projectRoot}/${path}`; | |
| } | |
| return path; | |
| }, | |
| }), | |
| ); | |
| }); | |
| return () => cleanup?.(); | |
| }, [editor, projectRoot]); | |
| return null; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment