diff --git a/package.json b/package.json index f91d326b..87d4e0fe 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@changesets/cli": "^2.27.10", "oxfmt": "^0.27.0", "turbo": "^2.6.3", + "typescript": "^5.9.3", "untun": "^0.1.3" }, "engines": { diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 0b18acd8..1cef1ed9 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -33,6 +33,12 @@ import { } from "../../constants.js"; import { formatShortcut } from "../../utils/format-shortcut.js"; import { freezeUpdates } from "../../utils/freeze-updates.js"; +import { + freezeGlobalAnimations, + unfreezeGlobalAnimations, + freezePseudoStates, + unfreezePseudoStates, +} from "../../utils/freeze-animations.js"; import { Tooltip } from "../tooltip.jsx"; interface ToolbarProps { @@ -974,7 +980,7 @@ export const Toolbar: Component = (props) => { class={cn( "grid transition-all duration-150 ease-out", isCollapsed() - ? "grid-cols-[0fr] opacity-0" + ? "grid-cols-[0fr] opacity-0 pointer-events-none" : "grid-cols-[1fr] opacity-100", )} > @@ -1010,6 +1016,8 @@ export const Toolbar: Component = (props) => { props.onSelectHoverChange?.(true); if (!unfreezeUpdatesCallback) { unfreezeUpdatesCallback = freezeUpdates(); + freezeGlobalAnimations(); + freezePseudoStates(); } }} onMouseLeave={() => { @@ -1018,6 +1026,8 @@ export const Toolbar: Component = (props) => { if (!props.isActive && !props.isContextMenuOpen) { unfreezeUpdatesCallback?.(); unfreezeUpdatesCallback = null; + unfreezeGlobalAnimations(); + unfreezePseudoStates(); } }} > diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index c2eb7fc6..17679e7e 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -51,6 +51,8 @@ export const ARROW_HEIGHT_PX = 8; export const ARROW_CENTER_PERCENT = 50; export const LABEL_GAP_PX = 4; export const MAX_HTML_FALLBACK_LENGTH = 500; +export const DEFAULT_STACK_CONTEXT_LINES = 3; +export const SOURCE_CONTEXT_LINES = 3; export const PREVIEW_ATTR_VALUE_MAX_LENGTH = 15; export const PREVIEW_MAX_ATTRS = 3; diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 77eb1743..682c9978 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -2,8 +2,11 @@ import { isSourceFile, normalizeFileName, getOwnerStack, + sourceMapCache, + getSourceMap, StackFrame, } from "bippy/source"; +import type { SourceMap } from "bippy/source"; import { isCapitalized } from "../utils/is-capitalized.js"; import { getFiberFromHostInstance, @@ -13,10 +16,12 @@ import { traverseFiber, } from "bippy"; import { + DEFAULT_STACK_CONTEXT_LINES, MAX_HTML_FALLBACK_LENGTH, PREVIEW_ATTR_VALUE_MAX_LENGTH, PREVIEW_MAX_ATTRS, PREVIEW_PRIORITY_ATTRS, + SOURCE_CONTEXT_LINES, } from "../constants.js"; const NEXT_INTERNAL_COMPONENT_NAMES = new Set([ @@ -55,6 +60,163 @@ const REACT_INTERNAL_COMPONENT_NAMES = new Set([ "SuspenseList", ]); +interface SourceMapData { + [filename: string]: string; +} + +const isWeakRef = ( + value: SourceMap | WeakRef, +): value is WeakRef => + typeof WeakRef !== "undefined" && value instanceof WeakRef; + +const resolveSourceMapFromCache = ( + cacheEntry: SourceMap | WeakRef | null, +): SourceMap | null => { + if (!cacheEntry) return null; + if (isWeakRef(cacheEntry)) return cacheEntry.deref() ?? null; + return cacheEntry; +}; + +const extractSourceMapData = (sourceMap: SourceMap): SourceMapData => { + const result: SourceMapData = {}; + + const processSources = ( + sources: string[], + contents: (string | null)[] | undefined, + ) => { + if (!contents) return; + + for (let i = 0; i < sources.length; i++) { + const filename = normalizeFileName(sources[i]); + const content = contents[i]; + if (filename && content && !result[filename]) { + result[filename] = content; + } + } + }; + + if (sourceMap.sections) { + for (const section of sourceMap.sections) { + processSources(section.map.sources, section.map.sourcesContent); + } + } + + processSources(sourceMap.sources, sourceMap.sourcesContent); + return result; +}; + +const hasJavaScriptType = (script: Element): boolean => { + const type = script.getAttribute("type")?.toLowerCase().trim(); + return ( + !type || + type === "text/javascript" || + type === "application/javascript" || + type === "module" + ); +}; + +const getScriptUrls = (): string[] => { + if (typeof document === "undefined") return []; + + const urls = new Set(); + + for (const script of Array.from(document.querySelectorAll("script[src]"))) { + const src = script.getAttribute("src"); + if (!src || !hasJavaScriptType(script)) continue; + + try { + const url = new URL(src, window.location.href).href; + if (url.split("?")[0].endsWith(".js")) { + urls.add(url); + } + } catch { + continue; + } + } + + return Array.from(urls); +}; + +const getSourceMapDataFromCache = (): SourceMapData => { + const result: SourceMapData = {}; + + for (const cacheEntry of sourceMapCache.values()) { + const sourceMap = resolveSourceMapFromCache(cacheEntry); + if (sourceMap) { + Object.assign(result, extractSourceMapData(sourceMap)); + } + } + + return result; +}; + +const getSourceMapDataForUrl = async (url: string): Promise => { + try { + const sourceMap = await getSourceMap(url, true); + return sourceMap ? extractSourceMapData(sourceMap) : {}; + } catch { + return {}; + } +}; + +const getSourceMapDataFromScripts = async (): Promise => { + const cachedUrls = new Set(sourceMapCache.keys()); + const uncachedUrls = getScriptUrls().filter((url) => !cachedUrls.has(url)); + + const results = await Promise.all( + uncachedUrls.map((url) => getSourceMapDataForUrl(url)), + ); + + const combined: SourceMapData = {}; + for (const data of results) { + Object.assign(combined, data); + } + return combined; +}; + +let sourceMapDataCache: SourceMapData | null = null; +let sourceMapDataPromise: Promise | null = null; + +const getSourceMapData = async (): Promise => { + if (sourceMapDataCache) return sourceMapDataCache; + + if (!sourceMapDataPromise) { + sourceMapDataPromise = (async () => { + const cached = getSourceMapDataFromCache(); + const fromScripts = await getSourceMapDataFromScripts(); + sourceMapDataCache = { ...cached, ...fromScripts }; + return sourceMapDataCache; + })(); + } + + return sourceMapDataPromise; +}; + +const getFileExtension = (filename: string): string => { + const match = filename.match(/\.([^.]+)$/); + return match ? match[1] : "js"; +}; + +const getSourceContext = ( + fileContent: string, + lineNumber: number, + contextLines: number, +): string => { + const lines = fileContent.split("\n"); + const startLine = Math.max(0, lineNumber - contextLines - 1); + const endLine = Math.min(lines.length, lineNumber + contextLines); + + const contextSnippet: string[] = []; + const maxLineNumWidth = String(endLine).length; + + for (let i = startLine; i < endLine; i++) { + const lineNum = String(i + 1).padStart(maxLineNumWidth, " "); + contextSnippet.push(`${lineNum} | ${lines[i]}`); + } + + return contextSnippet.join("\n"); +}; + export const checkIsNextProject = (): boolean => { if (typeof document === "undefined") return false; return Boolean( @@ -197,15 +359,17 @@ export const getElementContext = async ( element: Element, options: GetElementContextOptions = {}, ): Promise => { - const { maxLines = 3 } = options; + const { maxLines = DEFAULT_STACK_CONTEXT_LINES } = options; const stack = await getStack(element); const html = getHTMLPreview(element); if (hasSourceFiles(stack)) { const isNextProject = checkIsNextProject(); + const sourceMapData = isNextProject ? await getSourceMapData() : {}; const stackContext: string[] = []; if (stack) { + let didAttachSourceSnippet = false; for (const frame of stack) { if (stackContext.length >= maxLines) break; @@ -219,26 +383,47 @@ export const getElementContext = async ( ); continue; } - if (frame.fileName && isSourceFile(frame.fileName)) { - let line = "\n in "; - const hasComponentName = - frame.functionName && - checkIsSourceComponentName(frame.functionName); - if (hasComponentName) { - line += `${frame.functionName} (at `; + if (frame.fileName && isSourceFile(frame.fileName)) { + const filename = normalizeFileName(frame.fileName); + const fileContent = sourceMapData[filename]; + + if ( + !didAttachSourceSnippet && + isNextProject && + fileContent && + frame.lineNumber + ) { + didAttachSourceSnippet = true; + const extension = getFileExtension(filename); + const sourceContext = getSourceContext( + fileContent, + frame.lineNumber, + SOURCE_CONTEXT_LINES, + ); + const locationInfo = frame.columnNumber + ? `${filename}:${frame.lineNumber}:${frame.columnNumber}` + : `${filename}:${frame.lineNumber}`; + + stackContext.push( + `\n\n\`\`\`${extension}\n${sourceContext}\n\`\`\`\nat ${locationInfo}`, + ); + continue; } - line += normalizeFileName(frame.fileName); + const isValidSourceComponent = + frame.functionName && + checkIsSourceComponentName(frame.functionName); // HACK: bundlers like vite mess up the line number and column number - if (isNextProject && frame.lineNumber && frame.columnNumber) { - line += `:${frame.lineNumber}:${frame.columnNumber}`; - } - - if (hasComponentName) { - line += `)`; - } + const locationSuffix = + isNextProject && frame.lineNumber && frame.columnNumber + ? `:${frame.lineNumber}:${frame.columnNumber}` + : ""; + + const line = isValidSourceComponent + ? `\n in ${frame.functionName} (at ${filename}${locationSuffix})` + : `\n in ${filename}${locationSuffix}`; stackContext.push(line); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91ce0c3e..39442140 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: turbo: specifier: ^2.6.3 version: 2.6.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 untun: specifier: ^0.1.3 version: 0.1.3 @@ -10227,7 +10230,7 @@ snapshots: eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.37.0(jiti@2.6.1)) @@ -10260,7 +10263,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -10329,7 +10332,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9